This commit is contained in:
Oleg Maslov
2026-09-02 10:14:22 +02:00
parent 0c3e2ead3b
commit b20b138fe4
771 changed files with 161561 additions and 9027 deletions

View File

@@ -42,6 +42,7 @@ DATABASE_URL=postgres://waggle:waggle_dev@localhost:5434/waggle
REDIS_URL=redis://localhost:6381
CLERK_SECRET_KEY=sk_test_...
CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_WEBHOOK_SIGNING_SECRET=
PORT=3100
CORS_ORIGIN=http://localhost:8080

5
.gitattributes vendored
View File

@@ -59,3 +59,8 @@ decisions/*.md -text
# are consumed by `curl | bash` on Linux/macOS. A CRLF checkout would break the
# shebang and `read`/`printf` parsing, so pin them to LF regardless of core.autocrlf.
*.sh text eol=lf
# Tauri rewrites these tracked files during Windows packaging. Keep checkout and
# generated bytes stable so post-build mutation checks report semantic drift only.
app/src-tauri/Cargo.toml text eol=lf
app/src-tauri/gen/schemas/*.json text eol=lf

19
.github/sync.md vendored
View File

@@ -1,14 +1,13 @@
> **⚠️ DEPRECATED (2026-04-30 monorepo migration) — HISTORICAL/AUDIT REFERENCE ONLY.**
> This manual describes the dual-repo bidirectional-sync mechanism that ran while the
> substrate lived in BOTH waggle-os (`packages/core/src/{mind,harvest}/`) and an external
> `marolinik/hive-mind`. After the migration the substrate lives ONLY at
> **`packages/hive-mind-core/src/{mind,harvest}/`**, and the OSS mirror is **generated** via
> `git subtree split` — see [`packages/hive-mind-core/CONTRIBUTING.md`](../packages/hive-mind-core/CONTRIBUTING.md)
> and [`scripts/oss-subtree-split.sh`](../scripts/oss-subtree-split.sh). The
> `mind-parity-check.yml` / `sync-mind.yml` workflows referenced below are **inert deprecation
> anchors** (their `packages/core/src/...` trigger paths no longer exist, so they never fire).
> Everything below is retained for historical context — do NOT treat it as the active process.
> See CLAUDE.md §7.5 for the current mechanism.
> This manual describes the retired dual-repo bidirectional-sync mechanism. The canonical
> substrate now lives only at **`packages/hive-mind-core/src/{mind,harvest}/`**. Its public
> mirror has a different layout and is updated through a maintainer-curated forward-port that
> removes excluded files and interleaved `install_audit` logic. A raw subtree split is unsafe
> and must never be pushed; [`scripts/oss-subtree-split.sh`](../scripts/oss-subtree-split.sh)
> is inspection-only. The workflows below are inert deprecation anchors because their old
> `packages/core/src/...` trigger paths no longer exist. Everything after this banner is
> historical and must not be treated as current instructions. See AGENTS.md §7.5 and
> [`packages/hive-mind-core/CONTRIBUTING.md`](../packages/hive-mind-core/CONTRIBUTING.md).
---

View File

@@ -60,10 +60,12 @@ jobs:
run: |
npm test -- \
--exclude=packages/cli/tests/cli-runtime.test.ts \
--exclude=packages/marketplace/tests/cli-runtime.test.ts \
--exclude=packages/hive-mind-cli/tests/cli-help.test.ts \
--exclude=packages/hive-mind-mcp-server/tests/runtime.test.ts \
--exclude=packages/launcher/tests/cli.test.ts \
--exclude=packages/memory-mcp/tests/runtime.test.ts
--exclude=packages/memory-mcp/tests/runtime.test.ts \
--maxWorkers=2
# These tests each create a temporary project and run npm install. Running
# several cold installs in parallel makes individual test timeouts measure
@@ -72,6 +74,7 @@ jobs:
run: |
npx vitest run \
packages/cli/tests/cli-runtime.test.ts \
packages/marketplace/tests/cli-runtime.test.ts \
packages/hive-mind-cli/tests/cli-help.test.ts \
packages/hive-mind-mcp-server/tests/runtime.test.ts \
packages/launcher/tests/cli.test.ts \

View File

@@ -19,6 +19,9 @@ on:
- 'packages/hive-mind-mcp-server/**'
- 'packages/hive-mind-wiki-compiler/**'
- 'packages/hive-mind-hooks-claude-code/**'
- 'vendor/pptxgenjs/**'
- 'package.json'
- 'package-lock.json'
- '.github/workflows/hive-mind-cli-cross-platform.yml'
pull_request:
branches: [main]
@@ -26,6 +29,10 @@ on:
- 'packages/hive-mind-cli/**'
- 'packages/hive-mind-shim-core/**'
- 'packages/hive-mind-core/**'
- 'vendor/pptxgenjs/**'
- 'package.json'
- 'package-lock.json'
- '.github/workflows/hive-mind-cli-cross-platform.yml'
jobs:
install-and-smoke:
@@ -47,7 +54,7 @@ jobs:
node-version: ${{ matrix.node-version }}
- name: Install workspace deps
run: npm install
run: npm ci
# hive-mind-core imports @waggle/shared, whose dist/ is gitignored and so
# absent on a clean checkout. hive-mind-core/tsconfig declares no project

View File

@@ -5,15 +5,17 @@ name: mind-parity-check
# This workflow ran the (then-external) hive-mind repo's mind/+harvest/ tests
# against waggle-os's substrate to verify behavioral parity while the same code
# lived in both repos. After CC Sesija B migration, the substrate lives ONLY in
# waggle-os/packages/hive-mind-core/, and the OSS mirror at
# github.com/marolinik/hive-mind is generated FROM waggle-os via subtree-split
# (not maintained as a parallel codebase). Parity checking is therefore
# definitionally trivial — the OSS export is byte-identical to its source.
# waggle-os/packages/hive-mind-core/. The OSS mirror at
# github.com/marolinik/hive-mind is a separately curated layout produced from
# that canonical source. It is intentionally not byte-identical: imports and
# layout differ, and excluded files plus interleaved `install_audit` logic are
# removed during the maintainer-reviewed forward-port.
#
# This workflow is preserved as the deprecation anchor. It will NOT fire on
# push because trigger paths (packages/core/src/mind/**) no longer exist as
# tracked content. See sync-mind.yml's deprecation note for the full migration
# context.
# context. Do not reactivate this injection workflow as a substitute for the
# current drift review, curated export, and public-mirror test gates.
# Memory Sync Repair Step 3.1. Verifies that hive-mind's mind/ + harvest/
# tests pass against waggle-os's substrate. The check runs the waggle-os
@@ -48,6 +50,9 @@ concurrency:
jobs:
parity-check:
# Permanently inert deprecation anchor. The old test-injection model is not a
# safe substitute for the curated forward-port and drift-review workflow.
if: ${{ false }}
name: hive-mind ↔ waggle-os mind substrate parity
runs-on: ubuntu-latest
timeout-minutes: 15

File diff suppressed because it is too large Load Diff

View File

@@ -3,15 +3,16 @@ name: sync-mind-to-hive-mind
# DEPRECATED 2026-04-30 — CC Sesija B monorepo migration §2.6 Task B22.
#
# This workflow was the bidirectional-sync mechanism between waggle-os and the
# now-archived `marolinik/hive-mind` repo while substrate code lived in BOTH
# public `marolinik/hive-mind` repo while substrate code lived in BOTH
# places (packages/core/src/mind/ + packages/core/src/harvest/ in waggle-os,
# duplicated in hive-mind/packages/core/src/{mind,harvest}/).
#
# After CC Sesija B migration (commits ff5b4aa..b59d188 on
# feature/hive-mind-monorepo-migration), the substrate lives ONLY in
# waggle-os/packages/hive-mind-core/. The OSS distribution mechanism is now
# `git subtree split` from waggle-os monorepo to public mirror — see
# `scripts/oss-subtree-split.sh` and `packages/hive-mind-core/CONTRIBUTING.md`.
# waggle-os/packages/hive-mind-core/. The public mirror now has a curated layout
# and is updated through a maintainer-reviewed forward-port that removes excluded
# files and interleaved `install_audit` logic. Raw subtree branches are unsafe
# publish sources; see `packages/hive-mind-core/CONTRIBUTING.md`.
#
# This workflow is preserved for AUDIT TRAIL purposes (the historical
# trigger paths and concurrency settings are referenced in EXTRACTION.md and
@@ -21,9 +22,9 @@ name: sync-mind-to-hive-mind
# on commit 3b556c0.
#
# DO NOT delete this file as part of cleanup — leave it as the deprecation
# anchor. If the workflow ever needs reactivation, trigger paths must be
# updated to the new packages/hive-mind-core/ location AND the
# @hive-mind ↔ @waggle name remapping must be added.
# anchor. Do not reactivate it by changing trigger paths: its filtered-patch
# model cannot safely remove interleaved proprietary schema logic. Build any
# future automation from the curated-forward-port contract instead.
# Memory Sync Repair Step 3.2 — waggle-os → hive-mind direction.
#
@@ -59,6 +60,8 @@ concurrency:
jobs:
open-hive-mind-pr:
# Permanently inert deprecation anchor. This filtered-patch publisher cannot
# enforce the current interleaved proprietary-content boundary.
name: Open auto-sync PR to marolinik/hive-mind
runs-on: ubuntu-latest
timeout-minutes: 10
@@ -68,7 +71,7 @@ jobs:
# Configured by Marko via `gh secret set HIVE_MIND_SYNC_TOKEN`.
# Without the secret, the job fails fast with a documented error
# rather than silently skipping.
if: ${{ vars.MIND_SYNC_ENABLED == 'true' }}
if: ${{ false }}
steps:
- name: Checkout waggle-os (full history for the diff)

View File

@@ -24,6 +24,7 @@ on:
- 'scripts/**'
- 'package.json'
- 'package-lock.json'
- '.github/workflows/release.yml'
- '.github/workflows/tauri-build-pr.yml'
push:
branches:
@@ -35,144 +36,251 @@ on:
- 'scripts/**'
- 'package.json'
- 'package-lock.json'
- '.github/workflows/release.yml'
- '.github/workflows/tauri-build-pr.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
verify-windows:
runs-on: windows-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
node-version: 22.23.2
cache: npm
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4
with:
toolchain: 1.94.0
- name: Rust cache
uses: Swatinem/rust-cache@v2
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
workspaces: app/src-tauri
- name: Install dependencies
run: npm install
run: npm ci
- name: Verify Windows skill-audit and vault regressions
shell: pwsh
run: node node_modules/vitest/vitest.mjs run --root . --config vitest.config.ts packages/agent/tests/skill-audit-store.test.ts packages/core/tests/vault.test.ts --maxWorkers=1 --no-file-parallelism
- name: Verify Windows release-mode and publication guards
shell: pwsh
env:
WAGGLE_REQUIRE_PWSH7: '1'
run: node node_modules/vitest/vitest.mjs run --root . --config vitest.config.ts packages/server/tests/tauri-config.test.ts -t "CI/CD Configuration"
- name: Install locked Tauri CLI
run: npm ci --prefix app --ignore-scripts
- name: Build packages (shared → core → agent → server)
run: npm run build:packages
- name: Bundle Node.js runtime
run: node scripts/bundle-node.mjs
- name: Build sidecar
run: node scripts/build-sidecar.mjs
- name: Bundle native dependencies
run: node scripts/bundle-native-deps.mjs
- name: Bundle Node.js runtime
run: node scripts/bundle-node.mjs
- name: Stage sidecar dependencies
run: node scripts/stage-sidecar-deps.mjs
- name: Verify packaged hook lifecycles
run: node node_modules/vitest/vitest.mjs run --root . --config vitest.config.ts packages/agent/tests/hook-packages-runtime.test.ts -t "runs staged Tauri hook lifecycles"
env:
WAGGLE_VERIFY_STAGED_HOOK_RUNTIME: '1'
- name: Build frontend
run: cd apps/web && npx vite build
- name: Verify repository cleanliness before Tauri build
shell: pwsh
run: |
$sourceStatus = @(& git status --porcelain=v1 --untracked-files=all)
if ($LASTEXITCODE -ne 0) { throw 'Could not inspect repository source state before Tauri build.' }
if ($sourceStatus.Count -ne 0) {
$sourceDetails = $sourceStatus -join [Environment]::NewLine
throw "Repository must be clean before Windows Tauri build.$([Environment]::NewLine)$sourceDetails"
}
- name: Build Tauri (Windows)
# @tauri-apps/cli is declared in app/package.json devDeps but is absent
# from package-lock.json, so `npm install` never installs it and a bare
# `npx tauri` errors "could not determine executable to run". Fetch the
# CLI explicitly by package name (npx resolves the win32 binary).
run: cd app && npx --yes @tauri-apps/cli@2 build
id: tauri-build-windows
# app/package-lock.json pins the CLI and platform binary. Invoke that
# local copy so verification builds cannot drift to a newer 2.x release.
run: cd app && node node_modules/@tauri-apps/cli/tauri.js build --bundles nsis
env:
# Skip code signing for PR verification — release.yml handles signing
# only on tag push.
TAURI_PRIVATE_KEY: ''
TAURI_KEY_PASSWORD: ''
- name: Report repository changes after Tauri build
if: ${{ always() && steps.tauri-build-windows.outcome != 'skipped' }}
shell: pwsh
run: |
$sourceStatus = @(& git status --porcelain=v1 --untracked-files=all)
if ($LASTEXITCODE -ne 0) { throw 'Could not inspect repository source state after Tauri build.' }
if ($sourceStatus.Count -ne 0) {
$trackedPaths = @(& git diff --name-only --diff-filter=ACDMRTUXB)
if ($LASTEXITCODE -ne 0) { throw 'Could not enumerate tracked Tauri build mutations.' }
foreach ($trackedPath in $trackedPaths) {
Write-Host "::group::Tracked mutation: $trackedPath"
$headBlob = @(& git rev-parse "HEAD:$trackedPath" 2>&1) -join ''
$headBlobExit = $LASTEXITCODE
$indexBlob = @(& git rev-parse ":$trackedPath" 2>&1) -join ''
$indexBlobExit = $LASTEXITCODE
if (Test-Path -LiteralPath $trackedPath -PathType Leaf) {
$worktreeHash = (Get-FileHash -LiteralPath $trackedPath -Algorithm SHA256).Hash.ToLowerInvariant()
$filteredBlob = @(& git hash-object "--path=$trackedPath" -- $trackedPath 2>&1) -join ''
$filteredBlobExit = $LASTEXITCODE
$resolvedPath = (Resolve-Path -LiteralPath $trackedPath).Path
$bytes = [IO.File]::ReadAllBytes($resolvedPath)
$crlfCount = 0
$lfOnlyCount = 0
for ($index = 0; $index -lt $bytes.Length; $index += 1) {
if ($bytes[$index] -ne 10) { continue }
if ($index -gt 0 -and $bytes[$index - 1] -eq 13) { $crlfCount += 1 }
else { $lfOnlyCount += 1 }
}
} else {
$worktreeHash = '<missing>'
$filteredBlob = '<missing>'
$filteredBlobExit = 0
$crlfCount = 0
$lfOnlyCount = 0
}
Write-Host "HEAD blob (exit $headBlobExit): $headBlob"
Write-Host "Index blob (exit $indexBlobExit): $indexBlob"
Write-Host "Git-filtered worktree blob (exit $filteredBlobExit): $filteredBlob"
Write-Host "Raw worktree SHA256: $worktreeHash; CRLF=$crlfCount; LF-only=$lfOnlyCount"
$changeSummary = @(& git diff --numstat -- $trackedPath)
$changeSummaryExit = $LASTEXITCODE
if ($changeSummaryExit -ne 0) {
Write-Host "Could not render numeric diff summary (exit $changeSummaryExit)."
} else {
Write-Host "Numeric diff summary: $($changeSummary -join '; ')"
}
Write-Host '::endgroup::'
}
$sourceDetails = $sourceStatus -join [Environment]::NewLine
throw "Windows Tauri build mutated repository worktree.$([Environment]::NewLine)$sourceDetails"
}
- name: Certify Windows Solo installer lifecycle
shell: pwsh
run: |
$installers = @(Get-ChildItem -LiteralPath 'app/src-tauri/target' -Recurse -Filter '*-setup.exe' -File | Where-Object { $_.DirectoryName -match '[\\/]bundle[\\/]nsis$' })
if ($installers.Count -ne 1) { throw "Expected exactly one NSIS setup executable, found $($installers.Count)" }
$installer = $installers[0]
& ./scripts/certify-windows-installer.ps1 -InstallerPath $installer.FullName -ExpectedSourceRevision $env:GITHUB_SHA
- name: Upload Windows artifacts
uses: actions/upload-artifact@v4
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: waggle-windows-${{ github.sha }}
path: |
app/src-tauri/target/release/bundle/nsis/*.exe
app/src-tauri/target/release/bundle/msi/*.msi
if-no-files-found: warn
app/src-tauri/target/**/bundle/nsis/*.exe
app/src-tauri/target/**/bundle/nsis/windows-installer-certificate.json
if-no-files-found: error
retention-days: 7
verify-macos:
runs-on: macos-latest
timeout-minutes: 60
strategy:
# Per-arch, matching release.yml. Universal builds are rejected by the
# bundle scripts (sqlite-vec / onnxruntime / node ship per-arch binaries),
# so each arch is staged and built separately.
matrix:
target: [aarch64-apple-darwin, x86_64-apple-darwin]
include:
- target: aarch64-apple-darwin
arch: arm64
runner: macos-15
bundles: dmg
artifact_path: app/src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/*.dmg
- target: x86_64-apple-darwin
arch: x64
runner: macos-15-intel
bundles: app
artifact_path: app/src-tauri/target/x86_64-apple-darwin/release/bundle/macos/*.app
runs-on: ${{ matrix.runner }}
env:
TARGET_ARCH: ${{ matrix.arch }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
node-version: 22.23.2
cache: npm
- name: Verify runner architecture
run: node -e "if (process.arch !== process.env.TARGET_ARCH) { console.error('Expected ' + process.env.TARGET_ARCH + ' runner, got ' + process.arch); process.exit(1); }"
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4
with:
toolchain: 1.94.0
targets: ${{ matrix.target }}
- name: Rust cache
uses: Swatinem/rust-cache@v2
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
workspaces: app/src-tauri
- name: Install dependencies
run: npm install
run: npm ci
- name: Install locked Tauri CLI
run: npm ci --prefix app --ignore-scripts
- name: Build packages (shared → core → agent → server)
run: npm run build:packages
- name: Bundle Node.js runtime
run: node scripts/bundle-node.mjs
- name: Build sidecar
run: node scripts/build-sidecar.mjs
- name: Bundle native dependencies
run: node scripts/bundle-native-deps.mjs
env:
TARGET_ARCH: ${{ matrix.target == 'aarch64-apple-darwin' && 'arm64' || 'x64' }}
- name: Bundle Node.js runtime
run: node scripts/bundle-node.mjs
env:
TARGET_ARCH: ${{ matrix.target == 'aarch64-apple-darwin' && 'arm64' || 'x64' }}
- name: Stage sidecar dependencies
run: node scripts/stage-sidecar-deps.mjs
env:
TARGET_ARCH: ${{ matrix.target == 'aarch64-apple-darwin' && 'arm64' || 'x64' }}
- name: Build frontend
run: cd apps/web && npx vite build
- name: Build Tauri (macOS ${{ matrix.target }})
# See verify-windows note: fetch @tauri-apps/cli by package name (absent
# from the lockfile). Built per-arch — universal is rejected by the
# bundle scripts (per-arch native modules), matching release.yml.
run: cd app && npx --yes @tauri-apps/cli@2 build --target ${{ matrix.target }}
# See verify-windows note: use the app-local lockfile-pinned CLI.
# Built per-arch — universal is rejected by the bundle scripts
# (per-arch native modules), matching release.yml.
run: cd app && node node_modules/@tauri-apps/cli/tauri.js build --target ${{ matrix.target }} --bundles ${{ matrix.bundles }}
env:
TAURI_PRIVATE_KEY: ''
TAURI_KEY_PASSWORD: ''
- name: Upload macOS artifacts
uses: actions/upload-artifact@v4
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: waggle-macos-${{ matrix.target }}-${{ github.sha }}
path: |
app/src-tauri/target/${{ matrix.target }}/release/bundle/dmg/*.dmg
app/src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app
if-no-files-found: warn
path: ${{ matrix.artifact_path }}
if-no-files-found: error
retention-days: 7

25
.gitignore vendored
View File

@@ -15,6 +15,11 @@ dist
dist-ssr
*.local
# Local generated analysis and runtime build artifacts
coverage/
dist-analyze/
node-compile-cache/
# Environment
.env
.env.local
@@ -198,7 +203,6 @@ app/src-tauri/resources/native/
# hook). A committed copy goes stale silently — a binary shipping an old server
# is a release-stopping defect class (UX-Refactor P4 ruling).
app/src-tauri/resources/service.js
app/src-tauri/resources/service.js.map
# Editor directories and files
.vscode/*
@@ -219,6 +223,8 @@ app/src-tauri/resources/service.js.map
playwright-report/
test-results/
tests/screenshots/
/.playwright-cli/
/output/
s*.png
screen*.png
# …but never the committed persona avatars (sales-rep/support-agent match s*.png)
@@ -280,3 +286,20 @@ bun.lock
# Local plaintext API keys — never commit
AI API KEYS.txt
# Sesija C external dependencies — vendored per-clone, not committed
# (ARE platform installed under external/meta-agents-research-environments;
# pinned SHA tracked in benchmarks/gaia2/README.md)
external/
# Sesija C per-run output dirs — large JSONL traces, not committed
# (Phase 2 smoke evidence summarized in benchmarks/gaia2/smoke-evidence.md;
# Phase 4 dry run JSONLs stay local + summarized in dry-run-results-memo.md)
benchmarks/gaia2/runs/*
!benchmarks/gaia2/runs/.gitkeep
# Sesija C per-clone Gaia2 task dumps — produced by dump-tasks.py from HF,
# large JSONL (2.442.67M chars/scenario), not committed. Phase 3b-B
# regenerates locally; Phase 4 Docker run uses ARE-native HF loader directly.
benchmarks/gaia2/data/*
!benchmarks/gaia2/data/.gitkeep

136
AGENTS.md
View File

@@ -16,8 +16,9 @@ If you're about to write code, **Section 3** is the most important thing you'll
## 1. What Waggle OS Actually Is
**Waggle OS** is a workspace-native AI agent platform with persistent memory. It ships as a
Tauri 2.0 desktop binary for Windows and macOS, with a Vite-bundled web app and a Node.js sidecar.
**Waggle OS** is a workspace-native AI agent platform with persistent memory. The active release
candidate is a Windows-first Tauri 2.0 desktop app with a Vite-bundled web UI and a bundled Node.js
sidecar. macOS packaging, signing, notarization, and runtime certification are roadmap work.
**Strategic function:** Waggle is the demand-creation and qualification engine for KVARK —
Egzakta Group's sovereign enterprise AI platform.
@@ -38,23 +39,52 @@ Egzakta Group's sovereign enterprise AI platform.
and connectors are all free (they generate memory). Team collaboration (shared memory,
WaggleDance, governance) is the upgrade trigger.
### Key Technology Facts (Verified April 2026)
### Current Release Qualification Contract (2026-08-22)
- Launch gate: **Windows Solo only**.
- In-scope external-agent release cohort: **Claude Code, Codex, and Hermes**. Each integration
uses the user's own installed client and its official user authentication.
- **Cursor and OpenClaw are roadmap-only**: detection metadata may remain, but production launch,
hooks, Fleet/task dispatch, and direct run routes must fail closed for them.
- Claude Desktop, Codex Desktop, and Hermes Desktop may remain as detected convenience launch
surfaces; they are not separate memory-hook or agent-acceptance targets in this release gate.
- ChatGPT/OpenAI is a model/provider and memory-import surface, not a separate launcher target.
- The Windows Solo qualification receipt must prove the bundled Node sidecar, no-Python
OpenAI-compatible proxy, Waggle-managed local runtime/model, default in-process embedding path,
and freedom from developer Node, Docker, Python, external LiteLLM, or a separately installed
Ollama. A separate revision-bound receipt must prove smart-router primary, compact-tool-context,
budget, and fallback paths and may carry forward only under the launch recommendation's bounded
no-impact rule; user-installed Ollama remains optional.
- Persona release evidence requires a complete 10-persona x 3-run collection with every result
at or above 95/100 after any explicitly documented independent semantic adjudication, plus a
final-HEAD no-impact attestation or a fresh 30/30 rerun when intervening behavior changed. Never
relabel a non-gating collection as a canonical deterministic seal.
- Do not claim release approval until the current launch recommendation's exact-HEAD gates pass.
- Exact candidate revisions, installer hashes, local receipt hashes, carry-forward limits, and the
current verdict live only in `docs/production-readiness/09-LAUNCH_RECOMMENDATION.md`. Do not copy
an old candidate's evidence forward merely because a later branch contains its commits.
- Public GO still requires a publicly trusted Authenticode artifact and a sealed managed Deep
Security report for the exact approved release-tag commit, with no unresolved Critical/High.
- The repository remains private until an explicit open-source and licensing decision is made.
### Key Technology Facts (Verified August 2026)
| Layer | Stack |
|---|---|
| Frontend | React **19** + TypeScript + Vite + Tailwind 4 + base-ui/react |
| Desktop | Tauri 2.0 (Rust shell) |
| Backend | Fastify sidecar (Node.js, bundled into Tauri) |
| LLM routing | LiteLLM (see `litellm-config.yaml`) |
| LLM routing | Bundled no-Python OpenAI-compatible proxy for Windows Solo; optional LiteLLM deployment config |
| Database | SQLite via @waggle/core (better-sqlite3 + sqlite-vec-windows-x64) |
| Memory | FrameStore + HybridSearch + KnowledgeGraph + IdentityLayer + AwarenessLayer |
| Agent runtime | `packages/agent/src/agent-loop.ts` |
| Billing | Stripe (installed; `stripe@^21.0.1`) |
| Design | Hive DS — honey #e5a000 / hive-950 #08090c / accent #a78bfa |
| Tests | Vitest (unit) + Playwright (E2E) |
| Deploy | Dockerfile + docker-compose.production.yml + render.yaml |
| Deploy | Windows Tauri installer; optional Dockerfile + docker-compose.production.yml + render.yaml |
Package manager: npm (root) with `bun.lock` also present. Node >= 20.
Package manager: npm with the root `package-lock.json`. Source development requires Node
`^20.19.0 || >=22.12.0`; the packaged Windows desktop runtime is pinned to Node `22.23.2`.
---
@@ -67,7 +97,7 @@ waggle-os/
├── apps/
│ ├── web/ # <-- MAIN web app UI (this is where most components live)
│ └── www/ # Landing page (waggle-os.ai)
├── packages/ # 16 workspace packages (see below)
├── packages/ # 28 workspace packages (see below)
├── sidecar/ # Node.js sidecar bundled into Tauri
├── scripts/ # build-sidecar, bundle-native-deps, bundle-node
├── tests/ # Cross-cutting integration tests
@@ -81,7 +111,7 @@ waggle-os/
└── package.json (workspaces: apps/*, packages/*)
```
### Packages (`packages/`, 27 workspaces — verified 2026-05-28)
### Packages (`packages/`, 28 workspaces — verified 2026-08-02)
```
Core (15):
admin-web cli launcher marketplace
@@ -89,15 +119,15 @@ agent core memory-mcp optimizer
sdk server shared waggle-dance
weaver wiki-compiler worker
hive-mind OSS split (12synced to marolinik/hive-mind, see §7.5):
hive-mind OSS source set (13curated forward-port target is marolinik/hive-mind; see §7.5):
hive-mind-core hive-mind-cli hive-mind-shim-core hive-mind-mcp-server
hive-mind-wiki-compiler
hive-mind-hooks-{Codex, Codex-desktop, codex, codex-desktop,
hive-mind-wiki-compiler hive-mind-hooks-core
hive-mind-hooks-{claude-code, claude-desktop, codex, codex-desktop,
cursor, hermes, openclaw}
```
> Note: the prior list said "16" and included `ui`, which has no `package.json`
> (not a workspace). Real count is 27. The 12 `hive-mind-*` packages were added
> since the April verification.
> (not a workspace). The live count is 28: 15 product packages and 13
> `hive-mind-*` packages.
### `packages/agent/src/` — MOST ACTIVE (94 .ts files + 4 subdirs)
@@ -152,10 +182,11 @@ Subdirs:
MOVED (2026-04-30 monorepo migration): the memory substrate `mind/` (db/schema/
identity/awareness/frames/sessions/search/knowledge/scoring/reconcile/ontology/
concept-tracker/entity-normalizer/evolution-runs/execution-traces/
improvement-signals/embedding-provider/*-embedder) and `harvest/` (chatgpt/Codex/
Codex/gemini/perplexity/pdf/plaintext/markdown/url/universal adapters +
improvement-signals/embedding-provider/*-embedder) and `harvest/` (chatgpt/claude/
claude-code/gemini/perplexity/pdf/plaintext/markdown/url/universal adapters +
pipeline.ts + dedup.ts) now live at **packages/hive-mind-core/src/{mind,harvest}/**,
NOT under packages/core/. The OSS mirror is generated from there via subtree-split (§7.5).
NOT under packages/core/. The OSS mirror is curated from there through a maintainer-reviewed
forward-port (§7.5); raw subtree branches are never publish sources.
```
For the deep-dive on what the mind/ substrate does, see [`docs/memory-architecture.md`](docs/memory-architecture.md).
@@ -219,6 +250,35 @@ npm run lint
> run the `packages/server` tsc above. (A real type error slipped through this
> way on 2026-05-28; see `docs/addictiveness-audit-2026-05-28/REDUNDANCY-AUDIT.md`.)
### Windows Solo release commands (PowerShell 7; frozen clean checkout)
```powershell
# Local build-host preparation (the installed desktop has none of these prerequisites).
npm ci
npm ci --prefix app --ignore-scripts
npm run build:packages
# Local unsigned smoke build only; this is not a releasable artifact.
npm --prefix app run tauri:build:win
# Optional internal-pilot build. Its private test root is not public trust.
npm --prefix app run tauri:build:win:pilot-signed
# Certify an internal candidate under a disposable Windows profile.
pwsh -NoProfile -File scripts/certify-windows-installer.ps1 `
-InstallerPath "<absolute-path-to-Waggle-setup.exe>" `
-ExpectedSourceRevision "<40-character-final-HEAD>" `
-VerifyManagedModel
```
Production signing is hosted-only. Do not use a local thumbprint, client secret, or
`sign-windows-artifact.ps1` substitute to create a release artifact. The exact-tag
`.github/workflows/release.yml` Azure OIDC chain is authoritative for production signing,
certification, attestation, and publication. Never treat an unsigned or internal-pilot build
as publicly trusted.
The certified installed desktop must not depend on developer Node.js, Python,
Docker, external LiteLLM, or a separately installed Ollama.
---
## 3. Behavioral Rules — How You Must Work
@@ -379,7 +439,7 @@ interface AgentPersona {
// guardrails + picker metadata (all optional, all shipped)
disallowedTools?: string[] // denylist — overrides tools[] on conflict
failurePatterns?: string[] // documented failure modes — shown in hover tooltip
isReadOnly?: boolean // true = no write tools ever (enforced in assembleToolPool)
isReadOnly?: boolean // true = no write tools after applyPersonaToolFilter/filterMcpToolsForPersona
tagline?: string // one sentence for picker hover
bestFor?: string[] // 3 example tasks in user-facing language
wontDo?: string // hard boundary statement
@@ -418,7 +478,7 @@ shows tagline + bestFor + wontDo. "Create Custom Persona" inline form POSTs to
## 7. Security Constraints (Non-Negotiable)
1. **Vault-only secrets.** API keys in Vault or `.env` (never committed). `.env.example` has key names only.
1. **Vault-only secrets.** API keys belong in Vault or an untracked local `.env`, never in Git. `.env.example` may contain non-secret development defaults, but never usable credentials or secrets.
2. **Injection defense.** `scanForInjection()` from `injection-scanner.ts` MUST be called on all connector/external input.
3. **No eval, no dynamic require.** Tauri WebView is restricted.
4. **Tauri IPC allowlist.** Explicit in `app/src-tauri/capabilities/`. Never `allowlist: all: true`.
@@ -428,7 +488,7 @@ shows tagline + bestFor + wontDo. "Create Custom Persona" inline form POSTs to
---
## 7.5. Memory Substrate Sync (waggle-os → hive-mind, subtree-split)
## 7.5. Memory Substrate Sync (waggle-os → hive-mind, curated forward-port)
The memory substrate lives at **`packages/hive-mind-core/src/{mind,harvest}/`** (moved from
`packages/core/src/` in the 2026-04-30 monorepo migration). The public OSS mirror at
@@ -443,14 +503,16 @@ directly on the OSS mirror.** Parity is NOT automatic — it broke once: the cro
(`inprocess-reranker.ts` + HybridSearch options) was written directly on `marolinik/hive-mind`
during the LoCoMo benchmark arc and existed ONLY there, discovered by the W4 recon and
reverse-ported in W4.2 (`f47ee8f`). Rules:
1. Substrate changes land in `packages/hive-mind-core/` here FIRST; the mirror is regenerated
via subtree-split afterward.
1. Substrate changes land in `packages/hive-mind-core/` here FIRST; the mirror is updated
through a reviewed, maintainer-curated forward-port afterward.
2. Benchmark/experiment work in a `D:/Projects/hive-mind` checkout is throwaway unless
reverse-ported here — port it the same arc, don't let it sit.
3. Run **`scripts/oss-drift-check.sh`** (file-level diff of the mapped src trees) before every
OSS release push and after any arc that touched a hive-mind checkout.
4. External PRs on the OSS repo are fine — the maintainer merges them back here via
subtree-pull, then re-splits.
3. Run **`node scripts/oss-drift-check.mjs D:/Projects/hive-mind`** before every OSS release
push and after any arc that touched a hive-mind checkout. The checker compares the live
mapped trees with an immutable reviewed baseline: parity and reviewed adaptations are
allowed, while known blockers, unreviewed differences, or forbidden exports keep exit 1.
4. External PRs on the OSS repo are fine — the maintainer intentionally ports accepted changes
back here first, then prepares the next curated forward-port.
=== END CRITICAL ===
=== CORRECTION — how the sync ACTUALLY works (2026-06-12 drift analysis) ===
@@ -481,8 +543,10 @@ The prior text here claimed the mirror is produced by `scripts/oss-subtree-split
**To work on the substrate or publish the OSS mirror:** see
[`packages/hive-mind-core/CONTRIBUTING.md`](./packages/hive-mind-core/CONTRIBUTING.md),
[`scripts/oss-subtree-split.sh`](./scripts/oss-subtree-split.sh) (inspection/guard only), and
[`scripts/oss-drift-check.sh`](./scripts/oss-drift-check.sh) (run before every release; note its
~50 "DIFFERS" are mostly OSS-adaptation noise — layout + import rewrites — not true drift).
[`scripts/oss-drift-check.mjs`](./scripts/oss-drift-check.mjs) (run before every release; its
immutable baseline distinguishes parity, reviewed adaptations, known blockers, unreviewed
differences, and forbidden exports; every blocker or unreviewed entry must be resolved or
explicitly re-baselined through maintainer review before an OSS release).
**Deprecated (do not rely on; do not delete):** the old dual-repo bidirectional-sync workflows
`.github/workflows/{mind-parity-check,sync-mind}.yml` and the `.github/sync.md` manual are **preserved
@@ -517,7 +581,7 @@ Grep before creating. These exist and are functional:
| `packages/core/src/telemetry.ts` | Telemetry pipeline |
| `packages/hive-mind-core/src/harvest/pipeline.ts` | Harvest adapters + dedup |
| `packages/core/src/compliance/` | Compliance + audit |
| `app/src/components/cockpit/` | Tauri cockpit UI |
| `apps/web/src/components/os/` | Main desktop cockpit UI loaded by Tauri |
---
@@ -556,7 +620,7 @@ Do not recreate or expose outside gating.
- **Premium harness reached HONEST 21/21** (May 2026 S1) — every pillar regression-locked + composing. Full agent suite 2657/2657. See `memory/project_session_handoff_0519_s1.md`.
**AI-OS arc (May 2026 S1/S2, 14 commits on origin):**
- Phase 0 — Tool detection PoC (`packages/agent/src/tool-detection.ts`) for all 7 supported AI tools, hermetic + cross-platform.
- Phase 0 — Tool detection PoC (`packages/agent/src/tool-detection.ts`) for eight registered AI-tool surfaces, hermetic + cross-platform. Registration is broader than release support.
- Phase 1A — WaggleDance v2 dispatcher branches wired (discovery/routed_share/model_recipe/knowledge_match/task_claim/model_recommendation).
- Phase 1B — Local sidecar surface (`/api/waggle-dance/signal` + `/signals`), SignalBus ring buffer, personal-tier-eligible.
- Phase 1C — Bridge: v2 bus → existing `/api/waggle/signals` UI stream (zero frontend changes).
@@ -565,7 +629,7 @@ Do not recreate or expose outside gating.
- Phase 2A — Launcher backend (`/api/tools/launch`, `/api/tools/hooks`).
- Phase 2B — LauncherApp dock surface (`apps/web/src/components/os/apps/LauncherApp.tsx`).
- Phase 3 — Skill diffusion (D1 fire → `skill_share` broadcast via `onSkillDistillationFire` callback).
- Phase 4 — Full 7-tool launch cohort + Mission Control inventory tile + Memory provenance badge + launch-with-prompt textarea + process tracker / 'Running' badge.
- Phase 4 — Eight-tool inventory/detection surface + Mission Control tile + Memory provenance badge + launch-with-prompt textarea + process tracker / 'Running' badge. The in-scope agent-integration release cohort is Claude Code, Codex, and Hermes; Cursor and OpenClaw are roadmap-only.
End-to-end: detect → install hooks (reversible) → launch with `WAGGLE_WORKSPACE_ID` env → hook captures → shim emitter → bus → bridge → UI. Rollback tag: `checkpoint/pre-ai-os-2026-05-20`. AI-OS exploration doc: `docs/plans/AI-OS-EXPLORATION-2026-05-19.md`.
@@ -574,7 +638,7 @@ End-to-end: detect → install hooks (reversible) → launch with `WAGGLE_WORKSP
|---|---|---|
| 1 | Spawn Agent + Dock wiring | P36 already wired in `Dock.tsx`+`Desktop.tsx`; P35 third-tier fallback (LiteLLM → runtime model → provider catalogs) landed `14942be`. Residual: runtime verification on a clean install. |
| 2 | Light mode finish | P40/P41 + CR-2 — semantic-token migration is done (no hive-950 references except a comment); remaining issues are render-time fine-tuning (BootScreen visual polish + a few header-styling judgments) that need a binary build to validate. |
| 3 | Wave 2/3 hook implementations | **Mostly DONE (corrected 2026-06-29).** 6 of 7 hook packages ship real bins: Codex + the 2026-06-01 Wave 2/3 port (codex, codex-desktop, cursor, hermes, openclaw). Only `hive-mind-hooks-Codex-desktop` remains a binless `export {}` stub (deferred MCP-bridge category). The dock (`LauncherApp.tsx`) now exposes hook install/verify/uninstall for all 6 via the corrected `HOOKS_COHORT` (was hardcoded `['Codex']`). Residual: Codex-desktop MCP-bridge hook only. |
| 3 | External-tool release cohort | **Windows Solo scope fixed 2026-08-02.** Claude Code, Codex, and Hermes are the in-scope agent-integration cohort. Cursor and OpenClaw implementations remain in-tree as roadmap work and are fail-closed in production surfaces. Claude Desktop, Codex Desktop, and Hermes Desktop are convenience launch surfaces, not separate agent-acceptance targets. |
**Closed during May 2026 backlog sweep:**
- ✅ OW-6 PersonaSwitcher two-tier — shipped via M-01 (`PersonaSwitcher.tsx` + `lib/persona-tier.ts` + `lib/persona-tooltip.ts`); 26/26 tests passing
@@ -623,17 +687,13 @@ For the full polish+launch backlog see `docs/plans/BACKLOG-CONSOLIDATED-2026-04-
| BEHAVIORAL_SPEC | Core agent rules (`packages/agent/src/behavioral-spec.ts`) |
| Sidecar | Node.js Fastify server bundled into Tauri (`/sidecar`) |
| KVARK | Egzakta sovereign enterprise AI — top of the Waggle funnel |
| LiteLLM | LLM routing layer (`litellm-config.yaml`) |
| LiteLLM | Optional server/team deployment proxy config (`litellm-config.yaml`); Windows Solo uses the bundled no-Python proxy and smart router |
| WaggleDance | Multi-agent coordination package (`packages/waggle-dance`) |
| Weaver | `packages/weaver` — (check source for current role) |
| Weaver | Memory consolidation and session-skill extraction engine (`packages/weaver`) |
| Evolution | Self-improvement subsystem (`evolution-*.ts`, `judge.ts`, `iterative-optimizer.ts`) |
| assembleToolPool | Per-persona tool filtering from allowlist + denylist (to implement) |
| applyPersonaToolFilter / filterMcpToolsForPersona | Enforced local and MCP per-persona allowlist/denylist filtering (`packages/server/src/local/persona-tool-filter.ts`) |
---
Maintained by Marko Markovic · Egzakta Group · April 2026
waggle-os.ai · www.kvark.ai
## Imported Claude Cowork project instructions
This is my app repo... use it for exploring and working. What ever you produce, you will put in a new folder cowork and store all there dont change the reo itself.

164
CLAUDE.md
View File

@@ -1,9 +1,9 @@
# CLAUDE.md — Waggle OS
### Authoritative Operating Contract · All Agents · All Contributors · All Sessions
### Claude operational companion
> Read this file in full before touching a single line of code.
> It is the single source of truth for architecture, strategic intent, and mechanical operating rules.
> If this file conflicts with any other document, **this file wins.**
> Read `AGENTS.md` in full before touching code. `AGENTS.md` is the canonical operating
> contract for all agents and wins on conflict. This file is a Claude-oriented companion;
> keep shared guidance aligned, but never treat it as a second source of truth.
---
@@ -16,8 +16,9 @@ If you're about to write code, **Section 3** is the most important thing you'll
## 1. What Waggle OS Actually Is
**Waggle OS** is a workspace-native AI agent platform with persistent memory. It ships as a
Tauri 2.0 desktop binary for Windows and macOS, with a Vite-bundled web app and a Node.js sidecar.
**Waggle OS** is a workspace-native AI agent platform with persistent memory. Its current desktop
release scope is Windows-first: a Tauri 2.0 app with a Vite-bundled web UI and bundled Node.js
sidecar. macOS packaging, signing, notarization, and runtime certification are roadmap work.
**Strategic function:** Waggle is the demand-creation and qualification engine for KVARK —
Egzakta Group's sovereign enterprise AI platform.
@@ -38,23 +39,68 @@ Egzakta Group's sovereign enterprise AI platform.
and connectors are all free (they generate memory). Team collaboration (shared memory,
WaggleDance, governance) is the upgrade trigger.
### Key Technology Facts (Verified April 2026)
### Current Release Qualification Contract (2026-08-22)
- Launch gate: **Windows Solo only**.
- In-scope external-agent release cohort: **Claude Code, Codex, and Hermes**. Each integration
uses the user's own installed client and its official user authentication.
- **Cursor and OpenClaw are roadmap-only**: detection metadata may remain, but production launch,
hooks, Fleet/task dispatch, and direct run routes must fail closed for them.
- Claude Desktop, Codex Desktop, and Hermes Desktop may remain as detected convenience launch
surfaces; they are not separate memory-hook or agent-acceptance targets in this release gate.
- ChatGPT/OpenAI is a model/provider and memory-import surface, not a separate launcher target.
- The Windows Solo launch contract requires an exact-revision Windows installer qualification receipt to
prove its bundled Node sidecar, no-Python OpenAI-compatible proxy, Waggle-managed local runtime/model, default
in-process embedding path, and freedom from developer Node, Docker, Python, external LiteLLM, or a
separately installed Ollama. A separate revision-bound router receipt must prove the smart-router primary,
compact-tool-context, budget, and fallback paths. A user-installed Ollama remains optional.
- Every receipt is valid first for the exact source revision and artifact SHA-256 it names. An older
router, persona, or authentication receipt is historical unless the current launch recommendation
explicitly carries it forward through a bounded no-impact attestation.
- Bounded carry-forward is allowed only when an exhaustive intervening-diff review proves that no
covered runtime surface changed, independent review approves that classification, and focused tests
and lint cover the intervening changes. Any affected persona, chat, provider, authentication, memory,
routing, or tool-context behavior requires a fresh receipt. Installer, public signing, and sealed
security artifacts remain revision-bound and must name the exact candidate they cover.
- A release-record-only Markdown descendant does not change the frozen runtime revision or installer
SHA-256. The eventual public hosted artifact and managed Deep Scan must instead be regenerated for
and name the exact approved release-tag commit.
- Persona evidence requires a complete 30-result collection across 10 personas at >=95/100 after
any explicitly documented independent semantic adjudication, plus either a fresh release-revision
run or an approved bounded no-impact attestation. Never relabel a non-gating collection as a
canonical deterministic seal. Claude Code/Codex/Hermes official user-auth canaries follow the
same carry-forward rule. GO also requires zero unresolved Critical/High findings.
- Do not claim release approval, production readiness, an overall 9.5/10, or competitor superiority
unless the current launch recommendation says GO for that same release.
### Evidence authority
Exact candidate revisions, installer and receipt hashes, carry-forward boundaries, open
checks, and the current verdict live only in
`docs/production-readiness/09-LAUNCH_RECOMMENDATION.md`. Do not duplicate an old
candidate table here or infer that an ancestor's installer certifies a later HEAD. The
repository remains private until an explicit open-source and licensing decision is made.
Public GO remains blocked until a publicly trusted Authenticode artifact and an exact-candidate
sealed managed Deep Security report close with no unresolved Critical/High findings.
### Key Technology Facts (Verified August 2026)
| Layer | Stack |
|---|---|
| Frontend | React **19** + TypeScript + Vite + Tailwind 4 + base-ui/react |
| Desktop | Tauri 2.0 (Rust shell) |
| Backend | Fastify sidecar (Node.js, bundled into Tauri) |
| LLM routing | LiteLLM (see `litellm-config.yaml`) |
| LLM routing | Windows Solo release contract: bundled no-Python OpenAI-compatible proxy and smart router; optional LiteLLM deployment config |
| Database | SQLite via @waggle/core (better-sqlite3 + sqlite-vec-windows-x64) |
| Memory | FrameStore + HybridSearch + KnowledgeGraph + IdentityLayer + AwarenessLayer |
| Agent runtime | `packages/agent/src/agent-loop.ts` |
| Billing | Stripe (installed; `stripe@^21.0.1`) |
| Design | Hive DS — honey #e5a000 / hive-950 #08090c / accent #a78bfa |
| Tests | Vitest (unit) + Playwright (E2E) |
| Deploy | Dockerfile + docker-compose.production.yml + render.yaml |
| Deploy | Windows Tauri installer release contract; optional Dockerfile + docker-compose.production.yml + render.yaml for server/team deployment |
Package manager: npm (root) with `bun.lock` also present. Node >= 20.
Package manager: npm with the root `package-lock.json`. Source development requires Node
`^20.19.0 || >=22.12.0`; the packaged Windows desktop runtime is pinned to Node `22.23.2`.
---
@@ -67,7 +113,7 @@ waggle-os/
├── apps/
│ ├── web/ # <-- MAIN web app UI (this is where most components live)
│ └── www/ # Landing page (waggle-os.ai)
├── packages/ # 16 workspace packages (see below)
├── packages/ # 28 workspace packages (see below)
├── sidecar/ # Node.js sidecar bundled into Tauri
├── scripts/ # build-sidecar, bundle-native-deps, bundle-node
├── tests/ # Cross-cutting integration tests
@@ -81,7 +127,7 @@ waggle-os/
└── package.json (workspaces: apps/*, packages/*)
```
### Packages (`packages/`, 27 workspaces — verified 2026-05-28)
### Packages (`packages/`, 28 workspaces — verified 2026-08-02)
```
Core (15):
admin-web cli launcher marketplace
@@ -89,15 +135,15 @@ agent core memory-mcp optimizer
sdk server shared waggle-dance
weaver wiki-compiler worker
hive-mind OSS split (12synced to marolinik/hive-mind, see §7.5):
hive-mind OSS source set (13curated forward-port target is marolinik/hive-mind; see §7.5):
hive-mind-core hive-mind-cli hive-mind-shim-core hive-mind-mcp-server
hive-mind-wiki-compiler
hive-mind-wiki-compiler hive-mind-hooks-core
hive-mind-hooks-{claude-code, claude-desktop, codex, codex-desktop,
cursor, hermes, openclaw}
```
> Note: the prior list said "16" and included `ui`, which has no `package.json`
> (not a workspace). Real count is 27. The 12 `hive-mind-*` packages were added
> since the April verification.
> (not a workspace). The live count is 28: 15 product packages and 13
> `hive-mind-*` packages.
### `packages/agent/src/` — MOST ACTIVE (94 .ts files + 4 subdirs)
@@ -155,7 +201,8 @@ MOVED (2026-04-30 monorepo migration): the memory substrate `mind/` (db/schema/
improvement-signals/embedding-provider/*-embedder) and `harvest/` (chatgpt/claude/
claude-code/gemini/perplexity/pdf/plaintext/markdown/url/universal adapters +
pipeline.ts + dedup.ts) now live at **packages/hive-mind-core/src/{mind,harvest}/**,
NOT under packages/core/. The OSS mirror is generated from there via subtree-split (§7.5).
NOT under packages/core/. The OSS mirror is curated from there through a maintainer-reviewed
forward-port (§7.5); raw subtree branches are never publish sources.
```
For the deep-dive on what the mind/ substrate does, see [`docs/memory-architecture.md`](docs/memory-architecture.md).
@@ -219,6 +266,49 @@ npm run lint
> run the `packages/server` tsc above. (A real type error slipped through this
> way on 2026-05-28; see `docs/addictiveness-audit-2026-05-28/REDUNDANCY-AUDIT.md`.)
### Windows Solo release commands (PowerShell 7; final frozen clean checkout)
```powershell
# Local build-host preparation (the installed desktop has none of these prerequisites).
npm ci
npm ci --prefix app --ignore-scripts
npm run build:packages
# Local unsigned build only; this verifies packaging/runtime, not public trust.
npm --prefix app run tauri:build:win
# Optional internal-pilot build. Its private test root is not public trust.
npm --prefix app run tauri:build:win:pilot-signed
# Internal clean-profile certification. Omit public-signature requirements for a pilot.
pwsh -NoProfile -File scripts/certify-windows-installer.ps1 `
-InstallerPath "<absolute-path-to-Waggle-setup.exe>" `
-ExpectedSourceRevision "<40-character-final-HEAD>" `
-VerifyManagedModel
```
Production signing is hosted-only. Do not run `new-windows-signing-handoff.ps1`,
`sign-windows-artifact.ps1`, or a local thumbprint-signing substitute to create a
release artifact. `.github/workflows/release.yml` is authoritative and must run from
an approved exact release tag. Its Windows chain is `build-windows-prebuilt` ->
`prepare-windows-signing` -> `sign-windows` -> `certify-windows` ->
`attest-windows` -> `publish-windows`.
`sign-windows` requires Azure Artifact Signing OIDC variables, a federated credential
scoped to the exact approved tag ref, and the least-privilege certificate-profile signer
role. Before Azure authentication, it must bind the push event, repository, tag,
workflow ref/SHA, clean checkout, and fresh `origin/main` ancestry. Signing may produce
private Actions artifacts, but public attestation and `publish-windows` remain disabled
while the repository is private; publication additionally requires
`WINDOWS_PUBLIC_RELEASE_AUTHORIZED` to be explicitly `true`. The existing `production`
environment isolates public-attestation OIDC claims from the exact-tag Azure signer.
Private repositories require GitHub Enterprise Cloud for GitHub artifact attestations,
so the sealed certified artifact is the terminal private-repository output. The approved
signer subject and timestamp must still pass before credential-free certification.
Never treat the local certification command as signed or change repository visibility
without an explicit OSS/licensing decision.
The certified installed desktop must not depend on developer Node.js, Python,
Docker, external LiteLLM, or a separately installed Ollama.
---
## 3. Behavioral Rules — How You Must Work
@@ -379,7 +469,7 @@ interface AgentPersona {
// guardrails + picker metadata (all optional, all shipped)
disallowedTools?: string[] // denylist — overrides tools[] on conflict
failurePatterns?: string[] // documented failure modes — shown in hover tooltip
isReadOnly?: boolean // true = no write tools ever (enforced in assembleToolPool)
isReadOnly?: boolean // true = no write tools after applyPersonaToolFilter/filterMcpToolsForPersona
tagline?: string // one sentence for picker hover
bestFor?: string[] // 3 example tasks in user-facing language
wontDo?: string // hard boundary statement
@@ -418,7 +508,7 @@ shows tagline + bestFor + wontDo. "Create Custom Persona" inline form POSTs to
## 7. Security Constraints (Non-Negotiable)
1. **Vault-only secrets.** API keys in Vault or `.env` (never committed). `.env.example` has key names only.
1. **Vault-only secrets.** API keys belong in Vault or an untracked local `.env`, never in Git. `.env.example` may contain non-secret development defaults, but never usable credentials or secrets.
2. **Injection defense.** `scanForInjection()` from `injection-scanner.ts` MUST be called on all connector/external input.
3. **No eval, no dynamic require.** Tauri WebView is restricted.
4. **Tauri IPC allowlist.** Explicit in `app/src-tauri/capabilities/`. Never `allowlist: all: true`.
@@ -428,7 +518,7 @@ shows tagline + bestFor + wontDo. "Create Custom Persona" inline form POSTs to
---
## 7.5. Memory Substrate Sync (waggle-os → hive-mind, subtree-split)
## 7.5. Memory Substrate Sync (waggle-os → hive-mind, curated forward-port)
The memory substrate lives at **`packages/hive-mind-core/src/{mind,harvest}/`** (moved from
`packages/core/src/` in the 2026-04-30 monorepo migration). The public OSS mirror at
@@ -443,14 +533,16 @@ directly on the OSS mirror.** Parity is NOT automatic — it broke once: the cro
(`inprocess-reranker.ts` + HybridSearch options) was written directly on `marolinik/hive-mind`
during the LoCoMo benchmark arc and existed ONLY there, discovered by the W4 recon and
reverse-ported in W4.2 (`f47ee8f`). Rules:
1. Substrate changes land in `packages/hive-mind-core/` here FIRST; the mirror is regenerated
via subtree-split afterward.
1. Substrate changes land in `packages/hive-mind-core/` here FIRST; the mirror is updated
through a reviewed, maintainer-curated forward-port afterward.
2. Benchmark/experiment work in a `D:/Projects/hive-mind` checkout is throwaway unless
reverse-ported here — port it the same arc, don't let it sit.
3. Run **`scripts/oss-drift-check.sh`** (file-level diff of the mapped src trees) before every
OSS release push and after any arc that touched a hive-mind checkout.
4. External PRs on the OSS repo are fine — the maintainer merges them back here via
subtree-pull, then re-splits.
3. Run **`node scripts/oss-drift-check.mjs D:/Projects/hive-mind`** before every OSS release
push and after any arc that touched a hive-mind checkout. The checker compares the live
mapped trees with an immutable reviewed baseline: parity and reviewed adaptations are
allowed, while known blockers, unreviewed differences, or forbidden exports keep exit 1.
4. External PRs on the OSS repo are fine — the maintainer intentionally ports accepted changes
back here first, then prepares the next curated forward-port.
=== END CRITICAL ===
=== CORRECTION — how the sync ACTUALLY works (2026-06-12 drift analysis) ===
@@ -481,8 +573,10 @@ The prior text here claimed the mirror is produced by `scripts/oss-subtree-split
**To work on the substrate or publish the OSS mirror:** see
[`packages/hive-mind-core/CONTRIBUTING.md`](./packages/hive-mind-core/CONTRIBUTING.md),
[`scripts/oss-subtree-split.sh`](./scripts/oss-subtree-split.sh) (inspection/guard only), and
[`scripts/oss-drift-check.sh`](./scripts/oss-drift-check.sh) (run before every release; note its
~50 "DIFFERS" are mostly OSS-adaptation noise — layout + import rewrites — not true drift).
[`scripts/oss-drift-check.mjs`](./scripts/oss-drift-check.mjs) (run before every release; its
immutable baseline distinguishes parity, reviewed adaptations, known blockers, unreviewed
differences, and forbidden exports; every blocker or unreviewed entry must be resolved or
explicitly re-baselined through maintainer review before an OSS release).
**Deprecated (do not rely on; do not delete):** the old dual-repo bidirectional-sync workflows
`.github/workflows/{mind-parity-check,sync-mind}.yml` and the `.github/sync.md` manual are **preserved
@@ -517,7 +611,7 @@ Grep before creating. These exist and are functional:
| `packages/core/src/telemetry.ts` | Telemetry pipeline |
| `packages/hive-mind-core/src/harvest/pipeline.ts` | Harvest adapters + dedup |
| `packages/core/src/compliance/` | Compliance + audit |
| `app/src/components/cockpit/` | Tauri cockpit UI |
| `apps/web/src/components/os/` | Main desktop cockpit UI loaded by Tauri |
---
@@ -556,7 +650,7 @@ Do not recreate or expose outside gating.
- **Premium harness reached HONEST 21/21** (May 2026 S1) — every pillar regression-locked + composing. Full agent suite 2657/2657. See `memory/project_session_handoff_0519_s1.md`.
**AI-OS arc (May 2026 S1/S2, 14 commits on origin):**
- Phase 0 — Tool detection PoC (`packages/agent/src/tool-detection.ts`) for all 7 supported AI tools, hermetic + cross-platform.
- Phase 0 — Tool detection PoC (`packages/agent/src/tool-detection.ts`) for eight registered AI-tool surfaces, hermetic + cross-platform. Registration is broader than release support.
- Phase 1A — WaggleDance v2 dispatcher branches wired (discovery/routed_share/model_recipe/knowledge_match/task_claim/model_recommendation).
- Phase 1B — Local sidecar surface (`/api/waggle-dance/signal` + `/signals`), SignalBus ring buffer, personal-tier-eligible.
- Phase 1C — Bridge: v2 bus → existing `/api/waggle/signals` UI stream (zero frontend changes).
@@ -565,7 +659,7 @@ Do not recreate or expose outside gating.
- Phase 2A — Launcher backend (`/api/tools/launch`, `/api/tools/hooks`).
- Phase 2B — LauncherApp dock surface (`apps/web/src/components/os/apps/LauncherApp.tsx`).
- Phase 3 — Skill diffusion (D1 fire → `skill_share` broadcast via `onSkillDistillationFire` callback).
- Phase 4 — Full 7-tool launch cohort + Mission Control inventory tile + Memory provenance badge + launch-with-prompt textarea + process tracker / 'Running' badge.
- Phase 4 — Eight-tool inventory/detection surface + Mission Control tile + Memory provenance badge + launch-with-prompt textarea + process tracker / 'Running' badge. The in-scope agent-integration release cohort is Claude Code, Codex, and Hermes; Cursor and OpenClaw are roadmap-only.
End-to-end: detect → install hooks (reversible) → launch with `WAGGLE_WORKSPACE_ID` env → hook captures → shim emitter → bus → bridge → UI. Rollback tag: `checkpoint/pre-ai-os-2026-05-20`. AI-OS exploration doc: `docs/plans/AI-OS-EXPLORATION-2026-05-19.md`.
@@ -574,7 +668,7 @@ End-to-end: detect → install hooks (reversible) → launch with `WAGGLE_WORKSP
|---|---|---|
| 1 | Spawn Agent + Dock wiring | P36 already wired in `Dock.tsx`+`Desktop.tsx`; P35 third-tier fallback (LiteLLM → runtime model → provider catalogs) landed `14942be`. Residual: runtime verification on a clean install. |
| 2 | Light mode finish | P40/P41 + CR-2 — semantic-token migration is done (no hive-950 references except a comment); remaining issues are render-time fine-tuning (BootScreen visual polish + a few header-styling judgments) that need a binary build to validate. |
| 3 | Wave 2/3 hook implementations | **Mostly DONE (corrected 2026-06-29).** 6 of 7 hook packages ship real bins: claude-code + the 2026-06-01 Wave 2/3 port (codex, codex-desktop, cursor, hermes, openclaw). Only `hive-mind-hooks-claude-desktop` remains a binless `export {}` stub (deferred MCP-bridge category). The dock (`LauncherApp.tsx`) now exposes hook install/verify/uninstall for all 6 via the corrected `HOOKS_COHORT` (was hardcoded `['claude-code']`). Residual: claude-desktop MCP-bridge hook only. |
| 3 | External-tool release cohort | **Windows Solo scope fixed 2026-08-02.** Claude Code, Codex, and Hermes are the in-scope agent-integration cohort. Cursor and OpenClaw implementations remain in-tree as roadmap work and are fail-closed in production surfaces. Claude Desktop, Codex Desktop, and Hermes Desktop are convenience launch surfaces, not separate agent-acceptance targets. |
**Closed during May 2026 backlog sweep:**
- ✅ OW-6 PersonaSwitcher two-tier — shipped via M-01 (`PersonaSwitcher.tsx` + `lib/persona-tier.ts` + `lib/persona-tooltip.ts`); 26/26 tests passing
@@ -623,11 +717,11 @@ For the full polish+launch backlog see `docs/plans/BACKLOG-CONSOLIDATED-2026-04-
| BEHAVIORAL_SPEC | Core agent rules (`packages/agent/src/behavioral-spec.ts`) |
| Sidecar | Node.js Fastify server bundled into Tauri (`/sidecar`) |
| KVARK | Egzakta sovereign enterprise AI — top of the Waggle funnel |
| LiteLLM | LLM routing layer (`litellm-config.yaml`) |
| LiteLLM | Optional server/team deployment proxy config (`litellm-config.yaml`); Windows Solo uses the bundled no-Python proxy and smart router |
| WaggleDance | Multi-agent coordination package (`packages/waggle-dance`) |
| Weaver | `packages/weaver` — (check source for current role) |
| Weaver | Memory consolidation and session-skill extraction engine (`packages/weaver`) |
| Evolution | Self-improvement subsystem (`evolution-*.ts`, `judge.ts`, `iterative-optimizer.ts`) |
| assembleToolPool | Per-persona tool filtering from allowlist + denylist (to implement) |
| applyPersonaToolFilter / filterMcpToolsForPersona | Enforced local and MCP per-persona allowlist/denylist filtering (`packages/server/src/local/persona-tool-filter.ts`) |
---

View File

@@ -1,6 +1,58 @@
# Waggle OS
Workspace-native AI agent platform with persistent memory, model-agnostic orchestration, and skill-extensible capabilities. It ships as a Tauri 2.0 desktop binary (Windows/macOS) with a Vite-bundled web app and a Node.js sidecar.
Workspace-native AI agent platform with persistent memory, model-agnostic orchestration, and skill-extensible capabilities. The current desktop release scope is Windows-first: a Tauri 2.0 app with a Vite-bundled web UI and bundled Node.js sidecar; macOS packaging and certification remain roadmap work.
## Current Release Scope
The active launch gate is **Windows Solo**. Its in-scope external-agent release cohort is **Claude Code, Codex, and Hermes**. Each tool uses the user's own installation and authentication; Waggle does not redistribute provider credentials or bypass provider terms.
- **Cursor and OpenClaw are roadmap integrations.** They remain registered for detection and future development, but the production launcher, hooks, Fleet/task path, and direct run API do not offer them.
- Claude Desktop, Codex Desktop, and Hermes Desktop may appear as detected convenience launch surfaces; they are not separate memory-hook or agent-acceptance targets in this release gate.
- **ChatGPT/OpenAI is a model and memory-import surface**, not a separate local coding-agent launcher.
- The Windows Solo launch contract requires an exact-revision Windows installer qualification receipt to prove its bundled Node sidecar, no-Python OpenAI-compatible proxy, Waggle-managed local runtime/model, default in-process embedding path, and freedom from developer Node, Docker, Python, an external LiteLLM service, or a separately installed Ollama. A separate revision-bound router receipt must prove the smart-router primary, compact-tool-context, budget, and fallback paths. Router, persona, and authentication receipts may cover a later candidate only through an independently reviewed bounded no-impact attestation proving that no covered runtime surface changed; otherwise they must be rerun. A user-installed Ollama remains optional.
- Docker/LiteLLM deployment files remain optional server and team deployment choices; they are not desktop prerequisites.
Release status, revision-bound receipts, and any bounded carry-forward attestations are governed only by the current [launch recommendation](docs/production-readiness/09-LAUNCH_RECOMMENDATION.md). If it does not say **GO**, do not describe Waggle as production-ready or reuse historical scores or receipts as current release evidence.
### Current Windows Solo internal RC evidence — 2026-08-27
The current runtime candidate is private `main` commit
`23ad3fa5f99bddce648b84750a41365299aeb0da`. Its internal-pilot NSIS installer
(102,923,936 bytes; SHA-256
`7BFA9F9B13633A51CD3336B42E3EF904B7F7A568C6DEE4F6CED967CBD4F40A59`)
passed **64/64** clean-profile checks. The receipt proves bundled sidecar and offline
npm, FREE/Solo first boot, in-process embeddings, the Waggle-managed local runtime and
`qwen2.5:0.5b`, local-model chat, proxy restart, repair, relaunch, data preservation,
Exit/cleanup, and uninstall. Docker, Python, developer Node.js, external LiteLLM, and a
separately installed Ollama were not prerequisites.
- Private PR #66 merged as `23ad3fa5` after every blocking check passed: primary CI,
Playwright smoke and full E2E, Windows and both macOS Tauri verification targets, and
Hive Mind install/smoke on Windows, Ubuntu, and macOS. The tested PR head and merged
source have the same tree.
- Exact-current full and production dependency audits contain **0 Critical and 0 High**
findings. Lower-severity maintenance remains documented.
- The historical persona collection at `4c712ff6` still records all ten personas x3 at
or above 95/100 after documented independent adjudication, but it is not relabeled as
an exact-current seal. PR #66 changed memory behavior, so public release qualification
requires a fresh receipt or an explicit bounded semantic-impact attestation.
- Smart-router primary, compact-tool-context, durable-budget and fallback evidence, plus
the Claude Code, Codex, and Hermes official-user-auth canaries, remain historical
scoped evidence. No later change touched provider credential handling; the auth
harness read or copied no credential files.
- The curated Hive Mind mirror is hardened through private-to-maintainer PR #53, merged
on public `master` as `3410327800db3ea23f875d547a0c7f4d08826b7e`; Windows, macOS,
Ubuntu, and Ubuntu first-run smoke were green. The immutable drift baseline still
reports reviewed release blockers, so the next OSS package release remains a separate
maintainer-curated operation and is not implied by this Windows Solo RC.
Detailed local receipt paths, hashes, limitations, and integration gates are recorded in
the current [launch recommendation](docs/production-readiness/09-LAUNCH_RECOMMENDATION.md).
The internal signer (`CN=Egzakta Internal Pilot`) and DigiCert timestamp prove the pilot
pipeline but are not publicly trusted Authenticode. Public release is **not yet approved**:
a protected hosted build with a publicly trusted signer, an exact-candidate sealed
managed Deep Security report, current persona qualification, and either fresh or
explicitly attested smart-router and official-auth qualification remain mandatory.
## Architecture
@@ -57,12 +109,21 @@ The monorepo has **28 packages** under `packages/`. They split into two groups.
## Quick Start
### Self-host in one line (Linux / macOS)
### Windows Solo desktop
Use only the signed Windows installer and SHA-256 identified by a **GO** [launch recommendation](docs/production-readiness/09-LAUNCH_RECOMMENDATION.md). If that recommendation is not GO, no packaged desktop artifact is release-approved; use the source-development instructions below.
### Self-host from the private repository (Linux / macOS)
```bash
curl -fsSL https://raw.githubusercontent.com/marolinik/waggle-os/main/install.sh | bash
gh repo clone marolinik/waggle-os
cd waggle-os
bash install.sh
```
This path is for maintainers with authenticated access to the private repository.
Do not publish an anonymous raw-file installer until the source/licensing decision is explicit.
Best for a VPS or homelab — this runs a headless Waggle server (no desktop shell):
- **Checks prerequisites, clones, builds, and starts** the Node.js sidecar, then prints the URL (`http://127.0.0.1:3333`). A 5-question wizard — install dir, port, data dir, build web UI, start now — is all Enter-defaulted; pass `--yes` to accept every default non-interactively.
@@ -74,7 +135,7 @@ Manage the running server with the installed wrapper: `scripts/waggle-server.sh
### Run from source (development)
```bash
# Prerequisites: Node.js >= 20, npm
# Prerequisites: Node.js ^20.19.0 or >=22.12.0, npm
npm install
# (Optional) copy the env template. Provider API keys are normally set in-app
@@ -91,6 +152,10 @@ npm run dev:web
# Open http://localhost:8080
```
The source tree follows the root `package.json` Node engine above. The packaged
Windows Solo desktop carries its own pinned Node.js 22.23.2 runtime, so an
installed user does not need a separate Node.js installation.
`npm run dev:server` runs the Fastify sidecar via `tsx` (equivalent to
`cd packages/server && npx tsx src/local/start.ts`). `npm run dev:web` runs the
Vite dev server for `apps/web`.
@@ -115,7 +180,7 @@ run. See [`.env.example`](./.env.example) for the full contract.
| `ANTHROPIC_API_KEY` | Recommended | Claude API key. Optional in `.env` — can be set in-app instead (vault). |
| `OPENAI_API_KEY` | No | Enables OpenAI models and optional OpenAI embeddings. |
| `EMBEDDING_PROVIDER` | No | `auto` (default) · `inprocess` · `ollama` · `voyage` · `openai` · `mock`. `auto` tries in-process → Ollama → API → mock. |
| `LITELLM_BASE_URL` | No | LiteLLM proxy URL for multi-model routing (default `http://localhost:4000`). |
| `LITELLM_BASE_URL` | No | Optional external LiteLLM-compatible proxy URL. The Windows Solo desktop uses its bundled no-Python proxy unless explicitly configured otherwise. |
| `DATABASE_URL` | Team only | PostgreSQL connection string. |
| `REDIS_URL` | Team only | Redis for the background job queue. |

View File

@@ -1,9 +1,9 @@
# Waggle OS — Threat Model
Waggle OS is a **workspace-native AI agent platform with persistent memory**, shipped as
a Tauri desktop binary (Windows/macOS) with a bundled Node.js sidecar. This document
states the trust boundary and the controls that enforce it, so contributors can reason
about security without reading the full agent + connector stack.
Waggle OS is a **workspace-native AI agent platform with persistent memory**. The current
launch scope is a Windows-first Tauri desktop binary with a bundled Node.js sidecar;
macOS packaging and certification remain roadmap work. This document states the trust
boundary and the controls that enforce it.
> Status: living document. The controls below are implemented and cited to source.
> Known gaps are open and honestly listed.
@@ -76,10 +76,11 @@ approval class, initiator, and trust source — a verifiable history of what was
when, why, and by whom. Backs the EU-AI-Act capability-provenance story.
### 5. Local secret storage — `vault.ts`
`packages/core/src/vault.ts`. Secrets are encrypted with AES-256-GCM under a machine-local
key file; each entry is independently encrypted. API keys live in the vault or `.env`
(never committed; `.env.example` carries key names only). No secret is ever written to a
prompt, a log, or a memory frame.
`packages/core/src/vault.ts`. Secrets are encrypted with AES-256-GCM under a
machine-local key file; each entry is independently encrypted. Runtime API keys
belong in the vault or a local `.env` (never committed). `.env.example` contains
names plus non-secret development defaults, never live credentials. Callers must
not persist secret values into prompts, logs, or memory frames.
### 6. MCP tool scope gate — `scope.ts`
`packages/memory-mcp/src/scope.ts` + `packages/hive-mind-mcp-server/src/scope.ts`. An
@@ -148,14 +149,15 @@ Coverage:
preamble (`orchestrator.ts`) but is not yet wrapped in the same structural fence;
harvest content is scanned at ingest but not re-fenced per frame. Extending the fence to
recall is a low-marginal-value follow-up.
4. **`isReadOnly` persona gating is fail-open.** Read-only personas filter write tools by
denylist rather than an inverse allowlist; a tool missing from the denylist is not
blocked. Flip to allowlist + static mutator backstop when persona governance is next
touched.
4. **Read-only persona gating depends on explicit classification.** Built-in tools are
filtered through `READ_ONLY_ALLOWED_TOOLS`; dynamic connector and MCP tools are dropped
wholesale for read-only personas. The residual risk is governance drift if a stateful
tool is incorrectly classified as side-effect-free and added to the allowlist; focused
tests guard known mutators and the MCP-denial boundary.
5. **Connector endpoint URLs are not redacted before logging.** userinfo/query/fragment
on LiteLLM/connector URLs can leak credentials into logs — fold a `redactUrl` pass into
the next compliance/logging pass.
6. **Connector auto-harvest persists external content durably.** Opt-in PRO connector
6. **Connector auto-harvest persists external content durably.** Opt-in Solo/FREE connector
harvest writes external data (e.g. inbox metadata + message previews) into the personal
mind, where it is recalled into model context on later turns. Content is injection-scanned
per frame but NOT scanned for secrets/PII; the email harvest pins `$select` to

14
app/package-lock.json generated
View File

@@ -2958,9 +2958,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"version": "3.3.17",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz",
"integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==",
"dev": true,
"funding": [
{
@@ -3007,9 +3007,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.12",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz",
"integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==",
"version": "8.5.25",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
"integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
"dev": true,
"funding": [
{
@@ -3027,7 +3027,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"nanoid": "^3.3.16",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},

View File

@@ -9,12 +9,13 @@
"typecheck": "tsc -b",
"preview": "vite preview",
"tauri": "tauri",
"tauri:build": "node ../scripts/build-sidecar.mjs && node ../scripts/bundle-native-deps.mjs && node ../scripts/bundle-node.mjs && node ../scripts/stage-sidecar-deps.mjs && cd ../apps/web && npx vite build && cd ../../app && npx tauri build",
"tauri:build:local": "node ../scripts/build-sidecar.mjs && node ../scripts/bundle-native-deps.mjs && node ../scripts/bundle-node.mjs && node ../scripts/stage-sidecar-deps.mjs && cd ../apps/web && npx vite build && cd ../../app && npx tauri build --debug",
"tauri:build:win": "node ../scripts/build-sidecar.mjs && node ../scripts/bundle-native-deps.mjs && node ../scripts/bundle-node.mjs && node ../scripts/stage-sidecar-deps.mjs && cd ../apps/web && npx vite build && cd ../../app && npx tauri build --target x86_64-pc-windows-msvc",
"tauri:build": "node ../scripts/bundle-node.mjs && node ../scripts/build-sidecar.mjs && node ../scripts/bundle-native-deps.mjs && node ../scripts/stage-sidecar-deps.mjs && cd ../apps/web && npx vite build && cd ../../app && npx tauri build",
"tauri:build:local": "node ../scripts/bundle-node.mjs && node ../scripts/build-sidecar.mjs && node ../scripts/bundle-native-deps.mjs && node ../scripts/stage-sidecar-deps.mjs && cd ../apps/web && npx vite build && cd ../../app && npx tauri build --debug",
"tauri:build:win": "node ../scripts/bundle-node.mjs && node ../scripts/build-sidecar.mjs && node ../scripts/bundle-native-deps.mjs && node ../scripts/stage-sidecar-deps.mjs && node ../scripts/check-sidecar-resources.mjs && cd ../apps/web && npx vite build && cd ../../app && npx tauri build --target x86_64-pc-windows-msvc",
"tauri:build:win:pilot-signed": "npm run tauri:sign:pilot:win:apply && npm run tauri:build:win -- --config src-tauri/tauri.build-override.conf.json",
"tauri:build:mac": "npm run tauri:build:mac:arm64 && npm run tauri:build:mac:x64",
"tauri:build:mac:arm64": "node ../scripts/build-sidecar.mjs && TARGET_ARCH=arm64 node ../scripts/bundle-native-deps.mjs && TARGET_ARCH=arm64 node ../scripts/bundle-node.mjs && TARGET_ARCH=arm64 node ../scripts/stage-sidecar-deps.mjs && cd ../apps/web && npx vite build && cd ../../app && npx tauri build --target aarch64-apple-darwin",
"tauri:build:mac:x64": "node ../scripts/build-sidecar.mjs && TARGET_ARCH=x64 node ../scripts/bundle-native-deps.mjs && TARGET_ARCH=x64 node ../scripts/bundle-node.mjs && TARGET_ARCH=x64 node ../scripts/stage-sidecar-deps.mjs && cd ../apps/web && npx vite build && cd ../../app && npx tauri build --target x86_64-apple-darwin",
"tauri:build:mac:arm64": "TARGET_ARCH=arm64 node ../scripts/bundle-node.mjs && node ../scripts/build-sidecar.mjs && TARGET_ARCH=arm64 node ../scripts/bundle-native-deps.mjs && TARGET_ARCH=arm64 node ../scripts/stage-sidecar-deps.mjs && cd ../apps/web && npx vite build && cd ../../app && npx tauri build --target aarch64-apple-darwin",
"tauri:build:mac:x64": "TARGET_ARCH=x64 node ../scripts/bundle-node.mjs && node ../scripts/build-sidecar.mjs && TARGET_ARCH=x64 node ../scripts/bundle-native-deps.mjs && TARGET_ARCH=x64 node ../scripts/stage-sidecar-deps.mjs && cd ../apps/web && npx vite build && cd ../../app && npx tauri build --target x86_64-apple-darwin",
"tauri:dev": "npx tauri dev",
"tauri:sign:pilot:win:setup": "powershell -ExecutionPolicy Bypass -File scripts/sign-windows-pilot.ps1 -Mode Setup",
"tauri:sign:pilot:win:apply": "node scripts/apply-signing-config.mjs",

View File

@@ -16,12 +16,10 @@ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
// The pure helpers below mirror app/scripts/signing-config.ts so this CLI has
// zero TS-loader dependency at runtime. The .ts version is the canonical
// implementation tested by signing-config.test.ts (19 cases covering parse,
// merge, idempotency, immutability). Keep the two implementations in lockstep:
// any change to parseThumbprintString or addWindowsSigningToOverride below
// MUST be mirrored in signing-config.ts and vice versa.
// The pure helpers below mirror the certificate-store helpers in
// app/scripts/signing-config.ts so this pilot CLI has zero TS-loader dependency
// at runtime. Keep parseThumbprintString and addWindowsSigningToOverride in
// lockstep with the canonical TypeScript implementation.
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
const APP_DIR = resolve(SCRIPT_DIR, '..');
@@ -33,11 +31,12 @@ const OVERRIDE_PATH = resolve(
'tauri.build-override.conf.json',
);
const THUMBPRINT_PATH = resolve(APP_DIR, 'src-tauri', '.thumbprint.txt');
const DEFAULT_DIGEST_ALGORITHM = 'sha256';
const DEFAULT_TIMESTAMP_URL = 'http://timestamp.digicert.com';
const THUMBPRINT_LENGTH = 40;
const HEX_PATTERN = /^[0-9A-F]+$/;
const WINDOWS_SIGNING_MODE =
process.env.WAGGLE_WINDOWS_SIGNING_MODE ?? 'certificate-store';
function parseThumbprintString(raw) {
if (!raw || raw.trim().length === 0) {
@@ -59,13 +58,15 @@ function addWindowsSigningToOverride(config, thumbprint, options = {}) {
const existingBundle = config.bundle ?? {};
const existingWindows = existingBundle.windows ?? {};
const nonCustomCommandWindows = { ...existingWindows };
delete nonCustomCommandWindows.signCommand;
return {
...config,
bundle: {
...existingBundle,
windows: {
...existingWindows,
...nonCustomCommandWindows,
certificateThumbprint: normalisedThumbprint,
digestAlgorithm,
timestampUrl,
@@ -77,6 +78,20 @@ function addWindowsSigningToOverride(config, thumbprint, options = {}) {
// ─── Main ───────────────────────────────────────────────────────────────────
function main() {
if (!['certificate-store', 'artifact-signing'].includes(WINDOWS_SIGNING_MODE)) {
console.error(
`[apply-signing-config] unsupported WAGGLE_WINDOWS_SIGNING_MODE: ${WINDOWS_SIGNING_MODE}`,
);
process.exit(1);
}
if (WINDOWS_SIGNING_MODE === 'artifact-signing') {
console.error(
'[apply-signing-config] Azure Artifact Signing is hosted-only. '
+ 'Run the protected GitHub-hosted release workflow; this local helper cannot issue '
+ 'the immutable build receipt, protected OIDC identity, session manifest, or callback ledger.',
);
process.exit(1);
}
if (!existsSync(THUMBPRINT_PATH)) {
console.error(
`[apply-signing-config] thumbprint file missing: ${THUMBPRINT_PATH}`,
@@ -93,7 +108,6 @@ function main() {
process.exit(1);
}
const rawThumbprint = readFileSync(THUMBPRINT_PATH, 'utf8');
const overrideRaw = readFileSync(OVERRIDE_PATH, 'utf8');
let override;
@@ -108,6 +122,7 @@ function main() {
let updated;
try {
const rawThumbprint = readFileSync(THUMBPRINT_PATH, 'utf8');
updated = addWindowsSigningToOverride(override, rawThumbprint);
} catch (err) {
console.error(
@@ -122,9 +137,9 @@ function main() {
writeFileSync(OVERRIDE_PATH, serialised, 'utf8');
const relativePath = OVERRIDE_PATH.replace(REPO_ROOT, '').replace(/^\\/, '');
console.log(
`[apply-signing-config] wrote thumbprint ${updated.bundle.windows.certificateThumbprint.slice(0, 8)}... to ${relativePath}`,
);
const signingDescription =
`thumbprint ${updated.bundle.windows.certificateThumbprint.slice(0, 8)}...`;
console.log(`[apply-signing-config] wrote ${signingDescription} to ${relativePath}`);
}
main();

File diff suppressed because it is too large Load Diff

View File

@@ -7,11 +7,10 @@
# Usage:
# ./sign-macos-adhoc.sh <path-to-Waggle.app>
#
# Tauri's bundle config (tauri.build-override.conf.json) already passes
# `signingIdentity: "-"` to codesign at build time, so the produced .app is
# already ad-hoc-signed. This script:
# Ordinary `npm run tauri:build:mac` does not load the optional build override.
# Treat the input as unsigned until this script signs and verifies it. This script:
#
# 1. Re-signs the bundle with --force --deep to catch any nested helpers
# 1. Signs or re-signs the bundle with --force --deep to catch nested helpers
# (sidecar binary, native deps) that Tauri's pass missed.
# 2. Verifies the signature with --verify --deep --strict.
#

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -155,9 +155,8 @@ if ($Mode -eq 'Setup') {
Write-Host "[setup] thumbprint -> $ThumbprintFile" -ForegroundColor Green
Write-Host ''
Write-Host 'Next:' -ForegroundColor Cyan
Write-Host ' 1. cd app && npm run tauri:sign:pilot:win:apply'
Write-Host ' 2. npm run tauri:build:win'
Write-Host ' 3. .\scripts\sign-windows-pilot.ps1 -Mode Sign -ArtifactPath <path-to-msi>'
Write-Host ' 1. npm run tauri:build:win:pilot-signed'
Write-Host ' 2. .\scripts\sign-windows-pilot.ps1 -Mode Sign -ArtifactPath <path-to-msi> # optional'
return
}

View File

@@ -1,6 +1,10 @@
import { describe, it, expect } from 'vitest';
import { spawnSync } from 'node:child_process';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
parseThumbprintString,
addWindowsArtifactSigningToOverride,
addWindowsSigningToOverride,
addMacosAdhocToOverride,
type TauriOverrideConfig,
@@ -86,6 +90,23 @@ describe('addWindowsSigningToOverride', () => {
expect(out.bundle?.windows?.certificateThumbprint).toBe(VALID_THUMBPRINT);
});
it('removes an Azure signCommand when returning to certificate-store signing', () => {
const azure: TauriOverrideConfig = addWindowsArtifactSigningToOverride(
{
bundle: {
windows: { nsis: { installMode: 'currentUser' } },
},
},
String.raw`D:\a\waggle-os\app\scripts\sign-windows-artifact.ps1`,
);
const out = addWindowsSigningToOverride(azure, VALID_THUMBPRINT);
expect(out.bundle?.windows?.signCommand).toBeUndefined();
expect(out.bundle?.windows?.certificateThumbprint).toBe(VALID_THUMBPRINT);
expect(out.bundle?.windows?.nsis).toEqual({ installMode: 'currentUser' });
});
it('overrides custom digestAlgorithm and timestampUrl when options provided', () => {
const out = addWindowsSigningToOverride({}, VALID_THUMBPRINT, {
digestAlgorithm: 'sha384',
@@ -127,6 +148,154 @@ describe('addWindowsSigningToOverride', () => {
});
});
// ─── addWindowsArtifactSigningToOverride ───────────────────────────────────
describe('addWindowsArtifactSigningToOverride', () => {
const WRAPPER_PATH = String.raw`D:\a\waggle-os\app\scripts\sign-windows-artifact.ps1`;
const SYSTEM_POWERSHELL_PATH =
String.raw`C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`;
it('configures an object-form Tauri signCommand with one artifact placeholder', () => {
const out = addWindowsArtifactSigningToOverride(
{},
WRAPPER_PATH,
);
expect(out.bundle?.windows?.signCommand).toEqual({
cmd: SYSTEM_POWERSHELL_PATH,
args: [
'-NoLogo',
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy',
'Bypass',
'-File',
WRAPPER_PATH,
'-ArtifactPath',
'%1',
],
});
});
it('removes mutually exclusive certificate-store signing fields', () => {
const input: TauriOverrideConfig = {
bundle: {
windows: {
certificateThumbprint: 'AB'.repeat(20),
digestAlgorithm: 'sha256',
timestampUrl: 'http://timestamp.digicert.com',
tsp: true,
nsis: { installMode: 'currentUser' },
},
},
};
const out = addWindowsArtifactSigningToOverride(
input,
WRAPPER_PATH,
);
expect(out.bundle?.windows?.certificateThumbprint).toBeUndefined();
expect(out.bundle?.windows?.digestAlgorithm).toBeUndefined();
expect(out.bundle?.windows?.timestampUrl).toBeUndefined();
expect(out.bundle?.windows?.tsp).toBeUndefined();
expect(out.bundle?.windows?.nsis).toEqual({ installMode: 'currentUser' });
});
it('is immutable and idempotent', () => {
const input: TauriOverrideConfig = {
build: {
beforeBuildCommand: 'npm run build',
beforeBundleCommand: 'node mutate-bundle.mjs',
},
bundle: {
active: false,
targets: ['msi'],
windows: { nsis: { installMode: 'currentUser' } },
},
};
const snapshot = JSON.parse(JSON.stringify(input));
const once = addWindowsArtifactSigningToOverride(
input,
WRAPPER_PATH,
);
const twice = addWindowsArtifactSigningToOverride(
once,
WRAPPER_PATH,
);
expect(input).toEqual(snapshot);
expect(twice).toEqual(once);
expect(once.build).toEqual({
beforeBuildCommand: '',
beforeBundleCommand: '',
});
expect(once.bundle?.active).toBe(true);
expect(once.bundle?.targets).toEqual(['nsis']);
expect(once.bundle?.windows?.nsis).toEqual({ installMode: 'currentUser' });
});
it('rejects non-absolute, placeholder-bearing, or control-character wrapper paths', () => {
expect(() =>
addWindowsArtifactSigningToOverride(
{},
'scripts/sign.ps1',
),
).toThrow(/absolute Windows path/i);
expect(() =>
addWindowsArtifactSigningToOverride(
{},
String.raw`D:\a\%1\sign-windows-artifact.ps1`,
),
).toThrow(/placeholder/i);
expect(() =>
addWindowsArtifactSigningToOverride(
{},
'D:\\safe\nmalicious.ps1',
),
).toThrow(/control characters/i);
expect(() =>
addWindowsArtifactSigningToOverride(
{},
String.raw`D:\safe\..\malicious.ps1`,
),
).toThrow(/canonical local Windows/i);
expect(() =>
addWindowsArtifactSigningToOverride(
{},
String.raw`D:\safe\sign.ps1:payload`,
),
).toThrow(/canonical local Windows/i);
});
it('contains exactly one artifact placeholder across the complete command', () => {
const out = addWindowsArtifactSigningToOverride({}, WRAPPER_PATH);
const command = out.bundle?.windows?.signCommand;
const placeholderCount = [command?.cmd, ...(command?.args ?? [])]
.flatMap((part) => part?.match(/%1/g) ?? [])
.length;
expect(placeholderCount).toBe(1);
});
});
describe('apply-signing-config Artifact Signing boundary', () => {
it('fails closed toward the protected hosted release workflow, never local Build mode', () => {
const scriptDir = dirname(fileURLToPath(import.meta.url));
const result = spawnSync(
process.execPath,
[resolve(scriptDir, 'apply-signing-config.mjs')],
{
cwd: resolve(scriptDir, '..'),
env: { ...process.env, WAGGLE_WINDOWS_SIGNING_MODE: 'artifact-signing' },
encoding: 'utf8',
},
);
const output = `${result.stdout}\n${result.stderr}`;
expect(result.status).not.toBe(0);
expect(output).toMatch(/protected GitHub-hosted release workflow/i);
expect(output).not.toMatch(/-Mode Build/i);
});
});
// ─── addMacosAdhocToOverride ────────────────────────────────────────────────
describe('addMacosAdhocToOverride', () => {

View File

@@ -18,10 +18,17 @@
export interface TauriBundleWindows {
certificateThumbprint?: string;
digestAlgorithm?: string;
signCommand?: TauriSignCommand;
timestampUrl?: string;
tsp?: boolean;
[key: string]: unknown;
}
export interface TauriSignCommand {
cmd: string;
args: string[];
}
export interface TauriBundleMacOS {
signingIdentity?: string;
[key: string]: unknown;
@@ -34,6 +41,7 @@ export interface TauriBundle {
}
export interface TauriOverrideConfig {
build?: Record<string, unknown>;
bundle?: TauriBundle;
[key: string]: unknown;
}
@@ -50,6 +58,39 @@ const DEFAULT_TIMESTAMP_URL = 'http://timestamp.digicert.com';
const MACOS_ADHOC_IDENTITY = '-';
const THUMBPRINT_LENGTH = 40;
const HEX_PATTERN = /^[0-9A-F]+$/;
const WINDOWS_ABSOLUTE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
const WINDOWS_POWERSHELL_PATH =
String.raw`C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`;
function containsControlCharacter(value: string): boolean {
return [...value].some((character) => {
const codePoint = character.codePointAt(0) ?? 0;
return codePoint <= 31 || codePoint === 127;
});
}
function assertCanonicalWindowsFilePath(value: string): void {
if (containsControlCharacter(value)) {
throw new Error('Artifact Signing wrapper path contains control characters.');
}
if (!WINDOWS_ABSOLUTE_PATH_PATTERN.test(value)) {
throw new Error('Artifact Signing wrapper must use an absolute Windows path.');
}
const pathTail = value.slice(3);
const segments = pathTail.split(/[\\/]/);
if (
pathTail.length === 0
|| value.slice(2).includes(':')
|| segments.some(
(segment) => segment.length === 0
|| segment === '.'
|| segment === '..'
|| /[. ]$/.test(segment),
)
) {
throw new Error('Artifact Signing wrapper must use a canonical local Windows file path.');
}
}
// ─── parseThumbprintString ──────────────────────────────────────────────────
@@ -82,7 +123,7 @@ export function parseThumbprintString(raw: string): string {
* Return a new override config with Windows code-signing fields applied.
*
* Preserves all existing top-level and bundle fields; replaces only the
* three signing-specific keys under `bundle.windows`. Idempotent — calling
* signing-specific keys under `bundle.windows`. Idempotent — calling
* twice with the same thumbprint yields an equal result.
*/
export function addWindowsSigningToOverride<T extends TauriOverrideConfig>(
@@ -96,9 +137,11 @@ export function addWindowsSigningToOverride<T extends TauriOverrideConfig>(
const existingBundle: TauriBundle = config.bundle ?? {};
const existingWindows: TauriBundleWindows = existingBundle.windows ?? {};
const nonCustomCommandWindows: TauriBundleWindows = { ...existingWindows };
delete nonCustomCommandWindows.signCommand;
const nextWindows: TauriBundleWindows = {
...existingWindows,
...nonCustomCommandWindows,
certificateThumbprint: normalisedThumbprint,
digestAlgorithm,
timestampUrl,
@@ -115,6 +158,73 @@ export function addWindowsSigningToOverride<T extends TauriOverrideConfig>(
};
}
// ─── addWindowsArtifactSigningToOverride ───────────────────────────────────
/**
* Return a new override config that delegates every Tauri Windows signing
* target to the fail-closed Azure Artifact Signing wrapper.
*
* Tauri replaces `%1` with each binary path. Object form keeps the absolute
* wrapper path intact when the checkout contains spaces. Certificate-store
* fields are removed because Tauri must not combine them with `signCommand`.
*/
export function addWindowsArtifactSigningToOverride<
T extends TauriOverrideConfig,
>(config: Readonly<T>, wrapperPath: string): T {
assertCanonicalWindowsFilePath(wrapperPath);
if (wrapperPath.includes('%1')) {
throw new Error('Artifact Signing wrapper path cannot contain the %1 placeholder.');
}
const existingBuild = config.build ?? {};
const existingBundle: TauriBundle = config.bundle ?? {};
const existingWindows: TauriBundleWindows = existingBundle.windows ?? {};
const nonSigningWindows: TauriBundleWindows = { ...existingWindows };
delete nonSigningWindows.certificateThumbprint;
delete nonSigningWindows.digestAlgorithm;
delete nonSigningWindows.timestampUrl;
delete nonSigningWindows.tsp;
const nextWindows: TauriBundleWindows = {
...nonSigningWindows,
signCommand: {
cmd: WINDOWS_POWERSHELL_PATH,
args: [
'-NoLogo',
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy',
'Bypass',
'-File',
wrapperPath,
'-ArtifactPath',
'%1',
],
},
};
const placeholderCount = [
nextWindows.signCommand?.cmd,
...(nextWindows.signCommand?.args ?? []),
].flatMap((part) => part?.match(/%1/g) ?? []).length;
if (placeholderCount !== 1) {
throw new Error('Artifact Signing command must contain exactly one %1 placeholder.');
}
return {
...config,
build: {
...existingBuild,
beforeBuildCommand: '',
beforeBundleCommand: '',
},
bundle: {
...existingBundle,
active: true,
targets: ['nsis'],
windows: nextWindows,
},
};
}
// ─── addMacosAdhocToOverride ────────────────────────────────────────────────
/**

View File

@@ -0,0 +1,45 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
const playbook = readFileSync(
new URL('../../docs/code-signing-pilot-and-launch.md', import.meta.url),
'utf8',
);
const pilotScript = readFileSync(
new URL('./sign-windows-pilot.ps1', import.meta.url),
'utf8',
);
const macPilotScript = readFileSync(
new URL('./sign-macos-adhoc.sh', import.meta.url),
'utf8',
);
describe('internal pilot signing guidance', () => {
it('routes Windows builds through the explicit pilot-signing override', () => {
expect(playbook).toContain('npm run tauri:build:win:pilot-signed');
expect(playbook).not.toMatch(/^npm run tauri:build:win$/m);
expect(pilotScript).toContain(
"Write-Host ' 1. npm run tauri:build:win:pilot-signed'",
);
expect(pilotScript).not.toMatch(
/Write-Host '[ ]{2}1\. npm run tauri:build:win'\s*$/m,
);
expect(pilotScript).not.toMatch(/Write-Host '[ ]+1\. cd app/);
});
it('does not claim an ordinary macOS build loads the signing override', () => {
expect(playbook).toContain('macOS is deferred');
expect(playbook).toContain('npm run tauri:sign:pilot:mac:adhoc');
expect(playbook).not.toContain(
'so every `npm run tauri:build:mac` produces an ad-hoc-signed `.app` automatically',
);
expect(playbook).not.toContain(
'ships the macOS ad-hoc identity in the build-override config by default',
);
expect(macPilotScript).toContain(
'Treat the input as unsigned until this script signs and verifies it.',
);
expect(macPilotScript).not.toContain('already passes');
});
});

View File

@@ -5018,6 +5018,7 @@ dependencies = [
"tokio",
"urlencoding",
"uuid",
"windows-sys 0.61.2",
]
[[package]]

View File

@@ -27,3 +27,11 @@ serde_json = "1"
tokio = { version = "1", features = ["full"] }
urlencoding = "2"
uuid = { version = "1", features = ["v4"] }
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = [
"Win32_Foundation",
"Win32_Security",
"Win32_System_JobObjects",
"Win32_System_Threading",
] }

View File

@@ -1,69 +1,25 @@
; ─── Waggle NSIS Installer Template ──────────────────────────────────────────
; Waggle-specific extensions for Tauri's NSIS installer.
;
; Custom hooks for the Tauri NSIS installer:
; 1. Welcome message with Waggle branding
; 2. Desktop shortcut creation
; 3. Start Menu entry
; 4. "Launch Waggle" on finish
; 5. Uninstaller with optional ~/.waggle/ data removal
; Tauri owns install location, shortcuts, finish-page launch, silent /R launch,
; registry entries, and uninstaller cleanup. Do not duplicate those here: doing
; so double-launched normal installs and made silent repair nondeterministic.
; Autostart is handled by tauri-plugin-autostart at runtime.
; Personal data is always preserved by the package uninstaller. Tauri's base
; uninstaller exposes a generic "Delete app data" checkbox, so PREUNINSTALL
; explicitly neutralizes that state. Destructive erasure is available only
; through Waggle's authenticated, phrase-gated UI.
;
; Tauri injects NSIS defines: PRODUCT_NAME, PRODUCT_VERSION, MAINBINARYNAME,
; DEFAULT_INSTALL_DIR. Autostart is handled by tauri-plugin-autostart at
; runtime, not by the installer.
;
; Reference: https://tauri.app/distribute/windows-installer/#nsis
; ─────────────────────────────────────────────────────────────────────────────
InstallDir "${DEFAULT_INSTALL_DIR}"
; Reference: https://v2.tauri.app/distribute/windows-installer/#extending-the-installer
!macro NSIS_HOOK_PREINSTALL
DetailPrint "Installing ${PRODUCT_NAME} v${PRODUCT_VERSION}..."
DetailPrint "Your personal AI agent workspace powered by Waggle."
DetailPrint "Installing Waggle..."
DetailPrint "Your personal AI agent workspace - powered by Waggle."
!macroend
!macro NSIS_HOOK_POSTINSTALL
; ── Desktop shortcut ──────────────────────────────────────────────────────
CreateShortcut "$DESKTOP\${PRODUCT_NAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" \
"" "$INSTDIR\${MAINBINARYNAME}.exe" 0
DetailPrint "Desktop shortcut created."
; ── Start Menu entry ──────────────────────────────────────────────────────
CreateDirectory "$SMPROGRAMS\${PRODUCT_NAME}"
CreateShortcut "$SMPROGRAMS\${PRODUCT_NAME}\${PRODUCT_NAME}.lnk" \
"$INSTDIR\${MAINBINARYNAME}.exe" "" "$INSTDIR\${MAINBINARYNAME}.exe" 0
CreateShortcut "$SMPROGRAMS\${PRODUCT_NAME}\Uninstall ${PRODUCT_NAME}.lnk" \
"$INSTDIR\uninstall.exe" "" "$INSTDIR\uninstall.exe" 0
DetailPrint "Start Menu entry created."
; ── Launch after install ──────────────────────────────────────────────────
Exec '"$INSTDIR\${MAINBINARYNAME}.exe"'
DetailPrint "Launching ${PRODUCT_NAME}..."
!macroend
!macro NSIS_HOOK_POSTUNINSTALL
; ── Remove desktop shortcut ─────────────────────────────────────────────
Delete "$DESKTOP\${PRODUCT_NAME}.lnk"
; ── Remove Start Menu entries ───────────────────────────────────────────
Delete "$SMPROGRAMS\${PRODUCT_NAME}\${PRODUCT_NAME}.lnk"
Delete "$SMPROGRAMS\${PRODUCT_NAME}\Uninstall ${PRODUCT_NAME}.lnk"
RMDir "$SMPROGRAMS\${PRODUCT_NAME}"
; ── Ask about user data removal ─────────────────────────────────────────
MessageBox MB_YESNO|MB_ICONQUESTION \
"Waggle stores your data (agents, memories, configuration) in:$\r$\n$\r$\n\
$PROFILE\.waggle$\r$\n$\r$\n\
Do you want to remove this data as well?$\r$\n$\r$\n\
Choose $\"Yes$\" to delete all data, or $\"No$\" to keep it for future use." \
IDYES removeData IDNO skipData
removeData:
RMDir /r "$PROFILE\.waggle"
DetailPrint "User data removed: $PROFILE\.waggle"
Goto doneData
skipData:
DetailPrint "User data preserved: $PROFILE\.waggle"
doneData:
!macro NSIS_HOOK_PREUNINSTALL
StrCmp $DeleteAppDataCheckboxState "1" 0 +2
MessageBox MB_OK|MB_ICONINFORMATION \
"For safety, Waggle always preserves app data during uninstall. Data can only be erased from Settings > Data & Privacy while Waggle is installed."
StrCpy $DeleteAppDataCheckboxState 0
DetailPrint "Preserving Waggle app data."
!macroend

View File

@@ -42,7 +42,7 @@ pub async fn run_agent_query(
session: Option<String>,
) -> Result<String, String> {
let request_id = format!("agent-{}", Uuid::new_v4());
let port = state.port;
let port = state.verified_port()?;
let app_clone = app.clone();
let req_id_clone = request_id.clone();

View File

@@ -31,9 +31,10 @@ pub async fn recall_memory(
limit: Option<u32>,
workspace_id: Option<String>,
) -> Result<Value, String> {
let port = state.verified_port()?;
let mut url = format!(
"{}?q={}",
sidecar_url(state.port, "/api/memory/search"),
sidecar_url(port, "/api/memory/search"),
urlencoding::encode(&query)
);
if let Some(s) = scope {
@@ -61,6 +62,7 @@ pub async fn save_memory(
importance: Option<String>,
source: Option<String>,
) -> Result<Value, String> {
let port = state.verified_port()?;
let mut body = json!({ "content": content });
if let Some(ws) = workspace_id {
body["workspace"] = json!(ws);
@@ -72,7 +74,7 @@ pub async fn save_memory(
body["source"] = json!(src);
}
let url = sidecar_url(state.port, "/api/memory/frames");
let url = sidecar_url(port, "/api/memory/frames");
let resp = http_post(&url, &body).await?;
parse_json(resp).await
}
@@ -85,7 +87,8 @@ pub async fn search_entities(
workspace_id: Option<String>,
scope: Option<String>,
) -> Result<Value, String> {
let mut url = sidecar_url(state.port, "/api/memory/graph").to_string();
let port = state.verified_port()?;
let mut url = sidecar_url(port, "/api/memory/graph").to_string();
let mut params: Vec<String> = Vec::new();
if let Some(ws) = workspace_id {
params.push(format!("workspace={}", urlencoding::encode(&ws)));
@@ -111,7 +114,7 @@ pub async fn search_entities(
/// pre-A1.1 placeholders and now only fire on hard sidecar outages.
#[tauri::command]
pub async fn get_identity(state: State<'_, ServiceState>) -> Result<Value, String> {
let url = sidecar_url(state.port, "/api/identity");
let url = sidecar_url(state.verified_port()?, "/api/identity");
match http_get(&url).await {
Ok(resp) if resp.status().as_u16() == 404 => Ok(identity_placeholder(
"sidecar route 404 (unexpected post-A1.1)",

View File

@@ -26,7 +26,7 @@ use crate::service::ServiceState;
/// index (slugs + titles + metadata); call get_wiki_page_content for the body.
#[tauri::command]
pub async fn get_wiki_pages(state: State<'_, ServiceState>) -> Result<Value, String> {
let url = sidecar_url(state.port, "/api/wiki/pages");
let url = sidecar_url(state.verified_port()?, "/api/wiki/pages");
let resp = http_get(&url).await?;
parse_json(resp).await
}
@@ -36,7 +36,7 @@ pub async fn get_wiki_pages(state: State<'_, ServiceState>) -> Result<Value, Str
#[tauri::command]
pub async fn get_wiki_page(state: State<'_, ServiceState>, slug: String) -> Result<Value, String> {
let url = sidecar_url(
state.port,
state.verified_port()?,
&format!("/api/wiki/pages/{}", urlencoding::encode(&slug)),
);
let resp = http_get(&url).await?;
@@ -51,7 +51,7 @@ pub async fn get_wiki_page_content(
slug: String,
) -> Result<Value, String> {
let url = sidecar_url(
state.port,
state.verified_port()?,
&format!("/api/wiki/pages/{}/content", urlencoding::encode(&slug)),
);
let resp = http_get(&url).await?;
@@ -69,7 +69,7 @@ pub async fn compile_wiki_section(
if let Some(ws) = workspace_id {
body["workspace"] = json!(ws);
}
let url = sidecar_url(state.port, "/api/wiki/compile");
let url = sidecar_url(state.verified_port()?, "/api/wiki/compile");
let resp = http_post(&url, &body).await?;
parse_json(resp).await
}

View File

@@ -59,6 +59,52 @@ pub fn run() {
commands::onboarding::reset_first_launch,
])
.setup(|app| {
// Create the configured window here so the Windows certifier can
// opt into a loopback-only WebView CDP port without shipping
// remote debugging enabled for normal launches.
let main_window_config = app
.config()
.app
.windows
.iter()
.find(|window| window.label == "main")
.cloned()
.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
"configured main window is missing",
)
})?;
let mut main_window = tauri::WebviewWindowBuilder::from_config(
app.handle(),
&main_window_config,
)?;
#[cfg(windows)]
if let Some(raw_port) = std::env::var_os("WAGGLE_CERTIFIER_WEBVIEW_DEBUG_PORT") {
let raw_port = raw_port.to_string_lossy();
let port = raw_port.parse::<u16>().map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"WAGGLE_CERTIFIER_WEBVIEW_DEBUG_PORT must be an integer",
)
})?;
if port < 1024 {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"WAGGLE_CERTIFIER_WEBVIEW_DEBUG_PORT must be >= 1024",
)
.into());
}
let browser_args = format!(
"--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection --remote-debugging-port={port}"
);
main_window = main_window.additional_browser_args(&browser_args);
eprintln!(
"[waggle] WebView certifier debug endpoint enabled on 127.0.0.1:{port}"
);
}
main_window.build()?;
tray::setup_tray(app.handle())?;
// Register global hotkey: Ctrl+Shift+W to toggle window visibility
@@ -90,18 +136,17 @@ pub fn run() {
);
}
// Auto-start the sidecar service before the webview loads so the
// React app finds it already healthy on localhost:3333.
// Auto-start an owned sidecar launch before the webview loads.
// Its verified endpoint may differ from the preferred port.
let service_state = app.state::<ServiceState>();
let port = service_state.port;
match service::spawn_service_sync(port, &service_state.process) {
Ok(()) => eprintln!("[waggle] Sidecar spawn initiated on port {}", port),
match service::spawn_service_sync(&service_state) {
Ok(()) => eprintln!("[waggle] Owned sidecar spawn initiated"),
Err(e) => eprintln!("[waggle] Failed to auto-start sidecar: {}", e),
}
// Start service watchdog
let app_handle_watchdog = app.handle().clone();
service::start_watchdog(app_handle_watchdog, port);
service::start_watchdog(app_handle_watchdog);
Ok(())
})
@@ -115,15 +160,10 @@ pub fn run() {
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|app_handle, event| {
// R7-002: kill the sidecar on app exit so it doesn't orphan and hold port 3333.
// R7-002: kill only the owned sidecar launch on app exit.
if let tauri::RunEvent::Exit = event {
if let Some(state) = app_handle.try_state::<ServiceState>() {
if let Ok(mut proc) = state.process.lock() {
if let Some(mut child) = proc.take() {
let _ = child.kill();
let _ = child.wait();
}
}
let _ = service::stop_service_sync(&state);
}
}
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
use tauri::{
image::Image,
menu::{MenuBuilder, MenuItemBuilder},
tray::TrayIconBuilder,
tray::{MouseButton, MouseButtonState, TrayIconBuilder},
AppHandle, Emitter, Manager,
};
@@ -50,6 +50,7 @@ pub fn setup_tray(app: &AppHandle) -> Result<(), Box<dyn std::error::Error>> {
TrayIconBuilder::new()
.icon(icon)
.menu(&menu)
.show_menu_on_left_click(false)
.tooltip("Waggle Agent Service")
.on_menu_event(|app, event| match event.id().as_ref() {
"show" => {
@@ -65,7 +66,12 @@ pub fn setup_tray(app: &AppHandle) -> Result<(), Box<dyn std::error::Error>> {
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let tauri::tray::TrayIconEvent::Click { .. } = event {
if let tauri::tray::TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
show_main_window(tray.app_handle());
}
})

View File

@@ -28,21 +28,19 @@
"windows": [
{
"title": "Waggle",
"create": false,
"width": 1200,
"height": 800,
"minWidth": 800,
"minHeight": 600,
"dataDirectory": "webview",
"resizable": true,
"fullscreen": false,
"decorations": true
}
],
"security": {
"csp": "default-src 'self'; connect-src 'self' http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:* https://us.i.posthog.com; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data:"
},
"trayIcon": {
"iconPath": "icons/icon.png",
"tooltip": "Waggle - AI Agent Swarm"
"csp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:* https://us.i.posthog.com; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data:"
}
},
"plugins": {

View File

@@ -101,8 +101,9 @@ describe('auto-update configuration', () => {
expect(workflow).toContain('x86_64-apple-darwin');
});
it('uses tauri-action for builds', () => {
expect(workflow).toContain('tauri-apps/tauri-action');
it('uses the app-lockfile-pinned Tauri CLI for builds', () => {
expect(workflow).toContain('node node_modules/@tauri-apps/cli/tauri.js build');
expect(workflow).not.toMatch(/^\s*uses:\s+tauri-apps\/tauri-action/m);
});
it('does NOT publish a broken (empty-signature) updater manifest', () => {

View File

@@ -1,69 +1,66 @@
# Waggle Companion · Chrome MV3 extension
# Waggle Companion - Chrome MV3 extension
The browser-side hook for Waggle OS. Lets the user save any page or selection
to their workspace memory from anywhere on the web, without leaving the tab.
Browser-side capture for Waggle OS. It saves a page selection or the current
page to personal Waggle memory without leaving the tab.
This implements **FR-1** from the 2026-05-28 addictiveness audit — closes the
"external trigger surface" rubric gap (dim 1) for the non-coder personas
whose real workflow lives in browser tabs (researcher, journalist, marketer,
writer, retired teacher).
## What it does
## What it does (v0.1.0)
- The popup shows connection status and the active memory destination.
- Save Selection and Save Page send personal imported-memory captures.
- The context menu can save selected text.
- Pairing is explicit: generate a one-time code in Waggle Settings, then enter
it in the popup. The code is single-use and expires after ten minutes.
- **Popup** — shows connection status + the active workspace memory is saving to + two buttons (save selection / save page).
- **Right-click context menu** — "Save to Waggle memory" appears on any text selection.
- **Reuses existing sidecar endpoints** — `/api/browser-ext/session-token` for local token bootstrap, `/api/browser-ext/health` for status, and `/api/memory/frames` for ingest. No new ingest logic.
The popup never makes network requests. The MV3 background worker redeems the
code and stores only the resulting scoped credential in
`chrome.storage.local.companionToken`. Legacy `sessionToken` values are deleted
and never trusted. A rejected credential is removed; captures are never replayed
or automatically re-paired.
## How to load (developer mode, local install)
## Load locally
1. Start the Waggle sidecar with one of these env vars set so its CORS layer accepts the dev extension origin:
- **Quickest (dev only):** `WAGGLE_DEV_ALLOW_ANY_EXTENSION=1` — accepts any `chrome-extension://*` origin. Never set this in production.
- **Production-shaped:** `WAGGLE_BROWSER_EXT_IDS=<your-extension-id>` (comma-separated for multiple IDs). Pin once you have the loaded extension's ID from `chrome://extensions`.
2. Open `chrome://extensions` in Chrome (or Edge, or any Chromium browser).
3. Toggle **Developer mode** on (top right).
4. Click **Load unpacked** and pick this folder (`apps/browser-ext`).
5. Copy the extension ID shown on the card.
6. Restart the sidecar with `WAGGLE_BROWSER_EXT_IDS=<that-id>` for the production-shaped path, or skip this if you used the dev escape hatch in step 1.
7. Pin the extension to the toolbar.
8. Open the popup — you should see a green dot + "Connected" + the memory destination.
1. Start Waggle with an extension allowlist:
- Development only: `WAGGLE_DEV_ALLOW_ANY_EXTENSION=1`
- Production-shaped: `WAGGLE_BROWSER_EXT_IDS=<extension-id>`
2. Open `chrome://extensions`, enable Developer mode, and choose Load unpacked.
3. Select this `apps/browser-ext` directory.
4. If using the production-shaped allowlist, copy the installed extension ID
into `WAGGLE_BROWSER_EXT_IDS` and restart Waggle.
5. In Waggle Settings -> Advanced, generate a Browser Companion code.
6. Enter that code in the extension popup.
Without either env var set, the sidecar rejects Browser Companion token bootstrap and the popup shows setup recovery copy. The extension stores the sidecar session token in `chrome.storage.local.sessionToken` after a successful bootstrap and sends it as a bearer token on save/status calls.
## What's deliberately NOT in v0.1.0
- **Side panel chat** — the Chrome side panel for asking questions about the current page. Designed for v0.2; would call `/api/chat`.
- **One-time-code pairing UX** — v0.1.0 bootstraps the local session token for an env-allowlisted extension ID. A more explicit desktop Settings pairing flow with a one-time code is future hardening.
- **Cross-browser packaging** — manifest is MV3, works on Chrome/Edge/Brave. Firefox needs a parallel manifest shape.
- **Article extraction** — page text capture is `document.body.innerText` capped at 12k chars. Reader-mode style extraction belongs server-side.
- **Icons** — using browser default. Wire in real icons when we have the brand asset.
- **Build step** — vanilla JS, no bundler. Simpler MVP; if we add typescript/react for the side panel later, add Vite then.
Never enable `WAGGLE_DEV_ALLOW_ANY_EXTENSION` in a production build.
## Files
| File | Role |
|---|---|
| `manifest.json` | MV3 manifest — permissions, action, content script, background |
| `popup.html` | Popup UI shell (dark Hive theme inline) |
| `popup.js` | Popup logic — health refresh, selection read, save dispatch |
| `content.js` | Per-page content script — extracts selection + body text on demand |
| `background.js` | Service worker — fetch wrapper to the Waggle sidecar |
| `manifest.json` | MV3 permissions, popup, content script, background worker |
| `popup.html` | Popup UI |
| `popup.js` | Pairing, health, and capture UI logic |
| `content.js` | On-demand selection and page extraction |
| `background.js` | Pairing and authenticated loopback requests |
## Sidecar contract
- `GET /api/browser-ext/session-token` -> `{ token }` for allowlisted extension origins / MV3 service-worker requests.
- `GET /api/browser-ext/health` -> `{ ok: true, version, activeWorkspaceId, activeWorkspace }` (defined in `packages/server/src/local/routes/browser-ext.ts`; `activeWorkspace` is legacy compatibility)
- `POST /api/memory/frames` — existing endpoint, body `{ content, source: 'import', importance: 'normal' | 'low' }`. Dedup runs server-side.
- `POST /api/browser-ext/pair` redeems an allowlisted extension's valid code.
- `GET /api/browser-ext/health` reports local connection and workspace state.
- `POST /api/memory/frames` accepts only `source: "import"`, personal scope,
and normal/low importance for the paired credential.
## Verification
The global desktop session token is never exposed to the extension.
After loading the unpacked extension:
1. Click the extension icon on any web page → status should read "Connected" with a green dot.
2. Select some text → "Save selection to memory" enables → click it → toast reads "Saved to Waggle memory ✓".
3. Open the Waggle desktop → Memory app → confirm the new frame appears with source `import`.
## Manual smoke test
## Roadmap (post-MVP)
1. Open the popup before pairing: it must show the code form and disable
authenticated capture.
2. Pair with a fresh Settings code: the popup must show Connected.
3. Save a selection and confirm a personal frame with source `import` appears.
4. Revoke in Settings: the next health/save request must require a new code and
must not retry the previous capture.
- v0.2 — side panel with chat about the current page (calls `/api/chat`).
- v0.3 — pre-load Waggle's "Ask about this page" agent on important pages (configurable).
- v0.4 — Firefox MV2 parallel manifest.
- v0.5 — explicit auth pairing UX (one-time code from desktop Settings).
## Deferred
- Side-panel chat about the current page.
- Firefox-specific packaging.
- Reader-mode extraction and branded icons.

View File

@@ -1,11 +1,9 @@
// Waggle Companion background service worker — routes messages from
// popup.js to the local Waggle sidecar at 127.0.0.1:3333.
//
// MV3 service workers are short-lived; we don't keep any state here
// beyond per-message handlers. The sidecar's session token (if any) is
// pulled from chrome.storage.local on every request.
// Waggle Companion background service worker — the only extension process that
// talks to the loopback sidecar. The popup supplies a one-time code; only the
// resulting scoped credential is persisted.
const SIDECAR = 'http://127.0.0.1:3333';
const PAIRING_REQUIRED = 'Browser Companion not paired. Generate a one-time code in Waggle Settings.';
async function readJson(response) {
try {
@@ -18,56 +16,77 @@ async function readJson(response) {
function authErrorMessage(status, body) {
const code = body?.code;
if (code === 'EXTENSION_NOT_ALLOWLISTED') {
return 'Browser Companion is not allowlisted. Add this extension ID to Waggle, restart Waggle, then try again.';
return 'Browser Companion not allowlisted. Add the extension ID in Waggle, restart Waggle, and try again.';
}
if (status === 401 && code === 'INVALID_TOKEN') {
return 'Browser Companion pairing expired. Reopen Waggle desktop, then try again.';
if (code === 'PAIRING_CODE_INVALID') {
return 'Invalid or expired pairing code. Generate a new one-time code in Waggle Settings.';
}
if (status === 401 && (code === 'MISSING_TOKEN' || !code)) {
return 'Browser Companion is not paired. Start Waggle desktop, then try again.';
if (status === 401 && (code === 'INVALID_TOKEN' || code === 'MISSING_TOKEN' || !code)) {
return PAIRING_REQUIRED;
}
return body?.error || `HTTP ${status}`;
}
async function requestSessionToken() {
const r = await fetch(`${SIDECAR}/api/browser-ext/session-token`, {
method: 'GET',
headers: {
Accept: 'application/json',
'X-Waggle-Extension-Id': chrome.runtime.id,
},
});
const data = await readJson(r);
if (!r.ok || !data?.token) {
return { ok: false, error: authErrorMessage(r.status, data) };
}
await chrome.storage.local.set({ sessionToken: data.token });
return { ok: true, token: data.token };
async function removeLegacyToken() {
await chrome.storage.local.remove('sessionToken');
}
async function getAuthHeaders(options = {}) {
async function getAuthHeaders() {
try {
const { sessionToken } = await chrome.storage.local.get(['sessionToken']);
if (sessionToken) return { headers: { Authorization: `Bearer ${sessionToken}` } };
if (!options.pair) return { headers: {} };
const paired = await requestSessionToken();
if (!paired.ok) return { headers: {}, error: paired.error };
return { headers: { Authorization: `Bearer ${paired.token}` } };
const { companionToken, sessionToken } = await chrome.storage.local.get([
'companionToken',
'sessionToken',
]);
if (sessionToken) await removeLegacyToken();
if (!companionToken) return { headers: {}, error: PAIRING_REQUIRED };
return { headers: { Authorization: `Bearer ${companionToken}` } };
} catch (err) {
return { headers: {}, error: String(err) };
}
}
async function pairWithCode(rawCode) {
const code = typeof rawCode === 'string' ? rawCode.trim().toUpperCase() : '';
if (!/^[A-HJ-NP-Z2-9]{8}$/.test(code)) {
return { ok: false, error: 'Enter the 8-character code shown in Waggle Settings.' };
}
try {
const response = await fetch(`${SIDECAR}/api/browser-ext/pair`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-Waggle-Extension-Id': chrome.runtime.id,
},
body: JSON.stringify({ code }),
});
const data = await readJson(response);
if (!response.ok || typeof data?.token !== 'string') {
return { ok: false, error: authErrorMessage(response.status, data) };
}
await chrome.storage.local.set({ companionToken: data.token });
await removeLegacyToken();
return { ok: true };
} catch (err) {
return { ok: false, error: String(err) };
}
}
async function health() {
try {
const auth = await getAuthHeaders({ pair: true });
const auth = await getAuthHeaders();
if (auth.error) return { ok: false, error: auth.error };
const r = await fetch(`${SIDECAR}/api/browser-ext/health`, {
const response = await fetch(`${SIDECAR}/api/browser-ext/health`, {
method: 'GET',
headers: { Accept: 'application/json', ...auth.headers },
});
if (!r.ok) return { ok: false, error: authErrorMessage(r.status, await readJson(r)) };
return await r.json();
const data = await readJson(response);
if (response.status === 401) {
await chrome.storage.local.remove('companionToken');
return { ok: false, error: PAIRING_REQUIRED };
}
if (!response.ok) return { ok: false, error: authErrorMessage(response.status, data) };
return data;
} catch (err) {
return { ok: false, error: String(err) };
}
@@ -75,30 +94,23 @@ async function health() {
async function saveMemory(payload) {
try {
const body = JSON.stringify({
const auth = await getAuthHeaders();
if (auth.error) return { saved: false, error: auth.error };
const response = await fetch(`${SIDECAR}/api/memory/frames`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...auth.headers },
body: JSON.stringify({
content: payload.content,
source: payload.source || 'import',
importance: payload.importance || 'normal',
}),
});
const auth = await getAuthHeaders({ pair: true });
if (auth.error) return { saved: false, error: auth.error };
let r = await fetch(`${SIDECAR}/api/memory/frames`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...auth.headers },
body,
});
if (r.status === 401) {
const paired = await requestSessionToken();
if (paired.ok) {
r = await fetch(`${SIDECAR}/api/memory/frames`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${paired.token}` },
body,
});
const data = await readJson(response);
if (response.status === 401) {
await chrome.storage.local.remove('companionToken');
return { saved: false, error: PAIRING_REQUIRED };
}
}
if (!r.ok) return { saved: false, error: authErrorMessage(r.status, await readJson(r)) };
const data = await r.json();
if (!response.ok) return { saved: false, error: authErrorMessage(response.status, data) };
return {
saved: data?.saved ?? true,
duplicate: data?.duplicate ?? false,
@@ -109,16 +121,16 @@ async function saveMemory(payload) {
}
}
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
(async () => {
if (msg?.type === 'health') sendResponse(await health());
else if (msg?.type === 'save-memory') sendResponse(await saveMemory(msg));
if (message?.type === 'health') sendResponse(await health());
else if (message?.type === 'pair') sendResponse(await pairWithCode(message.code));
else if (message?.type === 'save-memory') sendResponse(await saveMemory(message));
else sendResponse({ error: 'unknown message type' });
})();
return true; // keep channel open for async sendResponse
return true;
});
// Context menu: right-click selection → "Save to Waggle memory"
chrome.runtime.onInstalled.addListener(() => {
chrome.contextMenus.create({
id: 'waggle-save-selection',
@@ -134,7 +146,6 @@ chrome.contextMenus.onClicked.addListener(async (info, tab) => {
source: 'import',
importance: 'normal',
});
// Best-effort badge feedback (MV3 has no toast API in background).
await chrome.action.setBadgeText({ text: result.saved ? '✓' : '!' });
await chrome.action.setBadgeBackgroundColor({ color: result.saved ? '#10b981' : '#ef4444' });
setTimeout(() => chrome.action.setBadgeText({ text: '' }), 2500);

View File

@@ -74,6 +74,19 @@
}
#toast.ok { color: var(--success); border-color: var(--success); }
#toast.err { color: var(--danger); border-color: var(--danger); }
#pair-form {
margin-bottom: 10px; padding: 8px; border: 1px solid var(--border);
border-radius: 8px; background: #101014;
}
#pair-form label { display: block; margin-bottom: 6px; color: var(--fg); }
.pair-row { display: flex; gap: 6px; }
#pair-code {
width: 100%; min-width: 0; padding: 7px 8px; border-radius: 6px;
border: 1px solid var(--border); background: var(--bg); color: var(--fg);
font: 600 14px/1 monospace; letter-spacing: .12em; text-transform: uppercase;
}
#pair-code:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
#pair-submit { width: auto; margin: 0; }
footer { margin-top: 10px; padding-top: 8px; border-top: 1px solid var(--border); font-size: 10px; color: var(--muted); text-align: center; }
a { color: var(--primary); text-decoration: none; }
</style>
@@ -87,6 +100,15 @@
<div class="workspace">Memory destination: <strong id="workspace-name"></strong></div>
<form id="pair-form" hidden>
<label for="pair-code">One-time code from Waggle Settings</label>
<div class="pair-row">
<input id="pair-code" name="pair-code" maxlength="8" minlength="8"
pattern="[A-HJ-NP-Za-hj-np-z2-9]{8}" autocomplete="off" spellcheck="false" required>
<button class="primary" id="pair-submit" type="submit">Pair</button>
</div>
</form>
<button class="primary" id="save-selection" disabled>
<span class="icon">💾</span><span>Save selection to memory</span>
</button>

View File

@@ -12,6 +12,9 @@ const btnSelection = $('save-selection');
const btnPage = $('save-page');
const btnOpen = $('open-waggle');
const toast = $('toast');
const pairForm = $('pair-form');
const pairCode = $('pair-code');
const pairSubmit = $('pair-submit');
let cachedSelection = '';
let cachedPageMeta = null;
@@ -60,6 +63,7 @@ async function refreshHealth() {
// textContent (not innerHTML) — workspace names are user-controlled
// and could otherwise be XSS sinks in the extension context.
workspaceNameEl.textContent = formatMemoryDestination(reply);
pairForm.hidden = true;
} else {
throw new Error(reply?.error || 'No response');
}
@@ -67,11 +71,32 @@ async function refreshHealth() {
dot.className = 'dot disconnected';
statusText.textContent = 'Not connected';
workspaceNameEl.textContent = 'Unavailable';
pairForm.hidden = false;
const msg = err?.message || 'Start Waggle desktop on this machine, then re-open this popup.';
showToast(msg, 'err', { sticky: true });
}
}
async function pair(event) {
event.preventDefault();
const code = pairCode.value.trim().toUpperCase();
pairSubmit.disabled = true;
try {
const reply = await chrome.runtime.sendMessage({ type: 'pair', code });
if (!reply?.ok) {
showToast(reply?.error || 'Pairing failed.', 'err', { sticky: true });
return;
}
pairCode.value = '';
showToast('Browser Companion paired.', 'ok');
await refreshHealth();
} catch (err) {
showToast(err?.message || 'Pairing failed.', 'err', { sticky: true });
} finally {
pairSubmit.disabled = false;
}
}
async function readActiveTab() {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab?.id) return null;
@@ -108,6 +133,7 @@ async function save(kind) {
showToast(reply.duplicate ? 'Already in memory.' : 'Saved to Waggle memory ✓', 'ok');
} else {
const msg = reply?.error || 'Save failed.';
if (isSetupError(msg)) pairForm.hidden = false;
showToast(msg, 'err', { sticky: isSetupError(msg) });
}
}
@@ -115,6 +141,7 @@ async function save(kind) {
btnSelection.addEventListener('click', () => save('selection'));
btnPage.addEventListener('click', () => save('page'));
btnOpen.addEventListener('click', () => chrome.tabs.create({ url: 'http://127.0.0.1:3333' }));
pairForm.addEventListener('submit', pair);
refreshHealth();
readActiveTab();

View File

@@ -64,7 +64,7 @@
"react-dom": "^19.2.0",
"react-hook-form": "^7.61.1",
"react-resizable-panels": "^2.1.9",
"react-router-dom": "^6.30.1",
"react-router-dom": "^6.30.4",
"recharts": "^2.15.4",
"simple-icons": "^16.15.0",
"sonner": "^1.7.4",

View File

@@ -0,0 +1,23 @@
import { createRoot } from 'react-dom/client';
import { flushSync } from 'react-dom';
import App from './App.tsx';
import './index.css';
import { applyStoredThemeEarly } from '@/providers/ThemeProvider';
export function mountApp(): void {
// Apply the persisted theme before first paint to avoid a flash of the wrong
// theme (warm graphite/dark default; warm paper for light).
applyStoredThemeEarly();
const rootElement = document.getElementById('root');
if (!rootElement) throw new Error('Waggle root element is missing');
flushSync(() => {
createRoot(rootElement).render(<App />);
});
rootElement.dataset.waggleUiReady = 'ready';
// Initialize PostHog cloud analytics (DAY0-04). Keep it off the startup path.
void import('@/lib/posthog')
.then(({ initPostHog }) => initPostHog())
.catch(() => {});
}

View File

@@ -0,0 +1,210 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const adapterMocks = vi.hoisted(() => ({
armDesktopServiceGate: vi.fn(),
connect: vi.fn(),
connectDesktopService: vi.fn(),
failDesktopServiceGate: vi.fn(),
}));
const tauriMocks = vi.hoisted(() => ({
ensureDesktopService: vi.fn(),
isTauri: vi.fn(),
listenDesktopServiceLifecycle: vi.fn(),
}));
vi.mock('./lib/adapter', () => ({ adapter: adapterMocks }));
vi.mock('./lib/tauri-bindings', () => tauriMocks);
type DesktopEndpoint = { port: number; instanceId: string };
type LifecycleEvent =
| { status: 'restarting' }
| { status: 'ready'; endpoint: DesktopEndpoint }
| { status: 'failed'; error?: string };
describe('boot connection', () => {
let emitLifecycle!: (event: LifecycleEvent) => void;
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
adapterMocks.armDesktopServiceGate.mockReturnValue(7);
adapterMocks.connect.mockResolvedValue({ status: 'ok' });
adapterMocks.connectDesktopService.mockResolvedValue({ status: 'ok' });
tauriMocks.isTauri.mockReturnValue(true);
tauriMocks.listenDesktopServiceLifecycle.mockImplementation(async (listener) => {
emitLifecycle = listener as (event: LifecycleEvent) => void;
return () => {};
});
});
it('does not release Tauri startup until the owned endpoint is connected', async () => {
const endpoint = { port: 49151, instanceId: 'desktop-instance-a' };
let publishEndpoint!: (value: typeof endpoint) => void;
tauriMocks.ensureDesktopService.mockReturnValue(
new Promise<typeof endpoint>((resolve) => { publishEndpoint = resolve; }),
);
const { armBootConnection } = await import('./boot-connect');
const startup = armBootConnection();
expect(armBootConnection()).toBe(startup);
await Promise.resolve();
expect(adapterMocks.connectDesktopService).not.toHaveBeenCalled();
publishEndpoint(endpoint);
await expect(startup).resolves.toBeUndefined();
expect(adapterMocks.connectDesktopService).toHaveBeenCalledWith(endpoint, 7);
expect(adapterMocks.failDesktopServiceGate).not.toHaveBeenCalled();
});
it('survives a stale initial launch and resolves from the replacement generation', async () => {
const replacement = { port: 49152, instanceId: 'desktop-instance-b' };
let rejectInitial!: (error: Error) => void;
tauriMocks.ensureDesktopService.mockReturnValue(
new Promise<DesktopEndpoint>((_resolve, reject) => { rejectInitial = reject; }),
);
adapterMocks.armDesktopServiceGate.mockReturnValueOnce(7).mockReturnValueOnce(8);
const { armBootConnection } = await import('./boot-connect');
const startup = armBootConnection();
await Promise.resolve();
emitLifecycle({ status: 'restarting' });
emitLifecycle({ status: 'ready', endpoint: replacement });
await expect(startup).resolves.toBeUndefined();
rejectInitial(new Error('stale managed launch changed'));
await Promise.resolve();
expect(adapterMocks.connectDesktopService).toHaveBeenCalledWith(replacement, 8);
expect(adapterMocks.failDesktopServiceGate).not.toHaveBeenCalled();
});
it('recovers when the stale launch rejects before its replacement event arrives', async () => {
const replacement = { port: 49152, instanceId: 'desktop-instance-b' };
let rejectInitial!: (error: Error) => void;
tauriMocks.ensureDesktopService.mockReturnValue(
new Promise<DesktopEndpoint>((_resolve, reject) => { rejectInitial = reject; }),
);
adapterMocks.armDesktopServiceGate.mockReturnValueOnce(7).mockReturnValueOnce(8);
const { armBootConnection } = await import('./boot-connect');
const startup = armBootConnection();
const outcome = startup.then(() => 'ready', () => 'failed');
await Promise.resolve();
rejectInitial(new Error('stale managed launch changed'));
await Promise.resolve();
emitLifecycle({ status: 'restarting' });
emitLifecycle({ status: 'ready', endpoint: replacement });
await expect(outcome).resolves.toBe('ready');
expect(adapterMocks.connectDesktopService).toHaveBeenCalledWith(replacement, 8);
expect(adapterMocks.failDesktopServiceGate).not.toHaveBeenCalled();
});
it('actively relaunches when an early child exit produces no lifecycle event', async () => {
vi.useFakeTimers();
try {
const replacement = { port: 49152, instanceId: 'desktop-instance-b' };
tauriMocks.ensureDesktopService
.mockRejectedValueOnce(new Error('managed child exited before ready'))
.mockResolvedValueOnce(replacement);
adapterMocks.armDesktopServiceGate.mockReturnValueOnce(7).mockReturnValueOnce(8);
const { armBootConnection } = await import('./boot-connect');
const startup = armBootConnection();
const outcome = startup.then(() => 'ready', () => 'failed');
await vi.advanceTimersByTimeAsync(15_000);
await expect(outcome).resolves.toBe('ready');
expect(tauriMocks.ensureDesktopService).toHaveBeenCalledTimes(2);
expect(adapterMocks.connectDesktopService).toHaveBeenCalledWith(replacement, 8);
expect(adapterMocks.failDesktopServiceGate).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it('deduplicates lifecycle-ready and ensure results for the same endpoint', async () => {
const endpoint = { port: 49151, instanceId: 'desktop-instance-a' };
let publishEndpoint!: (value: DesktopEndpoint) => void;
let finishConnection!: (value: { status: string }) => void;
tauriMocks.ensureDesktopService.mockReturnValue(
new Promise<DesktopEndpoint>((resolve) => { publishEndpoint = resolve; }),
);
adapterMocks.connectDesktopService.mockReturnValue(
new Promise<{ status: string }>((resolve) => { finishConnection = resolve; }),
);
const { armBootConnection } = await import('./boot-connect');
const startup = armBootConnection();
await Promise.resolve();
emitLifecycle({ status: 'ready', endpoint });
publishEndpoint(endpoint);
await Promise.resolve();
expect(adapterMocks.connectDesktopService).toHaveBeenCalledOnce();
finishConnection({ status: 'ok' });
await expect(startup).resolves.toBeUndefined();
});
it('cancels a concurrent ensure failure when the current endpoint binds successfully', async () => {
vi.useFakeTimers();
try {
const endpoint = { port: 49151, instanceId: 'desktop-instance-a' };
let rejectEnsure!: (error: Error) => void;
let finishConnection!: (value: { status: string }) => void;
tauriMocks.ensureDesktopService.mockReturnValue(
new Promise<DesktopEndpoint>((_resolve, reject) => { rejectEnsure = reject; }),
);
adapterMocks.connectDesktopService.mockReturnValue(
new Promise<{ status: string }>((resolve) => { finishConnection = resolve; }),
);
const { armBootConnection } = await import('./boot-connect');
const startup = armBootConnection();
await Promise.resolve();
emitLifecycle({ status: 'ready', endpoint });
rejectEnsure(new Error('stale ensure failed'));
await Promise.resolve();
finishConnection({ status: 'ok' });
await expect(startup).resolves.toBeUndefined();
await vi.advanceTimersByTimeAsync(15_000);
expect(adapterMocks.failDesktopServiceGate).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it('rejects startup and fails the current gate on a terminal ensure error', async () => {
vi.useFakeTimers();
const failure = new Error('managed service failed');
tauriMocks.ensureDesktopService.mockRejectedValue(failure);
const { armBootConnection } = await import('./boot-connect');
const startup = armBootConnection();
const rejection = expect(startup).rejects.toThrow('managed service failed');
await vi.advanceTimersByTimeAsync(15_000);
await vi.advanceTimersByTimeAsync(15_000);
await rejection;
expect(tauriMocks.ensureDesktopService).toHaveBeenCalledTimes(2);
expect(adapterMocks.failDesktopServiceGate).toHaveBeenCalledWith(failure, 7);
vi.useRealTimers();
});
it('keeps browser startup non-blocking while its health connection runs', async () => {
tauriMocks.isTauri.mockReturnValue(false);
adapterMocks.connect.mockReturnValue(new Promise(() => {}));
const { armBootConnection } = await import('./boot-connect');
await expect(armBootConnection()).resolves.toBeUndefined();
expect(adapterMocks.connect).toHaveBeenCalledOnce();
expect(adapterMocks.armDesktopServiceGate).not.toHaveBeenCalled();
});
});

View File

@@ -1,18 +1,138 @@
/**
* UX Refactor v2.1 P1b (D3) — boot connect kickoff.
*
* MUST stay main.tsx's FIRST import: ES-module import hoisting evaluates this
* module before any sibling, so the adapter's connect attempt is in flight
* before any component module could possibly issue a request — that is what
* arms the adapter's ensureReady() deferral gate for the entire boot burst
* (ServiceProvider's effect runs LAST among mount effects because it is the
* outermost provider; without this kickoff every child mount fetch would fire
* token-less first).
*
* Errors are swallowed here: ServiceProvider owns retry/backoff and the
* user-facing connection state, and a settled-failed attempt releases (then
* re-arms) the gate rather than wedging it.
* Arms backend connectivity before the React application graph is imported.
* Browser development keeps the existing fixed-URL behavior. Tauri builds
* accept only the endpoint that the Rust shell has verified belongs to its
* current managed sidecar generation.
*/
import { adapter } from './lib/adapter';
import {
ensureDesktopService,
isTauri,
listenDesktopServiceLifecycle,
type DesktopServiceEndpoint,
} from './lib/tauri-bindings';
adapter.connect().catch(() => { /* ServiceProvider surfaces connection state */ });
let bootPromise: Promise<void> | null = null;
const RECOVERY_RETRY_DELAY_MS = 15_000;
const MAX_ACTIVE_RECOVERY_ATTEMPTS = 1;
export function armBootConnection(): Promise<void> {
if (bootPromise) return bootPromise;
bootPromise = startBootConnection();
return bootPromise;
}
function startBootConnection(): Promise<void> {
if (!isTauri()) {
void adapter.connect().catch(() => {
/* ServiceProvider surfaces browser connection state. */
});
return Promise.resolve();
}
return new Promise<void>((resolveBoot, rejectBoot) => {
let bootSettled = false;
let activeGateId = adapter.armDesktopServiceGate();
let bindingKey: string | null = null;
let bindingPromise: Promise<void> | null = null;
let pendingFailure: { gateId: number; timer: ReturnType<typeof setTimeout> } | null = null;
let activeRecoveryAttempts = 0;
const cancelPendingFailure = (gateId?: number) => {
if (!pendingFailure || (gateId !== undefined && pendingFailure.gateId !== gateId)) return;
clearTimeout(pendingFailure.timer);
pendingFailure = null;
};
const failActiveGate = (error: unknown, gateId: number) => {
if (gateId !== activeGateId) return;
cancelPendingFailure(gateId);
const failure = error instanceof Error ? error : new Error(String(error));
bindingKey = null;
bindingPromise = null;
adapter.failDesktopServiceGate(failure, gateId);
if (!bootSettled) {
bootSettled = true;
rejectBoot(failure);
}
};
function deferOperationalFailure(error: unknown, gateId: number) {
if (gateId !== activeGateId || bootSettled) return;
cancelPendingFailure();
const failure = error instanceof Error ? error : new Error(String(error));
const timer = setTimeout(() => {
if (pendingFailure?.gateId !== gateId) return;
pendingFailure = null;
if (activeRecoveryAttempts < MAX_ACTIVE_RECOVERY_ATTEMPTS) {
activeRecoveryAttempts += 1;
const recoveryGateId = restartGate(false);
void ensureActiveGate(recoveryGateId);
return;
}
failActiveGate(failure, gateId);
}, RECOVERY_RETRY_DELAY_MS);
pendingFailure = { gateId, timer };
}
function restartGate(resetRecoveryAttempts = true): number {
cancelPendingFailure();
bindingKey = null;
bindingPromise = null;
if (resetRecoveryAttempts) activeRecoveryAttempts = 0;
activeGateId = adapter.armDesktopServiceGate();
return activeGateId;
}
function bindEndpoint(endpoint: DesktopServiceEndpoint, gateId: number): Promise<void> {
if (gateId !== activeGateId) return Promise.resolve();
cancelPendingFailure(gateId);
const key = `${gateId}:${endpoint.port}:${endpoint.instanceId}`;
if (bindingKey === key && bindingPromise) return bindingPromise;
bindingKey = key;
bindingPromise = adapter.connectDesktopService(endpoint, gateId)
.then(() => {
if (gateId !== activeGateId || bindingKey !== key) return;
cancelPendingFailure(gateId);
if (bootSettled) return;
bootSettled = true;
resolveBoot();
})
.catch((error) => {
if (gateId === activeGateId && bindingKey === key) {
bindingKey = null;
bindingPromise = null;
deferOperationalFailure(error, gateId);
}
});
return bindingPromise;
}
async function ensureActiveGate(gateId: number): Promise<void> {
try {
const endpoint = await ensureDesktopService();
if (gateId === activeGateId) await bindEndpoint(endpoint, gateId);
} catch (error) {
deferOperationalFailure(error, gateId);
}
}
void (async () => {
try {
await listenDesktopServiceLifecycle((event) => {
if (event.status === 'restarting') {
restartGate();
} else if (event.status === 'ready') {
void bindEndpoint(event.endpoint, activeGateId);
} else {
failActiveGate(
new Error(event.error ?? 'The managed desktop service failed'),
activeGateId,
);
}
});
} catch (error) {
failActiveGate(error, activeGateId);
return;
}
await ensureActiveGate(activeGateId);
})();
});
}

View File

@@ -128,6 +128,7 @@ const ChatHostInstance = ({ workspaceId }: { workspaceId: string }) => {
templateId={ws?.templateId}
storageType={ws?.storageType}
initialPersona={personaId}
initialModel={ws?.model}
initialMessage={seed?.initialMessage}
autoSendInitial={seed?.autoSend ?? false}
onPersonaChange={setPersona}

View File

@@ -40,6 +40,8 @@ describe('ModelPilotCard', () => {
const threshold = screen.getByRole('slider', { name: /budget saver activation threshold/i });
expect(threshold).toHaveAttribute('name', 'budgetThreshold');
expect(threshold).toHaveAttribute('min', '0.5');
expect(threshold).toHaveAttribute('max', '0.95');
expect(threshold.className).toContain('focus-visible:ring-2');
fireEvent.change(threshold, { target: { value: '0.75' } });

View File

@@ -459,17 +459,17 @@ const ModelPilotCard = ({
aria-label="Budget saver activation threshold"
name="budgetThreshold"
type="range"
min={0.1}
max={1.0}
min={0.5}
max={0.95}
step={0.05}
value={budgetThreshold}
onChange={(e) => onUpdate({ budgetThreshold: parseFloat(e.target.value) })}
className="w-full h-1.5 rounded-full appearance-none bg-muted/50 accent-[var(--honey)] cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
/>
<div className="flex justify-between text-[11px] text-muted-foreground mt-0.5">
<span>10%</span>
<span>50%</span>
<span>100%</span>
<span>75%</span>
<span>95%</span>
</div>
</div>
)}

View File

@@ -597,6 +597,16 @@ const ChatApp = ({
const followingRef = useRef(true);
useEffect(() => { followingRef.current = following; }, [following]);
const inputRef = useRef<HTMLTextAreaElement>(null);
const composerEditRevisionRef = useRef(0);
const sendSubmissionRef = useRef(0);
const composerThreadKey = `${workspaceId ?? ''}\u0000${activeSessionId ?? ''}`;
const composerThreadKeyRef = useRef(composerThreadKey);
composerThreadKeyRef.current = composerThreadKey;
const starterTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => () => {
if (starterTimeoutRef.current) clearTimeout(starterTimeoutRef.current);
starterTimeoutRef.current = null;
}, [composerThreadKey]);
const fileInputRef = useRef<HTMLInputElement>(null);
const personaPickerRef = useRef<HTMLDivElement>(null);
const modelPickerRef = useRef<HTMLDivElement>(null);
@@ -625,6 +635,13 @@ const ChatApp = ({
if (!last || last.role !== 'assistant' || !last.content) return [];
return extractSuggestedActions(last.content);
}, [messages, isLoading]);
const persistedMessageIndices = useMemo(() => {
let persistedIndex = -1;
return messages.map(message => {
if (!message.draft && !message.queued) persistedIndex += 1;
return persistedIndex;
});
}, [messages]);
// B3: the latest completed file-write becomes the work-canvas doc; auto-open
// the canvas when a NEW artifact appears (the user can close it; reopening is
@@ -807,6 +824,37 @@ const ChatApp = ({
prevCanSendRef.current = canSend;
}, [canSend]);
const submitComposerMessage = useCallback((content: string, restoreText = content) => {
const submission = ++sendSubmissionRef.current;
const editRevision = composerEditRevisionRef.current;
const threadKey = composerThreadKeyRef.current;
const restoreRejected = () => {
if (
sendSubmissionRef.current === submission
&& composerEditRevisionRef.current === editRevision
&& composerThreadKeyRef.current === threadKey
) {
setInput(current => current || restoreText);
}
};
setInput('');
setShowSlash(false);
try {
const sendResult = onSendMessage(content);
if (sendResult) {
void sendResult.then(
accepted => {
if (accepted === false) restoreRejected();
},
restoreRejected,
);
}
} catch {
restoreRejected();
}
}, [onSendMessage]);
// F2: mount-once auto-send of the wizard's first task. This fires exactly once,
// after the session has landed and history has been fetched (so the optimistic
// turn isn't clobbered by the history replace). Once consumed, the untouched
@@ -820,12 +868,14 @@ const ChatApp = ({
})) return;
const text = (initialMessage as string).trim();
autoSentRef.current = true; // consume BEFORE dispatch: StrictMode/effect-rerun safe
if (inputUnchanged) setInput('');
void Promise.resolve(onSendMessage(text)).then((ok) => {
// If the send failed, restore the untouched seed so the user can retry.
if (ok === false && inputUnchanged) setInput(prev => (prev === '' ? text : prev));
});
}, [autoSendInitial, initialMessage, activeSessionId, historyLoaded, onSendMessage]);
submitComposerMessage(text);
}, [
autoSendInitial,
initialMessage,
activeSessionId,
historyLoaded,
submitComposerMessage,
]);
// Router arc P1-B (B2): composer "Best fit" — POST the composer text to
// /api/route-proposals and inject the proposal as a LOCAL route_proposal
@@ -907,20 +957,15 @@ const ChatApp = ({
if (text === '/models') {
// Show available models as a local message
const models = availableModels?.join(', ') || 'No models loaded';
onSendMessage(`Available models: ${models}`);
setInput('');
submitComposerMessage(`Available models: ${models}`, text);
return;
}
if (text === '/cost') {
onSendMessage('/cost');
setInput('');
setShowSlash(false);
submitComposerMessage('/cost', text);
return;
}
onSendMessage(text);
setInput('');
setShowSlash(false);
submitComposerMessage(text);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
@@ -954,6 +999,7 @@ const ChatApp = ({
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const val = e.target.value;
composerEditRevisionRef.current += 1;
setInput(val);
if (val.startsWith('/')) {
setShowSlash(true);
@@ -1098,12 +1144,20 @@ const ChatApp = ({
// the first turn is a single click. Pre-filling the input first
// gives the user a visible "this is what's about to ship" beat;
// editing the input within the 1s cancels the auto-send.
composerEditRevisionRef.current += 1;
const editRevision = composerEditRevisionRef.current;
const threadKey = composerThreadKeyRef.current;
setInput(msg);
inputRef.current?.focus();
setTimeout(() => {
if (inputRef.current?.value === msg) {
onSendMessage(msg);
setInput('');
if (starterTimeoutRef.current) clearTimeout(starterTimeoutRef.current);
starterTimeoutRef.current = setTimeout(() => {
starterTimeoutRef.current = null;
if (
composerThreadKeyRef.current === threadKey
&& composerEditRevisionRef.current === editRevision
&& inputRef.current?.value === msg
) {
submitComposerMessage(msg);
}
}, 1000);
}}
@@ -1112,6 +1166,7 @@ const ChatApp = ({
// their starter strings end with ": " — the user must finish
// the sentence before sending. Cursor lands at end-of-input
// so they can type immediately.
composerEditRevisionRef.current += 1;
setInput(msg);
inputRef.current?.focus();
// Move caret to end so typing appends instead of replacing.
@@ -1141,7 +1196,10 @@ const ChatApp = ({
<p className="text-xs text-muted-foreground">Your memory and agents live inside a workspace</p>
</div>
)}
{messages.map((msg, msgIdx) => (
{messages.map((msg, msgIdx) => {
const messagePersona = msg.persona ? getPersonaById(msg.persona) : undefined;
const persistedMessageIndex = persistedMessageIndices[msgIdx];
return (
<div key={msg.id} className={`group/turn flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'} gap-2`}
onDoubleClick={() => {
if (onContextRail && msg.content) {
@@ -1165,12 +1223,12 @@ const ChatApp = ({
: ''
}`}
>
{/* I1 fix 2: the active persona's bee sprite on every assistant
turn (22 unique mascots); unknown/custom personas fall back
to the letter/Bot mark below. */}
{persona && <AvatarImage src={getPersonaAvatar(persona.id)} alt={`${persona.name} avatar`} />}
{/* The authoring persona is immutable message provenance. A
later persona switch must never relabel an earlier turn;
legacy/unknown messages fall back to the Bot mark. */}
{messagePersona && <AvatarImage src={getPersonaAvatar(messagePersona.id)} alt={`${messagePersona.name} avatar`} />}
<AvatarFallback className="text-[11px] bg-primary/20">
{persona ? persona.name[0] : <Bot className="w-3.5 h-3.5" aria-hidden="true" />}
{messagePersona ? messagePersona.name[0] : <Bot className="w-3.5 h-3.5" aria-hidden="true" />}
</AvatarFallback>
</Avatar>
)}
@@ -1184,8 +1242,13 @@ const ChatApp = ({
{msg.role === 'assistant' && (
<div className="mb-1 flex items-center gap-1.5 font-mono text-[11.5px] text-[var(--text-muted)]">
<span className="font-semibold text-[var(--text-2)]">Waggle</span>
{persona?.name && <span>· {persona.name}</span>}
{currentModel && <span>· {formatModelLabel(currentModel)}</span>}
{messagePersona?.name && <span>· {messagePersona.name}</span>}
{msg.model && <span>· {formatModelLabel(msg.model)}</span>}
{msg.draft && (
<span data-testid="chat-draft-status">
· {msg.draft.status === 'stopped' ? 'Stopped draft' : 'Draft'} · not saved
</span>
)}
</div>
)}
<div className={`relative select-text cursor-text group/msg text-sm ${
@@ -1200,14 +1263,35 @@ const ChatApp = ({
? 'rounded-[12px] bg-[var(--surface-2)] px-3 py-2 text-[12px] italic text-[var(--text-muted)]'
: 'rounded-[14px] px-3.5 py-2.5 leading-[1.6] text-[var(--text)]'
}`}>
{msg.role === 'assistant' && msg.blocks && msg.blocks.length > 0 ? (
{msg.role === 'assistant' && msg.draft?.content ? (
<div
className="whitespace-pre-wrap break-words"
data-testid="chat-draft-content"
>
<BlockRenderer blocks={[{
type: 'text',
blockId: `draft-${msg.id}`,
content: msg.draft.content,
}]} />
</div>
) : msg.role === 'assistant' && msg.blocks && msg.blocks.length > 0 ? (
<BlockRenderer
blocks={msg.blocks}
isStreaming={isLoading && msg === messages[messages.length - 1]}
workspaceId={workspaceId}
sessionId={activeSessionId}
onRetry={msgIdx === messages.length - 1 && !isLoading ? onRetry : undefined}
/>
) : msg.role === 'assistant' ? (
<BlockRenderer blocks={[{
type: 'text',
blockId: `legacy-${msg.id}`,
content: msg.content,
}]} />
) : (
<span className="whitespace-pre-wrap">{msg.content}</span>
<span className="whitespace-pre-wrap">
{msg.content}
</span>
)}
{/* Copy button — assistant turns get Copy in the hover action
row below (round-6 fix 2), so this overlay stays for user/
@@ -1262,7 +1346,7 @@ const ChatApp = ({
{msg.role === 'assistant' && msg.content && (
<FeedbackButtons
messageId={msg.id}
messageIndex={msgIdx}
messageIndex={persistedMessageIndex}
sessionId={activeSessionId ?? undefined}
feedback={msg.feedback}
content={msg.content}
@@ -1298,7 +1382,8 @@ const ChatApp = ({
)}
</div>
</div>
))}
);
})}
{/* Router arc B2: locally injected route proposals (composer Best fit). */}
{routeProposals.map(rp => (

View File

@@ -1,4 +1,4 @@
import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
@@ -6,8 +6,9 @@ const mocks = vi.hoisted(() => ({
getModel: vi.fn(),
getSettings: vi.fn(),
getTeamMembers: vi.fn(),
setModel: vi.fn(),
patchWorkspace: vi.fn(),
useChat: vi.fn(),
toast: vi.fn(),
}));
vi.mock('@/lib/adapter', () => ({ adapter: mocks }));
@@ -20,7 +21,25 @@ vi.mock('@/hooks/useSessions', () => ({
}),
}));
vi.mock('@/hooks/useChat', () => ({
useChat: () => ({
useChat: mocks.useChat,
}));
vi.mock('@/hooks/use-toast', () => ({ useToast: () => ({ toast: mocks.toast }) }));
vi.mock('./ChatApp', () => ({
default: ({ availableModels, currentModel, onModelChange }: {
availableModels: string[];
currentModel: string;
onModelChange: (model: string) => void;
}) => (
<div>
<div data-testid="models">{availableModels.join(',')}</div>
<div data-testid="current-model">{currentModel || 'auto'}</div>
<button type="button" onClick={() => onModelChange('openai/model-b')}>Select B</button>
<button type="button" onClick={() => onModelChange('openai/model-c')}>Select C</button>
</div>
),
}));
const chatState = {
messages: [],
isLoading: false,
historyLoaded: true,
@@ -30,14 +49,7 @@ vi.mock('@/hooks/useChat', () => ({
clearHistory: vi.fn(),
pendingApproval: null,
approveAction: vi.fn(),
}),
}));
vi.mock('@/hooks/use-toast', () => ({ useToast: () => ({ toast: vi.fn() }) }));
vi.mock('./ChatApp', () => ({
default: ({ availableModels }: { availableModels: string[] }) => (
<div data-testid="models">{availableModels.join(',')}</div>
),
}));
};
import ChatWindowInstance from './ChatWindowInstance';
@@ -48,8 +60,8 @@ beforeEach(() => {
mocks.getModel.mockResolvedValue('openai/existing-model');
mocks.getSettings.mockResolvedValue({});
mocks.getTeamMembers.mockResolvedValue([]);
mocks.setModel.mockResolvedValue(undefined);
mocks.patchWorkspace.mockResolvedValue(undefined);
mocks.useChat.mockReturnValue(chatState);
});
afterEach(() => {
@@ -70,4 +82,60 @@ describe('ChatWindowInstance model catalog refresh', () => {
.toHaveTextContent('openai/model-released-while-open'));
expect(mocks.getModels).toHaveBeenCalledTimes(2);
});
it('passes the workspace model into chat and ignores a stale startup result after a user choice', async () => {
let resolveStartup!: (model: string) => void;
mocks.getModel.mockReturnValueOnce(new Promise<string>((resolve) => { resolveStartup = resolve; }));
render(<ChatWindowInstance workspaceId="workspace-1" />);
fireEvent.click(screen.getByRole('button', { name: 'Select B' }));
expect(screen.getByTestId('current-model')).toHaveTextContent('openai/model-b');
await act(async () => { resolveStartup('openai/stale-startup-model'); });
await waitFor(() => expect(mocks.patchWorkspace)
.toHaveBeenCalledWith('workspace-1', { model: 'openai/model-b' }));
expect(screen.getByTestId('current-model')).toHaveTextContent('openai/model-b');
expect(mocks.useChat.mock.calls.at(-1)?.[0]).toMatchObject({ model: 'openai/model-b' });
});
it('reverts a failed latest selection to the last confirmed workspace model', async () => {
mocks.patchWorkspace.mockRejectedValueOnce(new Error('offline'));
render(<ChatWindowInstance workspaceId="workspace-1" initialModel="openai/model-a" />);
fireEvent.click(screen.getByRole('button', { name: 'Select B' }));
await waitFor(() => expect(screen.getByTestId('current-model')).toHaveTextContent('openai/model-a'));
expect(mocks.toast).toHaveBeenCalledWith(expect.objectContaining({
title: 'Model change failed',
variant: 'destructive',
}));
expect(mocks.getModel).not.toHaveBeenCalled();
});
it('serializes rapid changes and reverts the latest failure to the last successful choice', async () => {
let resolveB!: () => void;
const persistB = new Promise<void>((resolve) => { resolveB = resolve; });
mocks.patchWorkspace
.mockReturnValueOnce(persistB)
.mockRejectedValueOnce(new Error('second write failed'));
render(<ChatWindowInstance workspaceId="workspace-1" initialModel="openai/model-a" />);
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Select B' }));
await Promise.resolve();
});
expect(mocks.patchWorkspace).toHaveBeenCalledTimes(1);
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Select C' }));
await Promise.resolve();
});
expect(mocks.patchWorkspace).toHaveBeenCalledTimes(1);
await act(async () => { resolveB(); });
await waitFor(() => expect(mocks.patchWorkspace).toHaveBeenCalledTimes(2));
expect(mocks.patchWorkspace.mock.calls).toEqual([
['workspace-1', { model: 'openai/model-b' }],
['workspace-1', { model: 'openai/model-c' }],
]);
await waitFor(() => expect(screen.getByTestId('current-model')).toHaveTextContent('openai/model-b'));
});
});

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { useChat } from '@/hooks/useChat';
import { useSessions } from '@/hooks/useSessions';
import { useToast } from '@/hooks/use-toast';
@@ -13,6 +13,8 @@ interface ChatWindowInstanceProps {
workspaceId: string;
workspaceName?: string;
initialPersona?: string;
/** Workspace-scoped model already loaded by the shell. */
initialModel?: string;
/** QW-1: starter prompt prefilled into the chat input once on first mount. */
initialMessage?: string;
/** F2: auto-send the initialMessage once the chat is ready (wizard "Let's go"). */
@@ -39,6 +41,7 @@ const ChatWindowInstance = ({
workspaceId,
workspaceName,
initialPersona,
initialModel,
initialMessage,
autoSendInitial = false,
templateId,
@@ -61,6 +64,26 @@ const ChatWindowInstance = ({
const { sessions, activeSessionId, setActiveSessionId, createSession } = useSessions(workspaceId);
const [currentModel, setCurrentModel] = useState<string>(initialModel ?? '');
const currentModelRef = useRef(initialModel ?? '');
const confirmedModelRef = useRef(initialModel ?? '');
const initialModelRef = useRef(initialModel);
const modelRevisionRef = useRef(0);
const userSelectedModelRef = useRef(false);
const modelPersistenceRef = useRef<Promise<void>>(Promise.resolve());
// The shell may finish loading the workspace after this kept-alive chat
// mounts. Accept that workspace-scoped model until the user makes an
// explicit per-window choice; a late shell refresh must not overwrite it.
useEffect(() => {
initialModelRef.current = initialModel;
if (!initialModel || userSelectedModelRef.current) return;
modelRevisionRef.current += 1;
currentModelRef.current = initialModel;
confirmedModelRef.current = initialModel;
setCurrentModel(initialModel);
}, [initialModel]);
// chat-session-uuid-title (P2): the server returns a real title derived from the
// first user message, or null for a brand-new untitled session. Render a friendly
// placeholder instead of the raw `session-<uuid>` id — covering both null/empty
@@ -76,10 +99,10 @@ const ChatWindowInstance = ({
workspaceId,
sessionId: activeSessionId,
persona: currentPersona,
model: currentModel,
autonomy: { level: autonomyLevel, expiresAt: autonomyExpiresAt },
});
const [currentModel, setCurrentModel] = useState<string>('');
const [availableModels, setAvailableModels] = useState<string[]>([]);
const [teamPresence, setTeamPresence] = useState<TeamMember[]>([]);
@@ -127,19 +150,34 @@ const ChatWindowInstance = ({
// Try fetching the current active model from the sidecar. Also retries on
// transient failure — the initial render may race the sidecar spawning.
const fetchCurrentModel = async () => {
if (initialModelRef.current || userSelectedModelRef.current) {
currentLanded = true;
return;
}
const loadRevision = modelRevisionRef.current;
try {
const model = await adapter.getModel();
if (cancelled) return;
if (cancelled || userSelectedModelRef.current || loadRevision !== modelRevisionRef.current) {
currentLanded = true;
return;
}
if (typeof model === 'string' && model) {
currentModelRef.current = model;
confirmedModelRef.current = model;
setCurrentModel(model);
currentLanded = true;
return;
}
const settings = await adapter.getSettings();
if (cancelled) return;
if (cancelled || userSelectedModelRef.current || loadRevision !== modelRevisionRef.current) {
currentLanded = true;
return;
}
const fromSettings = (settings as { defaultModel?: string; model?: string }).defaultModel
?? (settings as { model?: string }).model;
if (fromSettings) {
currentModelRef.current = fromSettings;
confirmedModelRef.current = fromSettings;
setCurrentModel(fromSettings);
currentLanded = true;
}
@@ -188,11 +226,38 @@ const ChatWindowInstance = ({
}, []);
const handleModelChange = (model: string) => {
if (!model || model === currentModelRef.current) return;
userSelectedModelRef.current = true;
const revision = ++modelRevisionRef.current;
currentModelRef.current = model;
setCurrentModel(model);
adapter.setModel(model).catch((err) => console.error('[ChatWindowInstance] set model failed:', err));
adapter.patchWorkspace(workspaceId, { model })
.then(() => toast({ title: 'Model updated', description: `Now using ${formatModelLabel(model)}` }))
.catch(() => toast({ title: 'Model updated locally', description: 'Backend offline — will sync when connected', variant: 'destructive' }));
// Serialize workspace writes so two rapid clicks cannot resolve out of
// order. The request itself already carries `model`, so the optimistic
// selection is safe for an immediate Send while persistence completes.
modelPersistenceRef.current = modelPersistenceRef.current
.catch(() => undefined)
.then(async () => {
try {
await adapter.patchWorkspace(workspaceId, { model });
confirmedModelRef.current = model;
if (revision === modelRevisionRef.current) {
toast({ title: 'Model updated', description: `Now using ${formatModelLabel(model)}` });
}
} catch (err) {
console.error('[ChatWindowInstance] persist model failed:', err);
if (revision !== modelRevisionRef.current) return;
const confirmedModel = confirmedModelRef.current;
currentModelRef.current = confirmedModel;
setCurrentModel(confirmedModel);
toast({
title: 'Model change failed',
description: confirmedModel
? `Still using ${formatModelLabel(confirmedModel)}`
: 'The previous model remains active.',
variant: 'destructive',
});
}
});
};
return (

View File

@@ -79,7 +79,7 @@ const SETUP_HINTS: Record<string, ConnectorSetupHint> = {
};
/**
* The token/email inputs are a single shared state reused across every
* The credential inputs are a single shared state reused across every
* connector row. They must be cleared whenever the expanded connector
* changes (but NOT when re-collapsing the same one) so a credential typed
* for connector A can never be submitted to connector B. Pure so it can be
@@ -118,6 +118,7 @@ const ConnectorsApp = ({ personaId }: ConnectorsAppProps = {}) => {
const [expanded, setExpanded] = useState<string | null>(null);
const [tokenInput, setTokenInput] = useState('');
const [emailInput, setEmailInput] = useState('');
const [instanceUrlInput, setInstanceUrlInput] = useState('');
const [connecting, setConnecting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [revokeTarget, setRevokeTarget] = useState<ConnectorDefinition | null>(null);
@@ -126,12 +127,14 @@ const ConnectorsApp = ({ personaId }: ConnectorsAppProps = {}) => {
const { toast } = useToast();
// Expand a connector (or collapse when re-clicking the open one). Resets the
// token/email inputs whenever the target connector changes (R4-007).
// credential inputs whenever the target connector changes (R4-007).
const selectConnector = (id: string | null) => {
if (connecting) return;
setExpanded(prev => {
if (shouldResetCredentialInputs(prev, id)) {
setTokenInput('');
setEmailInput('');
setInstanceUrlInput('');
}
return id;
});
@@ -162,13 +165,17 @@ const ConnectorsApp = ({ personaId }: ConnectorsAppProps = {}) => {
if (!tokenInput.trim()) return;
setConnecting(true);
try {
if (emailInput) {
await adapter.addVaultSecret({ key: `connector:${id}:email`, value: emailInput });
}
await adapter.addVaultSecret({ key: `connector:${id}`, value: tokenInput, type: 'bearer' });
await adapter.connectConnector(id);
await adapter.connectConnector(id, {
token: tokenInput.trim(),
...(id === 'jira' ? {
email: emailInput.trim(),
baseUrl: instanceUrlInput.trim(),
} : {}),
...(id === 'salesforce' ? { instanceUrl: instanceUrlInput.trim() } : {}),
});
setTokenInput('');
setEmailInput('');
setInstanceUrlInput('');
setExpanded(null);
await loadConnectors();
} catch (err) {
@@ -277,8 +284,10 @@ const ConnectorsApp = ({ personaId }: ConnectorsAppProps = {}) => {
onToggle={() => selectConnector(expanded === conn.id ? null : conn.id)}
tokenInput={tokenInput}
emailInput={emailInput}
instanceUrlInput={instanceUrlInput}
onTokenChange={setTokenInput}
onEmailChange={setEmailInput}
onInstanceUrlChange={setInstanceUrlInput}
connecting={connecting}
onConnect={() => void handleConnect(conn.id)}
onDisconnect={() => void handleDisconnect(conn.id)}
@@ -366,7 +375,8 @@ const ConnectorsApp = ({ personaId }: ConnectorsAppProps = {}) => {
</p>
<button
onClick={() => selectConnector('composio')}
className="px-2.5 py-1 rounded-lg bg-violet-500/20 text-violet-400 text-[11px] font-display hover:bg-violet-500/30 transition-colors"
disabled={connecting}
className="px-2.5 py-1 rounded-lg bg-violet-500/20 text-violet-400 text-[11px] font-display hover:bg-violet-500/30 disabled:opacity-50 transition-colors"
>
Set up Composio
</button>

View File

@@ -266,13 +266,16 @@ describe('LauncherApp · captured tasks', () => {
expect(onOpenRoom).toHaveBeenCalledWith('room-multi');
});
it('does not offer a captured task for a GUI-only tool', async () => {
it('labels a roadmap tool and offers no launch, task, or hook actions', async () => {
mocks.adapter.detectTools.mockResolvedValue({
platform: 'darwin',
detectedAt: '2026-07-11T00:00:00.000Z',
tools: [{
id: 'cursor',
displayName: 'Cursor',
releaseStatus: 'roadmap',
launchable: false,
hookCapable: false,
installed: true,
installedPath: '/Applications/Cursor.app',
version: '1.0.0',
@@ -291,9 +294,96 @@ describe('LauncherApp · captured tasks', () => {
render(<LauncherApp workspaces={[{ id: 'ws-a', name: 'Alpha' }]} />);
expect(await screen.findByText('Cursor')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^launch$/i })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /run task/i })).not.toBeInTheDocument();
const card = await screen.findByTestId('launcher-tool-cursor');
expect(within(card).getByText('Roadmap')).toBeInTheDocument();
expect(within(card).getByText(/detection retained for compatibility/i)).toBeInTheDocument();
expect(within(card).queryByText('Detect only')).not.toBeInTheDocument();
expect(within(card).queryByText('Hooks active')).not.toBeInTheDocument();
expect(within(card).queryByRole('button', { name: /^launch$/i })).not.toBeInTheDocument();
expect(within(card).queryByRole('button', { name: /run task/i })).not.toBeInTheDocument();
expect(within(card).queryByRole('button', { name: /install hooks/i })).not.toBeInTheDocument();
});
it('launches Hermes Desktop but excludes it and a broken CLI from captured teams', async () => {
mocks.adapter.detectTools.mockResolvedValue({
platform: 'win32',
detectedAt: '2026-07-17T00:00:00.000Z',
tools: [
{
id: 'codex',
displayName: 'Codex CLI',
installed: true,
installedPath: 'C:\\tools\\codex.exe',
version: '0.144.1',
hooksInstalled: true,
hookPointerPath: 'C:\\Users\\test\\.codex\\hooks.json',
launchable: true,
capabilities: {
interactiveLaunch: true,
headlessTask: true,
structuredProgress: true,
resumable: true,
liveWaggleDance: false,
},
permissionModes: ['read-only', 'workspace-write', 'native'],
},
{
id: 'hermes',
displayName: 'Hermes Agent CLI',
installed: true,
installedPath: 'C:\\Users\\test\\AppData\\Local\\hermes\\bin\\hermes.cmd',
version: null,
hooksInstalled: false,
hookPointerPath: null,
launchable: false,
diagnostic: 'Hermes failed its --version health check.',
capabilities: {
interactiveLaunch: true,
headlessTask: true,
structuredProgress: false,
resumable: true,
liveWaggleDance: false,
},
permissionModes: ['native'],
},
{
id: 'hermes-desktop',
displayName: 'Hermes Desktop',
installed: true,
installedPath: 'C:\\Users\\test\\AppData\\Local\\hermes\\Hermes.exe',
version: null,
hooksInstalled: false,
hookPointerPath: null,
launchable: true,
hookCapable: false,
capabilities: {
interactiveLaunch: true,
headlessTask: false,
structuredProgress: false,
resumable: false,
liveWaggleDance: false,
},
permissionModes: [],
},
],
});
render(<LauncherApp workspaces={[{ id: 'ws-a', name: 'Alpha' }]} />);
const desktopCard = await screen.findByTestId('launcher-tool-hermes-desktop');
expect(within(desktopCard).getByRole('button', { name: /^launch$/i })).toBeInTheDocument();
expect(within(desktopCard).queryByRole('button', { name: /run task/i })).not.toBeInTheDocument();
expect(within(desktopCard).queryByRole('button', { name: /install hooks/i })).not.toBeInTheDocument();
expect(within(desktopCard).getByText(/launch only/i)).toBeInTheDocument();
const cliCard = screen.getByTestId('launcher-tool-hermes');
expect(within(cliCard).queryByRole('button', { name: /^launch$/i })).not.toBeInTheDocument();
expect(within(cliCard).queryByRole('button', { name: /run task/i })).not.toBeInTheDocument();
expect(within(cliCard).getByText(/failed its --version health check/i)).toBeInTheDocument();
fireEvent.click(within(screen.getByTestId('launcher-tool-codex')).getByRole('button', { name: /run task/i }));
expect(screen.queryByRole('checkbox', { name: 'Hermes Agent CLI' })).not.toBeInTheDocument();
expect(screen.queryByRole('checkbox', { name: 'Hermes Desktop' })).not.toBeInTheDocument();
});
});
@@ -553,7 +643,7 @@ describe('LauncherApp · hook cohort (#3)', () => {
expect(screen.queryByText('Install pointer')).not.toBeInTheDocument();
});
it('explains that Claude Desktop is launch-only because hooks are not supported yet', async () => {
it('offers Claude Desktop launch and hook management from the shared manifest', async () => {
mocks.adapter.detectTools.mockResolvedValue({
platform: 'darwin',
detectedAt: '2026-07-08T00:00:00.000Z',
@@ -574,9 +664,9 @@ describe('LauncherApp · hook cohort (#3)', () => {
expect(await screen.findByText('Claude Desktop')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^launch$/i })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /install hooks/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /^verify$/i })).not.toBeInTheDocument();
expect(screen.getByText(/launch only/i)).toBeInTheDocument();
expect(screen.getByText(/hooks are not supported for Claude Desktop yet/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /install hooks/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^verify$/i })).toBeInTheDocument();
expect(screen.queryByText(/launch only/i)).not.toBeInTheDocument();
expect(screen.queryByText(/hooks are not supported for Claude Desktop yet/i)).not.toBeInTheDocument();
});
});

View File

@@ -1,10 +1,10 @@
/**
* AI-OS Phase 2B — LauncherApp.
*
* Dock surface for the AI-OS tool launcher. Lists every supported
* AI tool — all 7 are launchable; 6 (all but claude-desktop) also
* support hook install/verify/uninstall — with detection status,
* hook-install status, and per-tool actions:
* Dock surface for the AI-OS tool launcher. Lists registered AI execution
* surfaces while keeping roadmap integrations visibly inert. Each card
* includes detection status, hook-install status,
* and its supported actions:
*
* Launch in workspace X / Install hooks / Verify hooks / Uninstall hooks
*
@@ -40,12 +40,13 @@ import {
BUILTIN_TOOL_MANIFESTS,
type ExternalToolAccess,
type ToolCapabilities,
type ToolReleaseStatus,
} from '@waggle/shared';
// #5 — derived from the shared manifest registry (single source of truth),
// replacing the hand-maintained local copies. LAUNCH_COHORT = launchable tools;
// HOOKS_COHORT = tools whose hive-mind hook package ships a bin (hookCapable
// claude-desktop is the only one excluded). Mirrors the backend cohorts, which
// HOOKS_COHORT = tools whose hive-mind hook package ships a bin (hookCapable).
// Hermes Desktop is intentionally excluded. Mirrors the backend cohorts, which
// derive from the same BUILTIN_TOOL_MANIFESTS.
const LAUNCH_COHORT = BUILTIN_TOOL_MANIFESTS.filter((m) => m.launchable).map((m) => m.id);
const HOOKS_COHORT = BUILTIN_TOOL_MANIFESTS.filter((m) => m.hookCapable).map((m) => m.id);
@@ -53,6 +54,7 @@ const HOOKS_COHORT = BUILTIN_TOOL_MANIFESTS.filter((m) => m.hookCapable).map((m)
interface DetectedTool {
id: string;
displayName: string;
releaseStatus?: ToolReleaseStatus;
launchable?: boolean;
hookCapable?: boolean;
builtin?: boolean;
@@ -116,10 +118,13 @@ const HOOK_PATH_RE = /([A-Za-z]:\\[^\s]+|\/[^\s]+)/;
const MAX_VISIBLE_HOOK_DETAILS = 6;
const toolCanLaunch = (tool: DetectedTool): boolean =>
tool.launchable ?? LAUNCH_COHORT.includes(tool.id);
tool.releaseStatus !== 'roadmap'
&& (tool.launchable ?? LAUNCH_COHORT.includes(tool.id));
const launchUnavailableMessage = (tool: DetectedTool): string =>
tool.installed && tool.diagnostic
tool.releaseStatus === 'roadmap'
? 'Roadmap integration. Detection retained for compatibility; launch, tasks, and hooks are deferred.'
: tool.installed && tool.diagnostic
? 'Launch is blocked for this install. Follow the note above, then refresh.'
: 'Detection ready. This adapter is not configured for launch.';
@@ -139,6 +144,7 @@ const defaultAccessForTool = (tool: DetectedTool): ExternalToolAccess | null =>
const toolCanRunCapturedTask = (tool: DetectedTool): boolean =>
tool.installed &&
toolCanLaunch(tool) &&
tool.capabilities?.headlessTask === true &&
(tool.permissionModes?.length ?? 0) > 0;
@@ -763,7 +769,7 @@ const LauncherApp = ({ activeWorkspaceId, workspaces = [], onOpenRoom }: Launche
Not installed
</Badge>
)}
{tool.hooksInstalled && (
{tool.hooksInstalled && tool.releaseStatus !== 'roadmap' && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-4" style={{ background: 'var(--honey-wash)', color: 'var(--honey)' }}>
Hooks active
</Badge>
@@ -786,7 +792,11 @@ const LauncherApp = ({ activeWorkspaceId, workspaces = [], onOpenRoom }: Launche
Running
</button>
)}
{!launchable && (
{tool.releaseStatus === 'roadmap' ? (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 text-muted-foreground">
Roadmap
</Badge>
) : !launchable && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 text-muted-foreground">
Detect only
</Badge>
@@ -906,8 +916,8 @@ const LauncherApp = ({ activeWorkspaceId, workspaces = [], onOpenRoom }: Launche
</div>
{launchOnly && (
<div className="text-[11px] text-muted-foreground">
{tool.id === 'claude-desktop'
? 'Hooks are not supported for Claude Desktop yet.'
{tool.id === 'hermes-desktop'
? 'Hook management is not supported for Hermes Desktop.'
: 'Hook management is not supported for this tool yet.'}
</div>
)}

View File

@@ -36,6 +36,7 @@ import EraseDataDialog from '@/components/os/overlays/EraseDataDialog';
import TelegramDigestCard from '@/components/os/settings/TelegramDigestCard';
import ChannelsSettings from '@/components/os/settings/ChannelsSettings';
import CoverageCompassCard from '@/components/os/settings/CoverageCompassCard';
import BrowserCompanionSettings from '@/components/os/settings/BrowserCompanionSettings';
import { AVAILABLE_SHAPES, useSelectedShape, type PromptShape } from '@/lib/shape-selection';
import { SectionLabel } from '@/components/os/warm';
import { ApprovalModal, type ApprovalRequest } from '@/components/ui/approval-modal';
@@ -1110,6 +1111,7 @@ const SettingsApp = () => {
<p className="text-[11px] text-muted-foreground font-mono">~/.waggle/</p>
<p className="text-[11px] text-muted-foreground mt-1">All workspaces, memory, vault, and config live here.</p>
</div>
<BrowserCompanionSettings />
<div className="p-3 rounded-xl bg-secondary/30 border border-border/30" data-testid="login-briefing-setting">
<div className="flex items-center justify-between mb-1">
<p className="text-xs font-display font-medium text-foreground">Show login briefing on each launch</p>

View File

@@ -236,9 +236,9 @@ const UserProfileApp = () => {
].filter((f): f is KnownFact => Boolean(f));
return (
<div className="flex h-full">
<div className="flex h-full min-h-0 min-w-0 flex-col sm:flex-row">
{/* Sidebar */}
<div className="w-36 border-r border-border/50 p-2 space-y-0.5 shrink-0" role="tablist" aria-label="Profile sections">
<div className="grid w-full shrink-0 grid-cols-2 gap-0.5 border-b border-border/50 p-2 sm:block sm:w-36 sm:border-b-0 sm:border-r sm:space-y-0.5" role="tablist" aria-label="Profile sections">
{tabs.map(t => (
<button key={t.id} onClick={() => setTab(t.id)}
role="tab"
@@ -251,7 +251,7 @@ const UserProfileApp = () => {
</button>
))}
{profile?.questionnaireCompleted && (
<div className="mt-3 pt-3 border-t border-border/30 px-2">
<div className="col-span-2 mt-3 border-t border-border/30 px-2 pt-3 sm:col-span-1">
<div className="flex items-center gap-1.5 text-[11px]" style={{ color: 'var(--healthy)' }}>
<CheckCircle2 className="w-3 h-3" /> Profile set up
</div>
@@ -260,7 +260,7 @@ const UserProfileApp = () => {
</div>
{/* Content */}
<div className="flex-1 p-4 overflow-auto" role="tabpanel">
<div className="min-h-0 min-w-0 flex-1 overflow-auto p-3 sm:p-4" role="tabpanel">
{/* ═══ IDENTITY ═══ */}
{tab === 'identity' && (
@@ -322,7 +322,7 @@ const UserProfileApp = () => {
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label htmlFor="profile-name" className="text-xs text-muted-foreground block mb-1">Name</label>
<Input id="profile-name" name="name" autoComplete="name" value={name} onChange={e => setName(e.target.value)} placeholder="Marko Markovic"
@@ -354,7 +354,7 @@ const UserProfileApp = () => {
className="w-full bg-muted/50 border border-border/50 rounded-lg px-3 py-2 text-sm text-foreground resize-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background" />
</div>
<div className="flex gap-2">
<div className="flex flex-wrap gap-2">
<button onClick={handleSave} disabled={saving}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg bg-primary text-primary-foreground hover:bg-primary/80 disabled:opacity-50 transition-colors">
{saving ? <Loader2 className="w-3 h-3 animate-spin" /> : <Save className="w-3 h-3" />} Save
@@ -384,7 +384,7 @@ const UserProfileApp = () => {
data-testid="known-fact"
>
<span className="text-[11px] uppercase tracking-wide text-muted-foreground whitespace-nowrap">{f.label}</span>
<span className="text-foreground break-words">{f.value}</span>
<span className="min-w-0 break-words text-foreground">{f.value}</span>
</li>
))}
</ul>
@@ -418,7 +418,7 @@ const UserProfileApp = () => {
{ws?.analyzed && (
<div className="p-3 rounded-xl bg-secondary/30 border border-border/30 space-y-2">
<h4 className="text-xs font-display font-semibold text-foreground">Your Style Profile</h4>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="grid grid-cols-1 gap-2 text-xs sm:grid-cols-2">
<div><span className="text-muted-foreground">Tone:</span> <span className="text-foreground capitalize">{ws.tone}</span></div>
<div><span className="text-muted-foreground">Sentences:</span> <span className="text-foreground capitalize">{ws.sentenceLength}</span></div>
<div><span className="text-muted-foreground">Vocabulary:</span> <span className="text-foreground capitalize">{ws.vocabulary}</span></div>
@@ -430,7 +430,7 @@ const UserProfileApp = () => {
<div className="p-3 rounded-xl bg-secondary/30 border border-border/30">
<h4 className="text-xs font-display font-semibold text-foreground mb-2">Communication Preference</h4>
<div className="flex gap-2">
<div className="flex flex-wrap gap-2">
{['brief', 'balanced', 'detailed'].map(s => (
<button key={s} onClick={() => { setCommStyle(s); handleSave(); }}
className={`px-3 py-1.5 rounded-lg text-xs font-display transition-colors ${
@@ -455,35 +455,35 @@ const UserProfileApp = () => {
<p className="text-[11px] text-muted-foreground">Define your brand colors and fonts. These are applied when the agent generates documents.</p>
{/* Color pickers */}
<div className="grid grid-cols-3 gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div>
<label className="text-xs text-muted-foreground block mb-1">Primary</label>
<div className="flex gap-2 items-center">
<div className="flex min-w-0 items-center gap-2">
<input type="color" name="brandPrimaryColor" aria-label="Primary color picker" autoComplete="off" value={primaryColor} onChange={e => setPrimaryColor(e.target.value)} className="w-8 h-8 rounded border-0 cursor-pointer" />
<Input name="brandPrimaryColorHex" aria-label="Primary color value" autoComplete="off" spellCheck={false} value={primaryColor} onChange={e => setPrimaryColor(e.target.value)}
className="flex-1 bg-muted/50 text-xs h-auto py-1 font-mono" />
className="min-w-0 flex-1 bg-muted/50 text-xs h-auto py-1 font-mono" />
</div>
</div>
<div>
<label className="text-xs text-muted-foreground block mb-1">Secondary</label>
<div className="flex gap-2 items-center">
<div className="flex min-w-0 items-center gap-2">
<input type="color" name="brandSecondaryColor" aria-label="Secondary color picker" autoComplete="off" value={secondaryColor} onChange={e => setSecondaryColor(e.target.value)} className="w-8 h-8 rounded border-0 cursor-pointer" />
<Input name="brandSecondaryColorHex" aria-label="Secondary color value" autoComplete="off" spellCheck={false} value={secondaryColor} onChange={e => setSecondaryColor(e.target.value)}
className="flex-1 bg-muted/50 text-xs h-auto py-1 font-mono" />
className="min-w-0 flex-1 bg-muted/50 text-xs h-auto py-1 font-mono" />
</div>
</div>
<div>
<label className="text-xs text-muted-foreground block mb-1">Accent</label>
<div className="flex gap-2 items-center">
<div className="flex min-w-0 items-center gap-2">
<input type="color" name="brandAccentColor" aria-label="Accent color picker" autoComplete="off" value={accentColor} onChange={e => setAccentColor(e.target.value)} className="w-8 h-8 rounded border-0 cursor-pointer" />
<Input name="brandAccentColorHex" aria-label="Accent color value" autoComplete="off" spellCheck={false} value={accentColor} onChange={e => setAccentColor(e.target.value)}
className="flex-1 bg-muted/50 text-xs h-auto py-1 font-mono" />
className="min-w-0 flex-1 bg-muted/50 text-xs h-auto py-1 font-mono" />
</div>
</div>
</div>
{/* Fonts */}
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label htmlFor="profile-brand-heading-font" className="text-xs text-muted-foreground block mb-1">Heading Font</label>
<Input id="profile-brand-heading-font" name="brandHeadingFont" autoComplete="off" value={fontHeading} onChange={e => setFontHeading(e.target.value)} placeholder="Inter"
@@ -523,7 +523,7 @@ const UserProfileApp = () => {
{/* Document template previews */}
<div className="border-t border-border/30 pt-4">
<h4 className="text-xs font-display font-semibold text-foreground mb-2">Document Style Previews</h4>
<div className="grid grid-cols-2 gap-2">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{[
{ icon: FileText, label: 'Word (docx)', desc: `${fontHeading} headings, ${fontBody} body` },
{ icon: Presentation, label: 'PowerPoint (pptx)', desc: `${primaryColor} theme` },

View File

@@ -78,7 +78,7 @@ const GroupDetail = ({ group, agents, onRun, onEdit, onDuplicate }: GroupDetailP
};
});
}
if (res.status === 'running') {
if (res.status === 'running' && !Array.isArray(workerSnapshots)) {
const now = Date.now();
updated.members = updated.members.map((m, i) => {
if (m.status === 'done' || m.status === 'failed') return m;

View File

@@ -6,6 +6,8 @@ import ModelSwitchBlock from './ModelSwitchBlock';
import ArtifactBlock, { isArtifactBlock } from './ArtifactBlock';
import ErrorBlock from './ErrorBlock';
import RouteProposalCard from './RouteProposalCard';
import CapabilityRequestCard, { type CapabilityRequest } from './CapabilityRequestCard';
import { segmentText } from './capability-request-parser';
import type { RouteProposalConfirmResponse } from '@/lib/route-proposals';
import { ActivityStream, type ActivityStep } from '../../warm';
import { frameSourceLabel } from '@/lib/frame-source';
@@ -13,6 +15,8 @@ import { frameSourceLabel } from '@/lib/frame-source';
interface BlockRendererProps {
blocks: ContentBlock[];
isStreaming?: boolean;
workspaceId?: string | null;
sessionId?: string | null;
/** F4: re-issue the last failed turn (threaded to error blocks). */
onRetry?: () => void;
/** Router arc B2: a route_proposal dispatch landed (ChatApp consumes the composer text). */
@@ -27,6 +31,29 @@ function getBlockKey(block: ContentBlock, index: number): string {
return `${block.type}-${index}`;
}
function trustedCapabilityProposals(blocks: ContentBlock[]): Map<string, CapabilityRequest> {
const trusted = new Map<string, CapabilityRequest>();
for (const block of blocks) {
if (
block.type !== 'tool_use'
|| block.name !== 'acquire_capability'
|| block.status !== 'done'
|| typeof block.result !== 'string'
) continue;
const segments = segmentText(block.result.trim());
const finalSegment = segments.at(-1);
const finalProposal = finalSegment?.kind === 'capability' ? finalSegment : null;
if (!finalProposal) continue;
const { request } = finalProposal;
const supportedRoute = (request.source === 'starter-pack' && request.kind === 'skill')
|| (request.source === 'marketplace' && request.kind === 'marketplace');
if (supportedRoute) trusted.set(block.id, request);
}
return trusted;
}
/**
* Group ALL "thinking" steps of a turn into one collapsible Activity card —
* the design's "the magic" surface (SCREENS §02). Default-open on the active
@@ -71,7 +98,8 @@ function renderStepGroup(steps: StepContentBlock[], key: string, isStreaming: bo
}
const BlockRenderer = ({
blocks, isStreaming, onRetry, onRouteProposalDispatched, onRouteProposalRePropose,
blocks, isStreaming, workspaceId, sessionId, onRetry,
onRouteProposalDispatched, onRouteProposalRePropose,
}: BlockRendererProps) => {
const out: ReactNode[] = [];
// F11: one Activity card per turn. Collect every step of the turn and render
@@ -80,6 +108,7 @@ const BlockRenderer = ({
// so a tool_use between two steps no longer splits the run into two cards.
const allSteps = blocks.filter((b): b is StepContentBlock => b.type === 'step');
const firstStepIdx = blocks.findIndex(b => b.type === 'step');
const capabilityProposals = trustedCapabilityProposals(blocks);
blocks.forEach((block, i) => {
if (block.type === 'step') {
@@ -92,7 +121,7 @@ const BlockRenderer = ({
case 'text':
out.push(<TextBlock key={key} block={block} isStreaming={isStreaming && isLast} />);
break;
case 'tool_use':
case 'tool_use': {
// C2: a completed file-write IS the deliverable — render an openable
// artifact card; in-flight/failed calls keep the generic tool row.
out.push(
@@ -100,7 +129,19 @@ const BlockRenderer = ({
? <ArtifactBlock key={key} block={block} />
: <ToolUseBlock key={key} block={block} />,
);
const capabilityProposal = capabilityProposals.get(block.id);
if (capabilityProposal) {
out.push(
<CapabilityRequestCard
key={`${key}-capability`}
request={capabilityProposal}
workspaceId={workspaceId}
sessionId={sessionId}
/>,
);
}
break;
}
case 'model_switch':
out.push(<ModelSwitchBlock key={key} block={block} />);
break;

View File

@@ -1,103 +1,118 @@
import { useId, useState } from 'react';
import { Loader2, Download, Plug, Zap, CheckCircle2, XCircle, Package, ShieldCheck } from 'lucide-react';
import { adapter } from '@/lib/adapter';
import { useRef, useState } from 'react';
import { Loader2, Download, CheckCircle2, XCircle, Package, ShieldCheck } from 'lucide-react';
import { adapter, AdapterHttpError } from '@/lib/adapter';
import { useToast } from '@/hooks/use-toast';
import { Input } from '@/components/ui/input';
import { useInstallStore } from '@/providers/InstallProvider';
import { describeError, type InstallOutcome, type InstallTarget } from '@/lib/install-store';
import { describeError } from '@/lib/install-store';
export interface CapabilityRequest {
name: string;
source: string;
kind?: 'skill' | 'marketplace' | 'connector' | 'mcp';
reason?: string;
/** Connector registry id (kind 'connector'); defaults to `name`. */
proposalId?: string;
expiresAt?: string;
packageId?: number;
sourceId?: number;
publisher?: string;
version?: string;
installType?: 'skill' | 'plugin' | 'mcp';
manifestDigest?: string;
riskStatus?: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | 'CLEAN';
riskScore?: number;
riskContentHash?: string;
riskBlocked?: boolean;
riskDigest?: string;
/** Reserved parser metadata; not authorized by the current card contract. */
connectorId?: string;
/** Connector auth method — token-paste vs OAuth-redirect (kind 'connector'). */
/** Reserved parser metadata; not authorized by the current card contract. */
authType?: string;
}
interface CapabilityRequestCardProps {
request: CapabilityRequest;
workspaceId?: string | null;
sessionId?: string | null;
}
type Phase = 'pending' | 'installing' | 'installed' | 'declined' | 'failed';
/**
* Inline install affordance for agent capability requests (PR4 Variation B,
* screen 09). Parsed out of agent text by TextBlock from a
* `<!--waggle:capability_request {…}-->` marker (or the legacy phrasing) so the
* user can act without leaving the conversation.
* screen 09). Rendered only from a completed acquire_capability tool result so
* the user can act without leaving the conversation.
*
* Type-aware, routed through the SHARED install store so a chat install
* reflects in the Marketplace grid + count bar immediately ("sync"):
* connector → vault-aware token-paste (OAuth → Hub, D3); FE-direct connect —
* the token NEVER transits the boolean approval channel.
* mcp → store enable (PRO + SecurityGate ride along server-side).
* marketplace → resolve packageId by name, then store install.
* starter → installPack (bundled; the store does not track on-disk skills).
* The current trusted producer contract supports bundled starter-pack skills
* and exact-name marketplace packages. Connector and MCP proposals use their
* dedicated flows and are rejected here until they carry canonical IDs.
*/
export default function CapabilityRequestCard({ request }: CapabilityRequestCardProps) {
const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
export default function CapabilityRequestCard({
request,
workspaceId,
sessionId,
}: CapabilityRequestCardProps) {
const [phase, setPhase] = useState<Phase>('pending');
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [showToken, setShowToken] = useState(false);
const [token, setToken] = useState('');
const tokenInputId = useId();
const installStarted = useRef(false);
const { toast } = useToast();
const { install } = useInstallStore();
const { confirmPackageProposal } = useInstallStore();
const kind: NonNullable<CapabilityRequest['kind']> =
request.kind ?? (request.source === 'marketplace' ? 'marketplace' : 'skill');
const isConnector = kind === 'connector';
const isMcp = kind === 'mcp';
const isMarketplace = kind === 'marketplace' || request.source === 'marketplace';
const isStarter = !isConnector && !isMcp && !isMarketplace;
const kind = request.kind;
const marketplaceIdentity = Number.isSafeInteger(request.packageId)
&& (request.packageId ?? 0) > 0
&& Number.isSafeInteger(request.sourceId)
&& (request.sourceId ?? 0) > 0
&& typeof request.proposalId === 'string'
&& UUID_V4_RE.test(request.proposalId)
&& typeof request.expiresAt === 'string'
&& Number.isFinite(Date.parse(request.expiresAt))
&& Date.parse(request.expiresAt) > Date.now()
&& typeof request.publisher === 'string'
&& request.publisher.trim().length > 0
&& typeof request.version === 'string'
&& request.version.trim().length > 0
&& typeof request.manifestDigest === 'string'
&& /^sha256:[0-9a-f]{64}$/i.test(request.manifestDigest)
&& typeof request.riskStatus === 'string'
&& ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'CLEAN'].includes(request.riskStatus)
&& typeof request.riskScore === 'number'
&& Number.isFinite(request.riskScore)
&& typeof request.riskContentHash === 'string'
&& /^(?:|[0-9a-f]{64})$/i.test(request.riskContentHash)
&& typeof request.riskBlocked === 'boolean'
&& typeof request.riskDigest === 'string'
&& /^sha256:[0-9a-f]{64}$/i.test(request.riskDigest)
&& typeof workspaceId === 'string'
&& workspaceId.length > 0
&& typeof sessionId === 'string'
&& sessionId.length > 0
&& (request.installType === 'skill'
|| request.installType === 'plugin'
|| request.installType === 'mcp');
const supportedRoute = (request.source === 'starter-pack' && kind === 'skill')
|| (request.source === 'marketplace' && kind === 'marketplace' && marketplaceIdentity);
const isMarketplace = request.source === 'marketplace' && kind === 'marketplace';
const isStarter = request.source === 'starter-pack' && kind === 'skill';
const verb = isConnector ? 'Connect' : isMcp ? 'Enable' : 'Install';
/** Map a store outcome → the card's terminal phase (the store already
* toasted; tier dispatched the upgrade event via the adapter). */
const applyOutcome = (outcome: InstallOutcome) => {
if (outcome.ok) { setPhase('installed'); return; }
setPhase('failed');
setErrorMessage(
outcome.reason === 'tier' ? 'Upgrade required'
: outcome.reason === 'security' ? 'Blocked by the security scan'
: outcome.reason === 'needs-credentials' ? 'A token is required'
: 'Install failed',
);
};
if (!supportedRoute) return null;
const handleInstall = async () => {
// Connector: OAuth can't finish inline (D3) → hand off to the Hub; token
// connectors reveal an inline paste row (the actual connect runs on submit).
if (isConnector) {
if (request.authType === 'oauth2') {
window.dispatchEvent(new CustomEvent('waggle:open-app', { detail: { appId: 'connectors' } }));
return;
}
setShowToken(true);
return;
}
if (installStarted.current) return;
installStarted.current = true;
setPhase('installing');
setErrorMessage(null);
try {
if (isMcp) {
applyOutcome(await install({ id: `mcp:${request.name}`, type: 'mcp', kind: 'federated', name: request.name }));
return;
}
if (isMarketplace) {
// The agent knows the name, not the numeric package id — resolve it.
const searchRes = await adapter.searchMarketplace(request.name, 1);
const searchData = await searchRes.json().catch(() => ({ packages: [] }));
const pkg = (searchData.packages ?? [])[0] as { id?: number; waggle_install_type?: string } | undefined;
if (!pkg?.id) throw new Error(`Marketplace package "${request.name}" not found`);
const target: InstallTarget = {
id: `pkg:${pkg.id}`, type: pkg.waggle_install_type === 'mcp' ? 'mcp' : 'skill',
kind: 'package', name: request.name, packageId: pkg.id,
};
applyOutcome(await install(target));
await confirmPackageProposal(
request.packageId!,
request.proposalId!,
workspaceId!,
sessionId!,
);
setPhase('installed');
toast({ title: 'Installed', description: `${request.name} is now active.` });
return;
}
// Starter pack — bundled, no auth; not store-tracked (on-disk skill).
@@ -105,29 +120,24 @@ export default function CapabilityRequestCard({ request }: CapabilityRequestCard
setPhase('installed');
toast({ title: 'Installed', description: `${request.name} is now active.` });
} catch (err) {
const message = describeError(err);
const proposalMessage = err instanceof AdapterHttpError
? ({
CAPABILITY_PROPOSAL_NOT_AVAILABLE: 'This install request is no longer available.',
CAPABILITY_PROPOSAL_EXPIRED: 'This install request expired. Ask Waggle to find it again.',
CAPABILITY_PROPOSAL_ALREADY_USED: 'This install request was already used.',
} as const)[err.code as 'CAPABILITY_PROPOSAL_NOT_AVAILABLE'
| 'CAPABILITY_PROPOSAL_EXPIRED'
| 'CAPABILITY_PROPOSAL_ALREADY_USED']
: undefined;
const message = proposalMessage ?? describeError(err);
setPhase('failed');
setErrorMessage(message);
toast({ title: 'Install failed', description: message, variant: 'destructive' });
}
};
const submitToken = async () => {
setPhase('installing');
setErrorMessage(null);
const outcome = await install(
{ id: `connector:${request.connectorId ?? request.name}`, type: 'connector', kind: 'federated', name: request.name },
{ token: token.trim() },
);
setShowToken(false);
setToken('');
applyOutcome(outcome);
};
const handleDecline = () => setPhase('declined');
const VerbIcon = isConnector ? Plug : isMcp ? Zap : Download;
return (
<div
data-testid="capability-request-card"
@@ -140,7 +150,7 @@ export default function CapabilityRequestCard({ request }: CapabilityRequestCard
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-display font-semibold text-foreground">
{verb} <span className="text-honey">{request.name}</span>?
Install <span className="text-honey">{request.name}</span>?
</span>
<span className="text-[11px] px-1.5 py-0.5 rounded bg-muted/60 text-muted-foreground font-display">
{kind}
@@ -150,46 +160,8 @@ export default function CapabilityRequestCard({ request }: CapabilityRequestCard
<p className="text-xs text-muted-foreground mt-1">{request.reason}</p>
)}
{/* Vault-aware connector token-paste — FE-direct connect; the token
never touches the boolean approval channel (D3). */}
{showToken && (
<div className="flex items-center gap-1.5 mt-2">
<label htmlFor={tokenInputId} className="sr-only">
{request.name} API token
</label>
<Input
id={tokenInputId}
name="capabilityConnectorToken"
autoComplete="off"
type="password"
value={token}
onChange={e => setToken(e.target.value)}
placeholder="Paste API token — stored in your vault"
data-testid="capability-connector-token-input"
className="flex-1 h-7 text-[11px]"
autoFocus
/>
<button
type="button"
onClick={() => void submitToken()}
disabled={token.trim() === '' || phase === 'installing'}
data-testid="capability-connector-token-submit"
className="px-2 py-1 text-[11px] rounded-lg bg-primary text-primary-foreground hover:bg-primary/80 transition-colors disabled:opacity-50"
>
Connect
</button>
<button
type="button"
onClick={() => { setShowToken(false); setToken(''); }}
className="px-2 py-1 text-[11px] rounded-lg text-muted-foreground hover:text-foreground transition-colors"
>
Cancel
</button>
</div>
)}
<div className="flex items-center gap-2 mt-2.5">
{phase === 'pending' && !showToken && (
{phase === 'pending' && (
<>
<button
type="button"
@@ -197,7 +169,7 @@ export default function CapabilityRequestCard({ request }: CapabilityRequestCard
data-testid="capability-request-install"
className="flex items-center gap-1.5 px-2.5 py-1 text-xs rounded-lg bg-primary text-primary-foreground hover:bg-primary/80 font-display transition-colors"
>
<VerbIcon className="w-3 h-3" /> {verb}
<Download className="w-3 h-3" /> Install
</button>
<button
type="button"

View File

@@ -1,10 +1,10 @@
import { memo, useMemo, Fragment } from 'react';
import { memo, useMemo } from 'react';
import type { TextContentBlock } from '@/lib/types';
import CapabilityRequestCard from './CapabilityRequestCard';
import { segmentText } from './capability-request-parser';
import { renderChatMarkdown } from '@/lib/render-markdown';
import { useStreamCadence } from '@/hooks/useStreamCadence';
const CAPABILITY_MARKER_DISPLAY_RE = /<!--\s*waggle:capability_request[\s\S]*?-->/g;
interface TextBlockProps {
block: TextContentBlock;
isStreaming?: boolean;
@@ -17,44 +17,24 @@ const TextBlock = memo(({ block, isStreaming }: TextBlockProps) => {
// first-token latency — `raw` already holds every delivered chunk; this only
// paces the paint. Settled/history turns + reduced-motion snap to whole text.
const { shown, caretVisible } = useStreamCadence(raw, !!isStreaming);
const segments = useMemo(() => segmentText(shown), [shown]);
const displayText = useMemo(
() => shown.replace(CAPABILITY_MARKER_DISPLAY_RE, ''),
[shown],
);
if (!raw && !isStreaming) return null;
// Streaming caret + bouncing-dot loader behaviour preserved from the original
// implementation. We attach the caret to the last text segment so the visual
// flow doesn't break when capability cards are interleaved with text.
let cursorAttached = false;
return (
<div>
{segments.map((seg, i) => {
if (seg.kind === 'capability') {
return <CapabilityRequestCard key={`cap-${i}`} request={seg.request} />;
}
const isLastTextSegment = !cursorAttached && i === segments.length - 1;
cursorAttached = cursorAttached || isLastTextSegment;
return (
<Fragment key={`txt-${i}`}>
{/* renderChatMarkdown escapes the full input before emitting any
tag (S04-hardened pattern) — partial markdown crossing the reveal
head forms as escaped text, never raw noise. */}
{seg.content && (
<span dangerouslySetInnerHTML={{ __html: renderChatMarkdown(seg.content) }} />
)}
{caretVisible && isLastTextSegment && seg.content && (
{/* Assistant text is presentation data only. renderChatMarkdown escapes
it before emitting tags; privileged controls come from tool results. */}
{displayText && <span dangerouslySetInnerHTML={{ __html: renderChatMarkdown(displayText) }} />}
{caretVisible && displayText && (
<span
aria-hidden
// R21 (design/competitor HIGH): the 2px caret was invisible at
// video scale ("no blinking caret"). A 3px rounded honey bar at
// ~1.15em reads as a live typing cursor; .stream-caret carries the
// token'd blink (reduced-motion → solid, no blink).
className="stream-caret inline-block w-[3px] h-[1.15em] rounded-[1.5px] bg-[var(--honey-text)] ml-0.5 align-text-bottom"
/>
)}
</Fragment>
);
})}
{isStreaming && !shown && (
<span className="inline-flex gap-1 ml-1">
<span className="w-1.5 h-1.5 rounded-full bg-primary/60 animate-bounce" style={{ animationDelay: '0ms' }} />

View File

@@ -13,15 +13,67 @@ const MARKER_RE = /<!--\s*waggle:capability_request\s+(\{[^}]+\})\s*-->/g;
// Falls back to this when the agent hasn't been updated to emit Pattern A.
const LEGACY_RE = /`install_capability`\s+with\s+name\s+"([^"]+)"\s+and\s+source\s+"([^"]+)"/gi;
const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const SHA256_RE = /^sha256:[0-9a-f]{64}$/i;
const CONTENT_HASH_RE = /^(?:|[0-9a-f]{64})$/i;
const RISK_STATUSES = new Set(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'CLEAN']);
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
function isMarketplaceProposal(obj: Partial<CapabilityRequest>): boolean {
return Number.isSafeInteger(obj.packageId)
&& (obj.packageId ?? 0) > 0
&& Number.isSafeInteger(obj.sourceId)
&& (obj.sourceId ?? 0) > 0
&& isNonEmptyString(obj.proposalId)
&& UUID_V4_RE.test(obj.proposalId)
&& isNonEmptyString(obj.expiresAt)
&& Number.isFinite(Date.parse(obj.expiresAt))
&& Date.parse(obj.expiresAt) > Date.now()
&& isNonEmptyString(obj.publisher)
&& isNonEmptyString(obj.version)
&& (obj.installType === 'skill' || obj.installType === 'plugin' || obj.installType === 'mcp')
&& isNonEmptyString(obj.manifestDigest)
&& SHA256_RE.test(obj.manifestDigest)
&& isNonEmptyString(obj.riskStatus)
&& RISK_STATUSES.has(obj.riskStatus)
&& typeof obj.riskScore === 'number'
&& Number.isFinite(obj.riskScore)
&& typeof obj.riskContentHash === 'string'
&& CONTENT_HASH_RE.test(obj.riskContentHash)
&& typeof obj.riskBlocked === 'boolean'
&& isNonEmptyString(obj.riskDigest)
&& SHA256_RE.test(obj.riskDigest);
}
function parseRequest(jsonRaw: string): CapabilityRequest | null {
try {
const obj = JSON.parse(jsonRaw) as Partial<CapabilityRequest>;
if (!obj.name || !obj.source) return null;
const isMarketplace = obj.source === 'marketplace' && obj.kind === 'marketplace';
if (isMarketplace && !isMarketplaceProposal(obj)) return null;
return {
name: String(obj.name),
source: String(obj.source),
kind: obj.kind,
reason: obj.reason ? String(obj.reason) : undefined,
...(isMarketplace ? {
proposalId: obj.proposalId,
expiresAt: obj.expiresAt,
packageId: obj.packageId,
sourceId: obj.sourceId,
publisher: obj.publisher,
version: obj.version,
installType: obj.installType,
manifestDigest: obj.manifestDigest,
riskStatus: obj.riskStatus,
riskScore: obj.riskScore,
riskContentHash: obj.riskContentHash,
riskBlocked: obj.riskBlocked,
riskDigest: obj.riskDigest,
} : {}),
...(obj.connectorId ? { connectorId: String(obj.connectorId) } : {}),
...(obj.authType ? { authType: String(obj.authType) } : {}),
};

View File

@@ -8,7 +8,7 @@
*
* Per-row async state (sync, lazy health, audit history) lives HERE; the
* shared credential inputs + the revoke confirm stay in the parent so the
* R4-007 credential-isolation guarantee (one shared input pair, reset on
* R4-007 credential-isolation guarantee (one shared input set, reset on
* target change) is preserved.
*/
import { useState } from 'react';
@@ -52,11 +52,13 @@ interface ConnectorCardProps {
hint?: ConnectorSetupHint;
expanded: boolean;
onToggle: () => void;
/** Shared credential inputs (single pair, parent-owned — R4-007). */
/** Shared credential inputs (single parent-owned set — R4-007). */
tokenInput: string;
emailInput: string;
instanceUrlInput: string;
onTokenChange: (v: string) => void;
onEmailChange: (v: string) => void;
onInstanceUrlChange: (v: string) => void;
connecting: boolean;
onConnect: () => void;
onDisconnect: () => void;
@@ -68,7 +70,8 @@ interface ConnectorCardProps {
const ConnectorCard = ({
conn, categoryLabel, hint, expanded, onToggle,
tokenInput, emailInput, onTokenChange, onEmailChange,
tokenInput, emailInput, instanceUrlInput,
onTokenChange, onEmailChange, onInstanceUrlChange,
connecting, onConnect, onDisconnect, onRevoke, onSynced,
}: ConnectorCardProps) => {
const [syncing, setSyncing] = useState(false);
@@ -79,6 +82,10 @@ const ConnectorCard = ({
const isConnected = conn.status === 'connected';
const isExpired = conn.status === 'expired';
const needsEmail = conn.id === 'jira';
const needsSiteUrl = conn.id === 'jira' || conn.id === 'salesforce';
const credentialsComplete = Boolean(tokenInput.trim())
&& (!needsEmail || Boolean(emailInput.trim()))
&& (!needsSiteUrl || Boolean(instanceUrlInput.trim()));
const identity = getBrandIdentity(conn.id, conn.name, categoryLabel);
const badge = connectorStatusBadge(conn.status, syncing);
@@ -113,8 +120,8 @@ const ConnectorCard = ({
return (
<div className="group rounded-xl border border-border/30 overflow-hidden transition-colors hover:border-primary/30 hover:bg-secondary/10">
<button onClick={handleExpand} aria-expanded={expanded}
className={cn('w-full flex items-center justify-between gap-3 p-2.5 transition-colors', CONTROL_FOCUS_CLASS)}>
<button onClick={handleExpand} aria-expanded={expanded} disabled={connecting}
className={cn('w-full flex items-center justify-between gap-3 p-2.5 disabled:cursor-not-allowed disabled:opacity-60 transition-colors', CONTROL_FOCUS_CLASS)}>
<div className="flex items-center gap-2.5 min-w-0 flex-1">
<BrandTile identity={identity} size={36} connected={isConnected} />
<div className="text-left min-w-0 flex-1">
@@ -220,17 +227,27 @@ const ConnectorCard = ({
needs an explicit aria-label. */}
{needsEmail && (
<Input type="email" name="connectorEmail" autoComplete="email" inputMode="email" spellCheck={false}
disabled={connecting}
value={emailInput} onChange={e => onEmailChange(e.target.value)} placeholder="Your Atlassian email"
aria-label="Atlassian account email"
className="w-full bg-muted/50 text-xs h-auto py-1" />
)}
{needsSiteUrl && (
<Input type="url" name={needsEmail ? 'connectorBaseUrl' : 'connectorInstanceUrl'} autoComplete="url" inputMode="url" spellCheck={false}
disabled={connecting}
value={instanceUrlInput} onChange={e => onInstanceUrlChange(e.target.value)}
placeholder={needsEmail ? 'https://your-team.atlassian.net' : 'https://your-domain.my.salesforce.com'}
aria-label={needsEmail ? 'Jira site URL' : 'Salesforce instance URL'}
className="w-full bg-muted/50 text-xs h-auto py-1 font-mono" />
)}
<div className="flex gap-2">
<Input type="password" name="connectorToken" autoComplete="off" spellCheck={false}
disabled={connecting}
value={tokenInput} onChange={e => onTokenChange(e.target.value)}
placeholder={hint?.placeholder ?? 'Paste token or API key'}
aria-label={`${conn.name} API token`}
className="flex-1 bg-muted/50 text-xs h-auto py-1 font-mono" />
<button onClick={onConnect} disabled={!tokenInput.trim() || connecting}
<button onClick={onConnect} disabled={!credentialsComplete || connecting}
className={cn('flex items-center gap-1 px-3 py-1 text-xs rounded-lg bg-primary text-primary-foreground hover:bg-primary/80 disabled:opacity-50 transition-colors', CONTROL_FOCUS_CLASS)}>
{connecting ? <Loader2 className="w-3 h-3 animate-spin" /> : <Plug className="w-3 h-3" />} Connect
</button>

View File

@@ -10,6 +10,8 @@ const mocks = vi.hoisted(() => ({
adapter: {
getProviders: vi.fn(),
getLocalInferenceStatus: vi.fn(),
getLocalInferenceModels: vi.fn(),
bootstrapLocalRuntime: vi.fn(),
testApiKey: vi.fn(),
setProviderKey: vi.fn(),
restartModelRouter: vi.fn(),
@@ -46,13 +48,44 @@ const providersResp = (...defs: {
activeSearch: 'duckduckgo',
});
const noLocal = { servers: [], ollamaInstalled: false, totalLocalModels: 0 };
const noLocal = {
servers: [{ type: 'ollama' }],
ollamaInstalled: true,
ollamaRunning: true,
totalLocalModels: 0,
offlineReady: false,
dockerRequired: false,
managedRuntime: {
source: 'waggle-managed',
supported: true,
installed: true,
running: true,
targetVersion: '0.32.0',
version: '0.32.0',
artifactSizeBytes: 1_503_047_573,
downloadRequired: false,
dockerRequired: false,
},
setupRequired: true,
setupMessage: null,
};
beforeEach(() => {
mocks.adapter.getProviders.mockResolvedValue(
providersResp({ id: 'anthropic', hasKey: false }, { id: 'openai', hasKey: false }, { id: 'ollama', hasKey: false }),
);
mocks.adapter.getLocalInferenceStatus.mockResolvedValue(noLocal);
mocks.adapter.getLocalInferenceModels.mockResolvedValue({
source: 'native',
models: [{ name: 'qwen3:1.7b', fitLevel: 'perfect', estimatedTps: 32, runMode: 'gpu' }],
});
mocks.adapter.bootstrapLocalRuntime.mockResolvedValue({
ok: true,
installedNow: true,
startedNow: true,
endpoint: 'http://127.0.0.1:11434',
dockerRequired: false,
});
mocks.adapter.testApiKey.mockResolvedValue({ valid: true, verified: true });
mocks.adapter.setProviderKey.mockResolvedValue({ router: { managed: true, ready: true } });
mocks.adapter.restartModelRouter.mockResolvedValue({
@@ -62,7 +95,11 @@ beforeEach(() => {
unavailableProviders: [],
});
mocks.adapter.saveSettings.mockResolvedValue(undefined);
mocks.adapter.pullLocalModel.mockResolvedValue({ ok: true });
mocks.adapter.pullLocalModel.mockResolvedValue({
ok: true,
model: 'llama3.2:latest',
verifiedGeneration: true,
});
// F3: default probe = network-degrade neutral (valid, not verified) so the
// key-presence tests keep their "You have a working model" wording.
mocks.adapter.probeProvider.mockResolvedValue({ configured: true, valid: true, verified: false });
@@ -95,7 +132,7 @@ describe('ModelGate', () => {
expect(key).toHaveAttribute('autocomplete', 'off');
fireEvent.click(screen.getByRole('tab', { name: /local model/i }));
const pull = await screen.findByLabelText(/pull a model/i);
const pull = await screen.findByLabelText(/download and verify a model/i);
expect(pull).toHaveAttribute('name', 'modelPullName');
expect(pull).toHaveAttribute('autocomplete', 'off');
});
@@ -373,15 +410,82 @@ describe('ModelGate', () => {
expect(mocks.adapter.probeProvider).toHaveBeenCalledWith('anthropic');
});
it('the local tab pulls a model and fires onModelReady', async () => {
it('installs and starts Waggles managed runtime without Docker or a system Ollama install', async () => {
mocks.adapter.getLocalInferenceStatus
.mockResolvedValueOnce({
...noLocal,
servers: [],
ollamaInstalled: false,
ollamaRunning: false,
managedRuntime: {
...noLocal.managedRuntime,
installed: false,
running: false,
version: null,
downloadRequired: true,
},
})
.mockResolvedValue(noLocal);
render(<ModelGate />);
fireEvent.click(await screen.findByRole('tab', { name: /local model/i }));
expect(await screen.findByText(/1\.4 GB/i)).toBeInTheDocument();
expect(screen.getByText(/no Docker, administrator access, or system Ollama install required/i)).toBeInTheDocument();
expect(screen.queryByText(/install Ollama to run models/i)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /^install runtime$/i }));
await waitFor(() => expect(mocks.adapter.bootstrapLocalRuntime).toHaveBeenCalledOnce());
expect(await screen.findByText(/private runtime ready/i)).toBeInTheDocument();
expect(await screen.findByLabelText(/download and verify a model/i)).toBeInTheDocument();
});
it('does not offer a fake managed install on an unsupported platform', async () => {
mocks.adapter.getLocalInferenceStatus.mockResolvedValue({
...noLocal,
servers: [],
ollamaInstalled: false,
ollamaRunning: false,
managedRuntime: {
...noLocal.managedRuntime,
supported: false,
installed: false,
running: false,
version: null,
downloadRequired: true,
reason: 'No managed Ollama artifact for linux/x64',
},
});
render(<ModelGate />);
fireEvent.click(await screen.findByRole('tab', { name: /local model/i }));
expect(await screen.findByText(/no managed Ollama artifact for linux\/x64/i)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /install runtime/i })).not.toBeInTheDocument();
});
it('downloads, generation-verifies, and selects the local model before reporting ready', async () => {
const onModelReady = vi.fn();
render(<ModelGate onModelReady={onModelReady} />);
fireEvent.click(await screen.findByRole('tab', { name: /local model/i }));
const input = await screen.findByLabelText(/pull a model/i);
const input = await screen.findByLabelText(/download and verify a model/i);
fireEvent.change(input, { target: { value: 'llama3.2' } });
fireEvent.click(screen.getByRole('button', { name: /^pull$/i }));
fireEvent.click(screen.getByRole('button', { name: /^install model$/i }));
await waitFor(() => expect(mocks.adapter.pullLocalModel).toHaveBeenCalledWith('llama3.2'));
expect(mocks.adapter.saveSettings).toHaveBeenCalledWith({ defaultModel: 'ollama/llama3.2:latest' });
expect(await screen.findByText(/installed and verified "llama3\.2:latest"/i)).toBeInTheDocument();
expect(onModelReady).toHaveBeenCalled();
});
it('does not report ready when the verified model cannot be selected as default', async () => {
const onModelReady = vi.fn();
mocks.adapter.saveSettings.mockRejectedValueOnce(new Error('settings unavailable'));
render(<ModelGate onModelReady={onModelReady} />);
fireEvent.click(await screen.findByRole('tab', { name: /local model/i }));
const input = await screen.findByLabelText(/download and verify a model/i);
fireEvent.change(input, { target: { value: 'llama3.2' } });
fireEvent.click(screen.getByRole('button', { name: /^install model$/i }));
expect(await screen.findByRole('alert')).toHaveTextContent(/could not select it as the default/i);
expect(onModelReady).not.toHaveBeenCalled();
});
});

View File

@@ -41,7 +41,32 @@ interface ModelGateProps {
interface LocalStatus {
servers: Array<Record<string, unknown>>;
ollamaInstalled: boolean;
ollamaRunning?: boolean;
totalLocalModels: number;
dockerRequired?: false;
managedRuntime?: {
supported: boolean;
installed: boolean;
running: boolean;
targetVersion: string | null;
version: string | null;
artifactSizeBytes: number | null;
downloadRequired: boolean;
dockerRequired: false;
reason?: string;
};
}
interface LocalModelRecommendation {
name: string;
fitLevel?: string;
estimatedTps?: number;
runMode?: string;
}
function formatDownloadSize(bytes: number | null | undefined): string | null {
if (!bytes || bytes <= 0) return null;
return `${(bytes / (1024 ** 3)).toFixed(1)} GB`;
}
type ValidateState =
@@ -98,6 +123,9 @@ export function ModelGate({
// Local models
const [local, setLocal] = useState<LocalStatus | null>(null);
const [recommendedLocal, setRecommendedLocal] = useState<LocalModelRecommendation | null>(null);
const [bootstrapping, setBootstrapping] = useState(false);
const [runtimeMsg, setRuntimeMsg] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null);
const [pullName, setPullName] = useState('');
const [pulling, setPulling] = useState(false);
const [pullMsg, setPullMsg] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null);
@@ -108,6 +136,26 @@ export function ModelGate({
} catch {
setLocal({ servers: [], ollamaInstalled: false, totalLocalModels: 0 });
}
try {
const result = await adapter.getLocalInferenceModels('general');
const candidate = result.models.find((model) => (
typeof model.name === 'string'
&& model.fitLevel !== 'too_tight'
&& model.runMode !== 'no_fit'
));
const recommendation: LocalModelRecommendation | null = candidate && typeof candidate.name === 'string'
? {
name: candidate.name,
...(typeof candidate.fitLevel === 'string' ? { fitLevel: candidate.fitLevel } : {}),
...(typeof candidate.estimatedTps === 'number' ? { estimatedTps: candidate.estimatedTps } : {}),
...(typeof candidate.runMode === 'string' ? { runMode: candidate.runMode } : {}),
}
: null;
setRecommendedLocal(recommendation ?? null);
if (recommendation?.name) setPullName((current) => current || recommendation.name);
} catch {
setRecommendedLocal(null);
}
}, []);
useEffect(() => { void refreshLocal(); }, [refreshLocal]);
@@ -343,21 +391,55 @@ export function ModelGate({
setPullMsg(null);
try {
const res = await adapter.pullLocalModel(name);
if (res?.ok) {
setPullMsg({ kind: 'ok', text: `Pulled "${name}".` });
if (res?.ok && res.verifiedGeneration) {
let selectedAsDefault = true;
try {
await adapter.saveSettings({ defaultModel: `ollama/${res.model}` });
} catch {
selectedAsDefault = false;
}
setPullMsg({
kind: selectedAsDefault ? 'ok' : 'err',
text: selectedAsDefault
? `Installed and verified "${res.model}". It is now your default local model.`
: `Installed and verified "${res.model}", but Waggle could not select it as the default.`,
});
setPullName('');
await refreshLocal();
onModelReady?.();
if (selectedAsDefault) onModelReady?.();
} else {
setPullMsg({ kind: 'err', text: `Could not pull "${name}".` });
setPullMsg({ kind: 'err', text: `Could not install and verify "${name}".` });
}
} catch {
setPullMsg({ kind: 'err', text: `Could not pull "${name}" — is Ollama running?` });
} catch (error) {
setPullMsg({
kind: 'err',
text: error instanceof Error ? error.message : `Could not install and verify "${name}".`,
});
} finally {
setPulling(false);
}
};
const handleBootstrap = async () => {
setBootstrapping(true);
setRuntimeMsg(null);
try {
await adapter.bootstrapLocalRuntime();
await refreshLocal();
setRuntimeMsg({
kind: 'ok',
text: 'Private runtime ready. Download the recommended model to finish local setup.',
});
} catch (error) {
setRuntimeMsg({
kind: 'err',
text: error instanceof Error ? error.message : 'Could not install the private runtime.',
});
} finally {
setBootstrapping(false);
}
};
// Round-P provider selector: a real filled-tile grid (not a pill row). The
// fill/glyph encode key state at a glance; failing = the live probe rejected
// the stored key (same `probe.failedProvider` signal the old chip carried).
@@ -672,22 +754,75 @@ export function ModelGate({
{tab === 'local' && (
<div className="space-y-3" role="tabpanel">
<p className="text-sm text-muted-foreground">
{local?.ollamaInstalled
? `Ollama detected${local.totalLocalModels} model${local.totalLocalModels === 1 ? '' : 's'} installed.`
: 'No local runtime detected. Install Ollama to run models privately on your machine.'}
{(local?.ollamaRunning ?? local?.ollamaInstalled)
? `Private runtime running${local?.totalLocalModels ?? 0} model${local?.totalLocalModels === 1 ? '' : 's'} installed.`
: local?.managedRuntime?.installed
? 'Private runtime installed but not running. Start it here to use local models.'
: local?.managedRuntime?.supported
? 'No local runtime yet. Waggle can install and manage it for you.'
: local?.managedRuntime?.reason ?? 'Managed runtime status is unavailable. Retry the check before local setup.'}
</p>
{!(local?.ollamaRunning ?? local?.ollamaInstalled) && local?.managedRuntime?.supported && (
<div className="space-y-2 rounded-lg border border-border bg-card/60 p-3">
<div className="flex items-start gap-2">
<Cpu className="mt-0.5 size-4 shrink-0 text-honey" aria-hidden />
<div>
<p className="text-sm font-medium text-foreground">
{local.managedRuntime.installed ? 'Start private runtime' : 'Install private runtime'}
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{local.managedRuntime.installed
? 'Starts Waggles verified local runtime on this device.'
: `Downloads the checksum-verified official runtime${formatDownloadSize(local.managedRuntime.artifactSizeBytes) ? ` (${formatDownloadSize(local.managedRuntime.artifactSizeBytes)})` : ''}. No Docker, administrator access, or system Ollama install required.`}
</p>
</div>
</div>
<div className="flex justify-end">
<button
type="button"
onClick={handleBootstrap}
disabled={bootstrapping}
className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground disabled:opacity-50"
>
{bootstrapping && <Loader2 className="size-3.5 animate-spin" aria-hidden />}
{bootstrapping
? 'Installing private runtime…'
: local.managedRuntime.installed ? 'Start runtime' : 'Install runtime'}
</button>
</div>
</div>
)}
{runtimeMsg && (
<p
role={runtimeMsg.kind === 'err' ? 'alert' : 'status'}
className={`text-sm ${runtimeMsg.kind === 'err' ? 'text-destructive' : 'text-honey'}`}
>
{runtimeMsg.text}
</p>
)}
{(local?.ollamaRunning ?? local?.ollamaInstalled) && (
<div className="space-y-2 rounded-lg border border-border bg-card/60 p-3">
<label htmlFor="model-gate-pull" className="block text-sm font-medium text-foreground">
Pull a model
Download and verify a model
</label>
{recommendedLocal && (
<p className="text-xs text-muted-foreground">
Recommended for this device: <span className="font-medium text-foreground">{recommendedLocal.name}</span>
{recommendedLocal.fitLevel ? ` · ${recommendedLocal.fitLevel.replace('_', ' ')} fit` : ''}
{typeof recommendedLocal.estimatedTps === 'number' ? ` · ~${recommendedLocal.estimatedTps} tok/s` : ''}
</p>
)}
<Input
id="model-gate-pull"
name="modelPullName"
autoComplete="off"
value={pullName}
onChange={(e) => setPullName(e.target.value)}
placeholder="e.g. llama3.2"
placeholder="e.g. llama3.2:3b"
/>
<p className="text-xs text-muted-foreground">
Model weights are downloaded to Waggles private data directory. By continuing, you accept the model publishers upstream license.
</p>
<div className="flex justify-end">
<button
type="button"
@@ -696,7 +831,7 @@ export function ModelGate({
className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground disabled:opacity-50"
>
{pulling && <Loader2 className="size-3.5 animate-spin" aria-hidden />}
{pulling ? 'Pulling…' : 'Pull'}
{pulling ? 'Downloading and verifying…' : 'Install model'}
</button>
</div>
{pullMsg && (
@@ -708,6 +843,7 @@ export function ModelGate({
</p>
)}
</div>
)}
</div>
)}
</div>

View File

@@ -805,7 +805,7 @@ const CreateWorkspaceDialog = ({ open, onClose, onCreate }: CreateWorkspaceDialo
}, [selectedTemplate, templates]);
const handleCreate = () => {
if (!name.trim()) return;
if (!name.trim() || (storageType === 'local' && !storagePath.trim())) return;
onCreate({
name: name.trim(), group,
persona: agentMode === 'single' ? selectedPersona : undefined,
@@ -1316,7 +1316,7 @@ const CreateWorkspaceDialog = ({ open, onClose, onCreate }: CreateWorkspaceDialo
<div className="flex justify-end gap-2 px-6 py-4 border-t border-border/30">
<button type="button" onClick={onClose} className="px-4 py-2 text-xs font-display rounded-lg text-muted-foreground hover:text-foreground transition-colors">Cancel</button>
<button type="button" onClick={handleCreate} disabled={!name.trim()} aria-label="Create workspace"
<button type="button" onClick={handleCreate} disabled={!name.trim() || (storageType === 'local' && !storagePath.trim())} aria-label="Create workspace"
className="flex items-center gap-1.5 px-4 py-2 text-xs font-display rounded-lg bg-primary text-primary-foreground hover:bg-primary/80 disabled:opacity-50 transition-colors">
<Plus className="w-3.5 h-3.5" /> Create
</button>

View File

@@ -175,6 +175,20 @@ describe('SpawnAgentDialog durable launch', () => {
expect(modelList).toContainElement(screen.getByTitle('anthropic/claude-3-5-sonnet'));
});
it('shows the retry CTA when a configured provider returns no models', async () => {
mocks.adapter.getModels.mockResolvedValue([]);
mocks.adapter.getModel.mockResolvedValue('');
mocks.adapter.getProviders.mockResolvedValue({
providers: [{ id: 'anthropic', name: 'Anthropic', hasKey: true, models: [] }],
search: [],
activeSearch: '',
});
renderDialog();
const retryCta = await screen.findByTestId('spawn-no-models-cta');
expect(retryCta).toHaveTextContent('Retry');
});
it('blocks launch and explains how to configure a model when no provider is ready', async () => {
mocks.adapter.getModels.mockResolvedValue([]);
mocks.adapter.getModel.mockResolvedValue('anthropic/claude-3-5-sonnet');

View File

@@ -97,9 +97,6 @@ const SpawnAgentDialog = ({ open, onClose, workspaces, activeWorkspaceId, onWork
const deduped = Array.from(new Set(fromProviders));
if (deduped.length > 0) modelList = deduped;
}
if (modelList.length === 0 && providers.providers.some((p) => p.hasKey)) {
setModelsError('Model list unavailable right now — retry, or check provider keys in Settings.');
}
setModels(modelList);
setPricing(p);
setProvidersWithKeys(countProvidersWithKeys(providers.providers));

View File

@@ -0,0 +1,86 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
getStatus: vi.fn(),
createCode: vi.fn(),
revoke: vi.fn(),
}));
vi.mock('@/lib/adapter', () => ({
adapter: {
getBrowserCompanionPairing: mocks.getStatus,
createBrowserCompanionPairingCode: mocks.createCode,
revokeBrowserCompanionPairing: mocks.revoke,
},
}));
import BrowserCompanionSettings from './BrowserCompanionSettings';
beforeEach(() => {
mocks.getStatus.mockResolvedValue({ paired: false, extensionId: null, pairedAt: null });
mocks.createCode.mockResolvedValue({ code: 'ABCDEFGH', expiresAt: Date.now() + 600_000 });
mocks.revoke.mockResolvedValue(undefined);
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe('BrowserCompanionSettings', () => {
it('reports that pairing status is being checked before showing an actionable state', async () => {
let resolveStatus!: (value: { paired: boolean; extensionId: null; pairedAt: null }) => void;
mocks.getStatus.mockReturnValue(new Promise((resolve) => { resolveStatus = resolve; }));
render(<BrowserCompanionSettings />);
expect(screen.getByTestId('browser-companion-status')).toHaveTextContent('Checking…');
expect(screen.queryByText('Not paired')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /generate one-time code/i })).not.toBeInTheDocument();
resolveStatus({ paired: false, extensionId: null, pairedAt: null });
expect(await screen.findByText('Not paired')).toBeInTheDocument();
});
it('fails closed when pairing status is unavailable', async () => {
mocks.getStatus.mockRejectedValue(new Error('offline'));
render(<BrowserCompanionSettings />);
expect(await screen.findByText('Unavailable')).toBeInTheDocument();
expect(screen.getByRole('alert')).toHaveTextContent('Could not read Browser Companion pairing status.');
expect(screen.queryByRole('button', { name: /generate one-time code/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /revoke/i })).not.toBeInTheDocument();
});
it('generates a code, confirms pairing, and revokes it', async () => {
mocks.getStatus
.mockResolvedValueOnce({ paired: false, extensionId: null, pairedAt: null })
.mockResolvedValueOnce({ paired: true, extensionId: 'extension-id', pairedAt: '2026-08-12T00:00:00Z' });
render(<BrowserCompanionSettings />);
await screen.findByText('Not paired');
fireEvent.click(screen.getByRole('button', { name: /generate one-time code/i }));
expect(await screen.findByTestId('browser-companion-code')).toHaveTextContent('ABCDEFGH');
expect(mocks.createCode).toHaveBeenCalledOnce();
fireEvent.click(screen.getByRole('button', { name: /check pairing/i }));
expect(await screen.findByText('Paired')).toBeInTheDocument();
expect(screen.queryByTestId('browser-companion-code')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /generate one-time code/i })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /revoke/i }));
await waitFor(() => expect(mocks.revoke).toHaveBeenCalledOnce());
expect(await screen.findByText('Not paired')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /generate one-time code/i })).toBeInTheDocument();
});
it('clears an expired pairing code', async () => {
mocks.createCode.mockResolvedValue({ code: 'ABCDEFGH', expiresAt: Date.now() - 1 });
render(<BrowserCompanionSettings />);
await screen.findByText('Not paired');
fireEvent.click(screen.getByRole('button', { name: /generate one-time code/i }));
await waitFor(() => expect(mocks.createCode).toHaveBeenCalledOnce());
await waitFor(() => expect(screen.queryByTestId('browser-companion-code')).not.toBeInTheDocument());
});
});

View File

@@ -0,0 +1,157 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { CheckCircle2, KeyRound, Loader2, Unplug } from 'lucide-react';
import { adapter, type BrowserCompanionPairingStatus } from '@/lib/adapter';
const BrowserCompanionSettings = () => {
const [status, setStatus] = useState<BrowserCompanionPairingStatus | null>(null);
const [pairingCode, setPairingCode] = useState<{ code: string; expiresAt: number } | null>(null);
const [busy, setBusy] = useState<'generate' | 'check' | 'revoke' | null>(null);
const [error, setError] = useState('');
const refreshId = useRef(0);
const refresh = useCallback(async (showBusy = false) => {
const requestId = ++refreshId.current;
if (showBusy) setBusy('check');
try {
const nextStatus = await adapter.getBrowserCompanionPairing();
if (requestId !== refreshId.current) return;
setStatus(nextStatus);
if (nextStatus.paired) setPairingCode(null);
setError('');
} catch {
if (requestId !== refreshId.current) return;
setError('Could not read Browser Companion pairing status.');
} finally {
if (showBusy && requestId === refreshId.current) setBusy(null);
}
}, []);
useEffect(() => {
void refresh();
return () => { refreshId.current += 1; };
}, [refresh]);
useEffect(() => {
if (!pairingCode) return;
const remainingMs = pairingCode.expiresAt - Date.now();
if (remainingMs <= 0) {
setPairingCode(null);
return;
}
const expiryTimer = window.setTimeout(() => setPairingCode(null), remainingMs);
return () => window.clearTimeout(expiryTimer);
}, [pairingCode]);
const createCode = async () => {
setBusy('generate');
setPairingCode(null);
try {
setPairingCode(await adapter.createBrowserCompanionPairingCode());
setError('');
} catch {
setError('Could not create a pairing code.');
} finally {
setBusy(null);
}
};
const revoke = async () => {
refreshId.current += 1;
setBusy('revoke');
try {
await adapter.revokeBrowserCompanionPairing();
setPairingCode(null);
setStatus({ paired: false, extensionId: null, pairedAt: null });
setError('');
} catch {
setError('Could not revoke Browser Companion pairing.');
} finally {
setBusy(null);
}
};
return (
<section
className="p-3 rounded-xl bg-secondary/30 border border-border/30 space-y-2.5"
data-testid="browser-companion-settings"
aria-labelledby="browser-companion-title"
>
<div className="flex items-center justify-between gap-3">
<div>
<h4 id="browser-companion-title" className="text-xs font-display font-medium text-foreground flex items-center gap-1.5">
<KeyRound aria-hidden="true" className="w-3.5 h-3.5 text-honey" /> Browser Companion
</h4>
<p className="text-[11px] text-muted-foreground mt-1">
Pair the extension with a short-lived, single-use code. Captures can write only to personal imported memory.
</p>
</div>
<span
className="text-[10px] text-muted-foreground shrink-0"
data-testid="browser-companion-status"
role="status"
aria-live="polite"
>
{status?.paired ? 'Paired' : status ? 'Not paired' : error ? 'Unavailable' : 'Checking…'}
</span>
</div>
{status?.paired && (
<p className="text-[11px] text-status-healthy flex items-center gap-1">
<CheckCircle2 aria-hidden="true" className="w-3 h-3" /> Connected extension: {status.extensionId ?? 'Browser Companion'}
</p>
)}
{pairingCode && (
<div className="rounded-lg border border-honey/40 bg-honey/10 p-2" role="status">
<p className="text-[10px] text-muted-foreground">Enter this code in the Browser Companion popup:</p>
<p className="mt-1 font-mono text-lg tracking-[0.2em] text-honey" data-testid="browser-companion-code">
{pairingCode.code}
</p>
<p className="text-[10px] text-muted-foreground">Expires {new Date(pairingCode.expiresAt).toLocaleTimeString()}.</p>
<button
type="button"
onClick={() => void refresh(true)}
disabled={busy !== null}
className="mt-2 inline-flex min-h-8 items-center gap-1.5 rounded-md bg-secondary px-2.5 py-1 text-[11px] text-foreground disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--focus-ring)]"
>
{busy === 'check' && <Loader2 aria-hidden="true" className="w-3 h-3 animate-spin motion-reduce:animate-none" />}
{busy === 'check' ? 'Checking…' : 'Check pairing'}
</button>
</div>
)}
{error && <p className="text-[11px] text-destructive" role="alert">{error}</p>}
<div className="flex gap-2">
{status && !status.paired && (
<button
type="button"
onClick={() => void createCode()}
disabled={busy !== null}
className="flex min-h-8 items-center gap-1.5 rounded-lg bg-primary px-3 py-1.5 text-xs text-primary-foreground disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--focus-ring)]"
>
{busy === 'generate'
? <Loader2 aria-hidden="true" className="w-3 h-3 animate-spin motion-reduce:animate-none" />
: <KeyRound aria-hidden="true" className="w-3 h-3" />}
{busy === 'generate' ? 'Generating…' : 'Generate one-time code'}
</button>
)}
{status?.paired && (
<button
type="button"
onClick={() => void revoke()}
disabled={busy !== null}
className="flex min-h-8 items-center gap-1.5 rounded-lg bg-secondary px-3 py-1.5 text-xs text-foreground disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--focus-ring)]"
>
{busy === 'revoke'
? <Loader2 aria-hidden="true" className="w-3 h-3 animate-spin motion-reduce:animate-none" />
: <Unplug aria-hidden="true" className="w-3 h-3" />}
{busy === 'revoke' ? 'Revoking…' : 'Revoke'}
</button>
)}
</div>
</section>
);
};
export default BrowserCompanionSettings;

View File

@@ -8,7 +8,12 @@ const ScrollArea = React.forwardRef<
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root ref={ref} className={cn("relative overflow-hidden", className)} {...props}>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">{children}</ScrollAreaPrimitive.Viewport>
<ScrollAreaPrimitive.Viewport
tabIndex={0}
className="h-full w-full rounded-[inherit] focus-visible:outline-offset-[-2px]"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>

File diff suppressed because it is too large Load Diff

View File

@@ -1,36 +1,35 @@
/**
* PR5 Phase A — the shared "≥1 working model" gate signal (cloud key OR local
* model), composed from the same /api/providers data the Settings Models tab
* reads.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, waitFor, cleanup } from '@testing-library/react';
import { act, cleanup, renderHook, waitFor } from '@testing-library/react';
const mocks = vi.hoisted(() => ({
adapter: {
getProviders: vi.fn(),
getLocalInferenceStatus: vi.fn(),
probeModel: vi.fn(),
probeProvider: vi.fn(),
},
}));
vi.mock('@/lib/adapter', () => ({ adapter: mocks.adapter, default: vi.fn() }));
import { useHasWorkingModel } from './useHasWorkingModel';
const providers = (...withKey: boolean[]) => ({
providers: withKey.map((hasKey, i) => ({ id: `p${i}`, name: `P${i}`, hasKey, badge: null, keyUrl: null, requiresKey: true, models: [] })),
search: [],
activeSearch: 'duckduckgo',
});
const providerRows = (...rows: Array<{ id: string; hasKey: boolean; requiresKey: boolean }>) => ({
providers: rows.map((row) => ({ ...row, name: row.id, badge: null, keyUrl: null, models: [] })),
search: [],
activeSearch: 'duckduckgo',
});
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => { resolve = resolvePromise; });
return { promise, resolve };
}
beforeEach(() => {
mocks.adapter.getProviders.mockResolvedValue(providers());
mocks.adapter.getProviders.mockResolvedValue(providerRows());
mocks.adapter.getLocalInferenceStatus.mockResolvedValue({ servers: [], ollamaInstalled: false, totalLocalModels: 0 });
mocks.adapter.probeModel.mockResolvedValue({ model: null, configured: false, verified: false });
mocks.adapter.probeProvider.mockResolvedValue({ configured: false, valid: false, verified: false });
});
afterEach(() => { cleanup(); vi.clearAllMocks(); });
@@ -39,39 +38,211 @@ describe('useHasWorkingModel', () => {
const { result } = renderHook(() => useHasWorkingModel());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current).toMatchObject({ hasWorkingModel: false, cloudReady: false, localReady: false });
expect(mocks.adapter.probeModel).not.toHaveBeenCalled();
});
it('a keyed cloud provider → cloudReady → working', async () => {
mocks.adapter.getProviders.mockResolvedValue(providers(false, true));
it('a verified default model is ready without provider fallback', async () => {
mocks.adapter.getProviders.mockResolvedValue(providerRows({ id: 'p0', hasKey: true, requiresKey: true }));
mocks.adapter.probeModel.mockResolvedValue({ model: 'p0/model', configured: true, verified: true });
const { result } = renderHook(() => useHasWorkingModel());
await waitFor(() => expect(result.current.hasWorkingModel).toBe(true));
expect(result.current.cloudReady).toBe(true);
expect(result.current.localReady).toBe(false);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current).toMatchObject({ hasWorkingModel: true, cloudReady: true });
expect(mocks.adapter.probeProvider).not.toHaveBeenCalled();
});
it('a detected local model (no cloud key) → localReady → working', async () => {
it('a rejected default model blocks a keyed provider', async () => {
mocks.adapter.getProviders.mockResolvedValue(providerRows({ id: 'p0', hasKey: true, requiresKey: true }));
mocks.adapter.probeModel.mockResolvedValue({ model: 'p0/model', configured: true, verified: false, rejected: true });
const { result } = renderHook(() => useHasWorkingModel());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current).toMatchObject({ hasWorkingModel: false, cloudReady: false });
expect(mocks.adapter.probeProvider).not.toHaveBeenCalled();
});
it('a transient default result remains usable after probing settles', async () => {
const modelProbe = deferred<{ model: string; configured: boolean; verified: boolean }>();
mocks.adapter.getProviders.mockResolvedValue(providerRows({ id: 'p0', hasKey: true, requiresKey: true }));
mocks.adapter.probeModel.mockReturnValue(modelProbe.promise);
const { result } = renderHook(() => useHasWorkingModel());
await waitFor(() => expect(mocks.adapter.probeModel).toHaveBeenCalledTimes(1));
expect(result.current).toMatchObject({ loading: true, cloudReady: false, hasWorkingModel: false });
await act(async () => { modelProbe.resolve({ model: 'p0/model', configured: true, verified: false }); });
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current).toMatchObject({ hasWorkingModel: true, cloudReady: true });
});
it('a keyed cloud provider is ready only after its fallback probe verifies it', async () => {
mocks.adapter.getProviders.mockResolvedValue(providerRows({ id: 'p0', hasKey: true, requiresKey: true }));
mocks.adapter.probeProvider.mockResolvedValue({ configured: true, valid: true, verified: true });
const { result } = renderHook(() => useHasWorkingModel());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current).toMatchObject({ hasWorkingModel: true, cloudReady: true, localReady: false });
expect(mocks.adapter.probeModel).toHaveBeenCalledTimes(1);
expect(mocks.adapter.probeProvider).toHaveBeenCalledWith('p0');
});
it('a rejected fallback provider is not ready', async () => {
mocks.adapter.getProviders.mockResolvedValue(providerRows({ id: 'p0', hasKey: true, requiresKey: true }));
mocks.adapter.probeProvider.mockResolvedValue({ configured: true, valid: false, verified: true, error: 'rejected' });
const { result } = renderHook(() => useHasWorkingModel());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current).toMatchObject({ hasWorkingModel: false, cloudReady: false });
});
it('a valid but unverified fallback provider remains usable after probing settles', async () => {
mocks.adapter.getProviders.mockResolvedValue(providerRows({ id: 'p0', hasKey: true, requiresKey: true }));
mocks.adapter.probeProvider.mockResolvedValue({ configured: true, valid: true, verified: false });
const { result } = renderHook(() => useHasWorkingModel());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current).toMatchObject({ hasWorkingModel: true, cloudReady: true });
});
it('all unconfigured probes are not ready', async () => {
mocks.adapter.getProviders.mockResolvedValue(providerRows({ id: 'p0', hasKey: true, requiresKey: true }));
const { result } = renderHook(() => useHasWorkingModel());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current).toMatchObject({ hasWorkingModel: false, cloudReady: false });
});
it('a pending cloud probe stays loading and non-ready', async () => {
const modelProbe = deferred<{ model: string; configured: boolean; verified: boolean }>();
mocks.adapter.getProviders.mockResolvedValue(providerRows({ id: 'p0', hasKey: true, requiresKey: true }));
mocks.adapter.probeModel.mockReturnValue(modelProbe.promise);
const { result } = renderHook(() => useHasWorkingModel());
await waitFor(() => expect(mocks.adapter.probeModel).toHaveBeenCalledTimes(1));
expect(result.current).toMatchObject({ loading: true, cloudReady: false, hasWorkingModel: false });
});
it('a detected local model overrides a rejected cloud default', async () => {
mocks.adapter.getProviders.mockResolvedValue(providerRows({ id: 'p0', hasKey: true, requiresKey: true }));
mocks.adapter.probeModel.mockResolvedValue({ model: 'p0/model', configured: true, verified: false, rejected: true });
mocks.adapter.getLocalInferenceStatus.mockResolvedValue({ servers: [], ollamaInstalled: true, totalLocalModels: 2 });
const { result } = renderHook(() => useHasWorkingModel());
await waitFor(() => expect(result.current.hasWorkingModel).toBe(true));
expect(result.current.cloudReady).toBe(false);
expect(result.current.localReady).toBe(true);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current).toMatchObject({ hasWorkingModel: true, cloudReady: false, localReady: true });
expect(mocks.adapter.probeModel).toHaveBeenCalledTimes(1);
});
it('does not count keyless local providers as cloud keys', async () => {
it('an installed local runtime with zero models is not ready', async () => {
mocks.adapter.getLocalInferenceStatus.mockResolvedValue({ servers: [], ollamaInstalled: true, totalLocalModels: 0 });
const { result } = renderHook(() => useHasWorkingModel());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current).toMatchObject({ hasWorkingModel: false, localReady: false });
});
it('does not count a keyless local provider as cloud readiness', async () => {
mocks.adapter.getProviders.mockResolvedValue(providerRows({ id: 'ollama', hasKey: true, requiresKey: false }));
mocks.adapter.getLocalInferenceStatus.mockResolvedValue({ servers: [], ollamaInstalled: true, totalLocalModels: 2 });
const { result } = renderHook(() => useHasWorkingModel());
await waitFor(() => expect(result.current.hasWorkingModel).toBe(true));
expect(result.current.cloudReady).toBe(false);
expect(result.current.localReady).toBe(true);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current).toMatchObject({ cloudReady: false, localReady: true, hasWorkingModel: true });
expect(mocks.adapter.probeModel).not.toHaveBeenCalled();
});
it('a local-inference probe failure degrades to no-local (cloud can still pass)', async () => {
it('a local-inference probe failure degrades to no local model', async () => {
mocks.adapter.getLocalInferenceStatus.mockRejectedValue(new Error('ollama down'));
mocks.adapter.getProviders.mockResolvedValue(providers(true));
const { result } = renderHook(() => useHasWorkingModel());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current).toMatchObject({ localReady: false, hasWorkingModel: false });
});
it('refresh reprobes unchanged provider ids after a key replacement', async () => {
mocks.adapter.getProviders.mockResolvedValue(providerRows({ id: 'p0', hasKey: true, requiresKey: true }));
mocks.adapter.probeModel
.mockResolvedValueOnce({ model: 'p0/model', configured: true, verified: true })
.mockResolvedValueOnce({ model: 'p0/model', configured: true, verified: false, rejected: true });
const { result } = renderHook(() => useHasWorkingModel());
await waitFor(() => expect(result.current.hasWorkingModel).toBe(true));
act(() => { result.current.refresh(); });
await waitFor(() => expect(mocks.adapter.probeModel).toHaveBeenCalledTimes(2));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current).toMatchObject({ hasWorkingModel: false, cloudReady: false });
});
it('a late old cloud success cannot overwrite a newer rejection', async () => {
const oldProbe = deferred<{ model: string; configured: boolean; verified: boolean; rejected?: boolean }>();
const newProbe = deferred<{ model: string; configured: boolean; verified: boolean; rejected?: boolean }>();
mocks.adapter.getProviders.mockResolvedValue(providerRows({ id: 'p0', hasKey: true, requiresKey: true }));
mocks.adapter.probeModel.mockReturnValueOnce(oldProbe.promise).mockReturnValueOnce(newProbe.promise);
const { result } = renderHook(() => useHasWorkingModel());
await waitFor(() => expect(mocks.adapter.probeModel).toHaveBeenCalledTimes(1));
act(() => { result.current.refresh(); });
await waitFor(() => expect(mocks.adapter.probeModel).toHaveBeenCalledTimes(2));
await act(async () => { newProbe.resolve({ model: 'p0/model', configured: true, verified: false, rejected: true }); });
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => { oldProbe.resolve({ model: 'p0/model', configured: true, verified: true }); });
expect(result.current).toMatchObject({ hasWorkingModel: false, cloudReady: false });
});
it('a late old local positive result cannot overwrite a newer zero-model refresh', async () => {
const oldLocal = deferred<{ servers: never[]; ollamaInstalled: boolean; totalLocalModels: number }>();
const newLocal = deferred<{ servers: never[]; ollamaInstalled: boolean; totalLocalModels: number }>();
mocks.adapter.getLocalInferenceStatus.mockReturnValueOnce(oldLocal.promise).mockReturnValueOnce(newLocal.promise);
const { result } = renderHook(() => useHasWorkingModel());
await waitFor(() => expect(mocks.adapter.getLocalInferenceStatus).toHaveBeenCalledTimes(1));
act(() => { result.current.refresh(); });
await waitFor(() => expect(mocks.adapter.getLocalInferenceStatus).toHaveBeenCalledTimes(2));
await act(async () => { newLocal.resolve({ servers: [], ollamaInstalled: true, totalLocalModels: 0 }); });
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => { oldLocal.resolve({ servers: [], ollamaInstalled: true, totalLocalModels: 2 }); });
expect(result.current.localReady).toBe(false);
expect(result.current.hasWorkingModel).toBe(true); // cloud key carries it
});
it('reprobes a same-id provider array returned by a window-focus refresh', async () => {
mocks.adapter.getProviders
.mockResolvedValueOnce(providerRows({ id: 'p0', hasKey: true, requiresKey: true }))
.mockResolvedValueOnce(providerRows({ id: 'p0', hasKey: true, requiresKey: true }));
mocks.adapter.probeModel
.mockResolvedValueOnce({ model: 'p0/model', configured: true, verified: true })
.mockResolvedValueOnce({ model: 'p0/model', configured: true, verified: false, rejected: true });
const { result } = renderHook(() => useHasWorkingModel());
await waitFor(() => expect(result.current.hasWorkingModel).toBe(true));
act(() => { window.dispatchEvent(new Event('focus')); });
await waitFor(() => expect(mocks.adapter.probeModel).toHaveBeenCalledTimes(2));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current).toMatchObject({ hasWorkingModel: false, cloudReady: false });
});
it('treats unavailable fallback probes as transient usable readiness', async () => {
mocks.adapter.getProviders.mockResolvedValue(providerRows(
{ id: 'p0', hasKey: true, requiresKey: true },
{ id: 'p1', hasKey: true, requiresKey: true },
));
mocks.adapter.probeProvider.mockRejectedValue(new Error('sidecar offline'));
const { result } = renderHook(() => useHasWorkingModel());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current).toMatchObject({ hasWorkingModel: true, cloudReady: true });
});
it('uses only refreshed p1 results when an explicit refresh replaces p0', async () => {
const oldP0Probe = deferred<{ configured: boolean; valid: boolean; verified: boolean }>();
mocks.adapter.getProviders
.mockResolvedValueOnce(providerRows({ id: 'p0', hasKey: true, requiresKey: true }))
.mockResolvedValueOnce(providerRows({ id: 'p1', hasKey: true, requiresKey: true }));
mocks.adapter.probeModel.mockResolvedValue({ model: null, configured: false, verified: false });
mocks.adapter.probeProvider.mockImplementation((id: string) => id === 'p0'
? oldP0Probe.promise
: Promise.resolve({ configured: true, valid: false, verified: true }));
const { result } = renderHook(() => useHasWorkingModel());
await waitFor(() => expect(mocks.adapter.probeProvider).toHaveBeenCalledWith('p0'));
act(() => { result.current.refresh(); });
await waitFor(() => expect(mocks.adapter.probeProvider).toHaveBeenCalledWith('p1'));
await waitFor(() => expect(result.current).toMatchObject({ loading: false, cloudReady: false }));
await act(async () => { oldP0Probe.resolve({ configured: true, valid: true, verified: true }); });
expect(mocks.adapter.probeProvider.mock.calls.map(([id]) => id)).toEqual(['p0', 'p1']);
expect(result.current).toMatchObject({ hasWorkingModel: false, cloudReady: false });
});
it('does no cloud work when unmounted during a deferred explicit provider refresh', async () => {
const refreshProviders = deferred<ReturnType<typeof providerRows>>();
mocks.adapter.getProviders
.mockResolvedValueOnce(providerRows())
.mockReturnValueOnce(refreshProviders.promise);
const { result, unmount } = renderHook(() => useHasWorkingModel());
await waitFor(() => expect(result.current.loading).toBe(false));
act(() => { result.current.refresh(); });
unmount();
await act(async () => { refreshProviders.resolve(providerRows({ id: 'p0', hasKey: true, requiresKey: true })); });
expect(mocks.adapter.probeModel).not.toHaveBeenCalled();
});
});

View File

@@ -1,59 +1,144 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { adapter } from '@/lib/adapter';
import { useProviders } from './useProviders';
/**
* useHasWorkingModel — the shared "≥1 working model" signal for the PR5 model
* gate (Onboarding step 3's HARD gate AND the Settings→Models banner). A model
* is "working" if either a cloud provider has a key configured OR a local model
* is detected. This is a UX affordance, not a security boundary — the real
* LLM-availability enforcement happens server-side at chat time — so it derives
* from already-fetched data (no live key-probe required to compute readiness).
*
* Composing useProviders keeps a single source of truth (the same /api/providers
* hasKey data the Settings Models tab reads), so the gate and the banner can
* never disagree about what counts as ready.
* Shared model-readiness signal for the onboarding hard gate and Models banner.
* Cloud keys are live-probed; a detected local model is independently sufficient.
*/
export interface WorkingModelState {
/** cloudReady || localReady — the hard-gate predicate. */
hasWorkingModel: boolean;
/** ≥1 cloud provider has a key in the vault. */
cloudReady: boolean;
/** ≥1 local model detected (Ollama/vLLM). */
localReady: boolean;
loading: boolean;
refresh: () => void;
}
export function useHasWorkingModel(): WorkingModelState {
const { activeProviders, loading: providersLoading, refresh: refreshProviders } = useProviders();
const { providers, activeProviders, loading: providersLoading, refresh: refreshProviders } = useProviders();
const [localModelCount, setLocalModelCount] = useState(0);
const [localLoading, setLocalLoading] = useState(true);
const [cloud, setCloud] = useState({ ready: false, loading: true });
const mounted = useRef(true);
const localGeneration = useRef(0);
const cloudGeneration = useRef(0);
const explicitCloudRefresh = useRef<number | null>(null);
const explicitProviders = useRef<unknown>(null);
const activeProviderIds = useRef<string[]>([]);
activeProviderIds.current = activeProviders.map((provider) => provider.id);
const refreshLocal = useCallback(async () => {
const generation = ++localGeneration.current;
if (mounted.current) {
setLocalModelCount(0);
setLocalLoading(true);
}
try {
const status = await adapter.getLocalInferenceStatus();
setLocalModelCount(status?.totalLocalModels ?? 0);
if (mounted.current && generation === localGeneration.current) setLocalModelCount(status?.totalLocalModels ?? 0);
} catch {
// Local-inference probe failed (Ollama not installed / unreachable) —
// treat as "no local model"; a cloud key can still make the gate pass.
setLocalModelCount(0);
if (mounted.current && generation === localGeneration.current) setLocalModelCount(0);
} finally {
setLocalLoading(false);
if (mounted.current && generation === localGeneration.current) setLocalLoading(false);
}
}, []);
useEffect(() => { void refreshLocal(); }, [refreshLocal]);
const probeCloud = useCallback(async (providerIds: string[], generation: number) => {
const setCloudForGeneration = (next: { ready: boolean; loading: boolean }) => {
if (mounted.current && generation === cloudGeneration.current) setCloud(next);
};
if (!mounted.current || generation !== cloudGeneration.current) return;
setCloudForGeneration({ ready: false, loading: true });
const cloudReady = activeProviders.length > 0;
if (providerIds.length === 0) {
setCloudForGeneration({ ready: false, loading: false });
return;
}
let defaultProbe: Awaited<ReturnType<typeof adapter.probeModel>> | null = null;
try {
defaultProbe = await adapter.probeModel();
} catch {
// An unavailable default-model probe falls back to the keyed providers.
}
if (!mounted.current || generation !== cloudGeneration.current) return;
if (defaultProbe?.configured) {
if (defaultProbe.verified) {
setCloudForGeneration({ ready: true, loading: false });
return;
}
if (defaultProbe.rejected) {
setCloudForGeneration({ ready: false, loading: false });
return;
}
setCloudForGeneration({ ready: true, loading: false });
return;
}
const outcomes = await Promise.allSettled(providerIds.map((id) => adapter.probeProvider(id)));
if (!mounted.current || generation !== cloudGeneration.current) return;
const probes = outcomes.flatMap((outcome) => outcome.status === 'fulfilled' ? [outcome.value] : []);
const verified = probes.some((probe) => probe.configured && probe.valid !== false && probe.verified);
const rejected = probes.some((probe) => probe.configured && probe.valid === false);
const transient = outcomes.some((outcome) => outcome.status === 'rejected')
|| probes.some((probe) => probe.configured && probe.valid !== false);
setCloudForGeneration({ ready: verified || (!rejected && transient), loading: false });
}, []);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
cloudGeneration.current += 1;
localGeneration.current += 1;
};
}, []);
useEffect(() => {
if (providersLoading) return;
if (explicitCloudRefresh.current === cloudGeneration.current) {
if (explicitProviders.current === providers) {
explicitCloudRefresh.current = null;
explicitProviders.current = null;
return;
}
explicitCloudRefresh.current = null;
explicitProviders.current = null;
}
const generation = ++cloudGeneration.current;
void probeCloud(activeProviderIds.current, generation);
}, [probeCloud, providers, providersLoading]);
useEffect(() => {
void refreshLocal();
}, [refreshLocal]);
const refresh = useCallback(() => {
const generation = ++cloudGeneration.current;
explicitCloudRefresh.current = generation;
if (mounted.current) setCloud({ ready: false, loading: true });
void refreshLocal();
void (async () => {
const data = await refreshProviders();
if (!mounted.current || generation !== cloudGeneration.current) return;
const ids = data
? data.providers.filter((provider) => provider.hasKey && provider.requiresKey).map((provider) => provider.id)
: activeProviderIds.current;
explicitProviders.current = data?.providers ?? null;
await probeCloud(ids, generation);
if (mounted.current && generation === cloudGeneration.current && !data) explicitCloudRefresh.current = null;
})();
}, [probeCloud, refreshLocal, refreshProviders]);
const cloudReady = cloud.ready;
const localReady = localModelCount > 0;
return {
hasWorkingModel: cloudReady || localReady,
cloudReady,
localReady,
loading: providersLoading || localLoading,
refresh: () => { refreshProviders(); void refreshLocal(); },
loading: providersLoading || cloud.loading || localLoading,
refresh,
};
}

View File

@@ -2,6 +2,7 @@ import { useState, useCallback, useEffect } from 'react';
import { adapter } from '@/lib/adapter';
import type { Workspace } from '@/lib/types';
import { useRevalidateOnError } from '@/hooks/useRevalidateOnError';
import { toast } from '@/hooks/use-toast';
import {
resolveActiveWorkspaceId,
readPersistedWorkspaceId,
@@ -41,7 +42,7 @@ export const useWorkspaces = () => {
// P1b D3 plus-clause: errored list revalidates on focus/online/connect-settled.
useRevalidateOnError(error !== null, fetchWorkspaces);
const createWorkspace = useCallback(async (data: { name: string; group: string; persona?: string; agentGroupId?: string; shared?: boolean; templateId?: string }) => {
const createWorkspace = useCallback(async (data: { name: string; group: string; persona?: string; agentGroupId?: string; shared?: boolean; templateId?: string; storageType?: Workspace['storageType']; storagePath?: string; storageConfig?: Record<string, unknown> }) => {
try {
const ws = await adapter.createWorkspace(data);
setWorkspaces(prev => [...prev, ws]);
@@ -49,23 +50,15 @@ export const useWorkspaces = () => {
persistWorkspaceId(ws.id);
return ws;
} catch (err) {
console.error('[useWorkspaces] create failed, using local fallback:', err);
const localWs: Workspace = {
id: `local-${Date.now()}`,
name: data.name,
group: data.group,
persona: data.persona,
shared: data.shared,
templateId: data.templateId,
health: 'healthy',
memoryCount: 0,
sessionCount: 0,
lastActive: new Date().toISOString(),
};
setWorkspaces(prev => [...prev, localWs]);
setActiveWorkspaceId(localWs.id);
persistWorkspaceId(localWs.id);
return localWs;
console.error('[useWorkspaces] create failed:', err);
const message = err instanceof Error ? err.message : 'Failed to create workspace';
setError(message);
toast({
title: "Couldn't create workspace",
description: message,
variant: 'destructive',
});
return null;
}
}, []);

View File

@@ -1,5 +1,3 @@
@import url('https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap');
/* Import Hive DS theme aliases and utility classes */
@import "./waggle-theme.css";

View File

@@ -17,6 +17,7 @@ import LocalAdapter, {
adapter as singletonAdapter,
resolveDefaultServerUrl,
} from './adapter';
import { fetchWithTimeout } from './fetch-utils';
const BASE = 'http://test-server:4242';
@@ -25,6 +26,38 @@ const jsonRes = (body: unknown, status = 200) =>
const HEALTH = { status: 'ok', mode: 'local' };
const TOKEN_PATH = '/api/auth/session-token';
const DESKTOP_A = { port: 49151, instanceId: 'desktop-instance-a' };
const DESKTOP_B = { port: 49152, instanceId: 'desktop-instance-b' };
const desktopHealth = (endpoint = DESKTOP_A) => ({
...HEALTH,
port: endpoint.port,
instanceId: endpoint.instanceId,
});
const enableTauri = () => {
(window as unknown as { __TAURI_INTERNALS__: unknown }).__TAURI_INTERNALS__ = {};
};
const flush = () => new Promise<void>(resolve => setTimeout(resolve, 0));
class FakeEventSource {
static instances: FakeEventSource[] = [];
readonly url: string;
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
onopen: (() => void) | null = null;
closed = false;
constructor(url: string) {
this.url = url;
FakeEventSource.instances.push(this);
}
addEventListener(): void { /* listeners are irrelevant to the identity gate */ }
close(): void { this.closed = true; }
fireError(): void { this.onerror?.(); }
}
/** Route-style fetch mock: dispatch on URL substring, in registration order. */
function routeMock(fetchSpy: ReturnType<typeof vi.spyOn>, routes: Array<[string, () => Response | Promise<Response>]>) {
@@ -49,6 +82,8 @@ describe('P1b auth gate', () => {
});
afterEach(() => {
delete (window as unknown as { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__;
vi.unstubAllGlobals();
fetchSpy.mockRestore();
vi.useRealTimers();
});
@@ -204,6 +239,408 @@ describe('P1b auth gate', () => {
expect(callsTo(fetchSpy, TOKEN_PATH)).toHaveLength(0);
});
// ── Rust-owned desktop endpoint gate ─────────────────────────────────────
it('managed desktop stays network-cold and hides default/stored URLs until Rust binds an endpoint', async () => {
enableTauri();
localStorage.setItem('waggle:server-url', 'http://stale-or-hostile:9999');
const a = new LocalAdapter('http://constructor-override:8888');
const gateId = a.armDesktopServiceGate();
const request = a.getWorkspaces();
const healthRequest = a.getSystemHealth();
const rejected = expect(request).rejects.toThrow('desktop boot stopped');
const healthRejected = expect(healthRequest).rejects.toThrow('desktop boot stopped');
await Promise.resolve();
expect(fetchSpy).not.toHaveBeenCalled();
expect(() => a.getServerUrl()).toThrow(/not ready/);
a.failDesktopServiceGate(new Error('desktop boot stopped'), gateId);
await Promise.all([rejected, healthRejected]);
expect(fetchSpy).not.toHaveBeenCalled();
expect(localStorage.getItem('waggle:server-url')).toBe('http://stale-or-hostile:9999');
});
it('managed desktop uses only the matching Rust-owned endpoint and never persists it', async () => {
enableTauri();
localStorage.setItem('waggle:server-url', 'http://stale-or-hostile:9999');
const a = new LocalAdapter(BASE);
routeMock(fetchSpy, [
['/health', () => jsonRes(desktopHealth(DESKTOP_A))],
[TOKEN_PATH, () => jsonRes({ token: 'desktop-token-a' })],
['/api/workspaces', () => jsonRes([])],
]);
const gateId = a.armDesktopServiceGate();
await a.connectDesktopService(DESKTOP_A, gateId);
await a.getWorkspaces();
expect(a.getServerUrl()).toBe(`http://127.0.0.1:${DESKTOP_A.port}`);
expect(localStorage.getItem('waggle:server-url')).toBe('http://stale-or-hostile:9999');
expect(callsTo(fetchSpy, '/health')).toHaveLength(2);
const [workspaceUrl, workspaceInit] = callsTo(fetchSpy, '/api/workspaces')[0];
expect(workspaceUrl).toBe(`http://127.0.0.1:${DESKTOP_A.port}/api/workspaces`);
expect((workspaceInit.headers as Record<string, string>).Authorization)
.toBe('Bearer desktop-token-a');
});
it('managed 401 recovery revalidates identity before retrying on the same port', async () => {
enableTauri();
const a = new LocalAdapter(BASE);
let health = desktopHealth(DESKTOP_A);
let token = 'desktop-token-a';
let workspaceCalls = 0;
routeMock(fetchSpy, [
['/health', () => jsonRes(health)],
[TOKEN_PATH, () => jsonRes({ token })],
['/api/workspaces', () => {
workspaceCalls++;
return jsonRes({ error: 'Unauthorized' }, 401);
}],
]);
const gateId = a.armDesktopServiceGate();
await a.connectDesktopService(DESKTOP_A, gateId);
health = { ...desktopHealth(DESKTOP_B), port: DESKTOP_A.port };
token = 'desktop-token-b';
await expect(a.getWorkspaces()).rejects.toThrow(/identity/);
expect(workspaceCalls).toBe(1);
expect(callsTo(fetchSpy, TOKEN_PATH)).toHaveLength(2);
expect(() => a.getServerUrl()).toThrow(/not ready/);
});
it('a post-ready managed health failure closes the verified desktop gate', async () => {
enableTauri();
const a = new LocalAdapter(BASE);
routeMock(fetchSpy, [
['/health', () => jsonRes(desktopHealth(DESKTOP_A))],
[TOKEN_PATH, () => jsonRes({ token: 'desktop-token-a' })],
]);
const gateId = a.armDesktopServiceGate();
await a.connectDesktopService(DESKTOP_A, gateId);
fetchSpy.mockRejectedValue(new TypeError('managed sidecar disappeared'));
await expect(a.getSystemHealth()).rejects.toThrow();
expect(a.isConnected).toBe(false);
expect(() => a.getServerUrl()).toThrow(/not ready/);
});
it('wrong desktop identity rejects the binding, shared connect, and queued request without token leakage', async () => {
enableTauri();
const a = new LocalAdapter(BASE);
routeMock(fetchSpy, [
['/health', () => jsonRes(desktopHealth(DESKTOP_B))],
[TOKEN_PATH, () => jsonRes({ token: 'must-not-be-fetched' })],
['/api/workspaces', () => jsonRes([])],
]);
const gateId = a.armDesktopServiceGate();
const binding = a.connectDesktopService(DESKTOP_A, gateId);
const sharedConnect = a.connect();
const queuedRequest = a.getWorkspaces();
await Promise.all([
expect(binding).rejects.toThrow(/identity/),
expect(sharedConnect).rejects.toThrow(/identity/),
expect(queuedRequest).rejects.toThrow(/identity/),
]);
expect(callsTo(fetchSpy, TOKEN_PATH)).toHaveLength(0);
expect(callsTo(fetchSpy, '/api/workspaces')).toHaveLength(0);
expect(a.isConnected).toBe(false);
expect(() => a.getServerUrl()).toThrow(/not ready/);
});
it('failing the desktop gate while token bootstrap is pending cannot be reopened by its late completion', async () => {
enableTauri();
const a = new LocalAdapter(BASE);
let releaseToken!: (response: Response) => void;
const tokenGate = new Promise<Response>(resolve => { releaseToken = resolve; });
routeMock(fetchSpy, [
['/health', () => jsonRes(desktopHealth(DESKTOP_A))],
[TOKEN_PATH, () => tokenGate],
['/api/workspaces', () => jsonRes([])],
]);
const gateId = a.armDesktopServiceGate();
const binding = a.connectDesktopService(DESKTOP_A, gateId);
const sharedConnect = a.connect();
const queuedRequest = a.getWorkspaces();
await vi.waitFor(() => expect(callsTo(fetchSpy, TOKEN_PATH)).toHaveLength(1));
const bindingRejected = expect(binding).rejects.toThrow(/shell stopped|generation changed/);
const sharedRejected = expect(sharedConnect).rejects.toThrow(/superseded|generation changed/);
const queuedRejected = expect(queuedRequest).rejects.toThrow('shell stopped');
a.failDesktopServiceGate(new Error('shell stopped'), gateId);
releaseToken(jsonRes({ token: 'late-token' }));
await Promise.all([bindingRejected, sharedRejected, queuedRejected]);
expect(a.isConnected).toBe(false);
expect(callsTo(fetchSpy, '/api/workspaces')).toHaveLength(0);
expect(() => a.getServerUrl()).toThrow(/not ready/);
});
it('superseding a token-pending launch rejects stale consumers and releases work only on the newer endpoint', async () => {
enableTauri();
const a = new LocalAdapter(BASE);
let releaseOldToken!: (response: Response) => void;
const oldTokenGate = new Promise<Response>(resolve => { releaseOldToken = resolve; });
fetchSpy.mockImplementation(async (url: unknown) => {
const value = String(url);
if (value === `http://127.0.0.1:${DESKTOP_A.port}/health`) {
return jsonRes(desktopHealth(DESKTOP_A));
}
if (value === `http://127.0.0.1:${DESKTOP_A.port}${TOKEN_PATH}`) return oldTokenGate;
if (value === `http://127.0.0.1:${DESKTOP_B.port}/health`) {
return jsonRes(desktopHealth(DESKTOP_B));
}
if (value === `http://127.0.0.1:${DESKTOP_B.port}${TOKEN_PATH}`) {
return jsonRes({ token: 'desktop-token-b' });
}
if (value === `http://127.0.0.1:${DESKTOP_B.port}/api/workspaces`) return jsonRes([]);
throw new Error(`unmocked fetch: ${value}`);
});
const firstGate = a.armDesktopServiceGate();
const staleBinding = a.connectDesktopService(DESKTOP_A, firstGate);
const staleConsumer = a.connect();
await vi.waitFor(() => expect(
callsTo(fetchSpy, `:${DESKTOP_A.port}${TOKEN_PATH}`),
).toHaveLength(1));
const staleBindingRejected = expect(staleBinding).rejects.toThrow(/superseded|changed/);
const staleConsumerRejected = expect(staleConsumer).rejects.toThrow(/superseded|changed/);
const secondGate = a.armDesktopServiceGate();
const queuedRequest = a.getWorkspaces();
await a.connectDesktopService(DESKTOP_B, secondGate);
await queuedRequest;
releaseOldToken(jsonRes({ token: 'stale-token-a' }));
await Promise.all([staleBindingRejected, staleConsumerRejected]);
expect(a.getServerUrl()).toBe(`http://127.0.0.1:${DESKTOP_B.port}`);
expect(a.isConnected).toBe(true);
expect(callsTo(fetchSpy, '/api/workspaces')).toHaveLength(1);
});
it('same-gate competing bindings let only the newest endpoint commit or fail the gate', async () => {
enableTauri();
const a = new LocalAdapter(BASE);
let releaseOldHealth!: (response: Response) => void;
const oldHealthGate = new Promise<Response>(resolve => { releaseOldHealth = resolve; });
fetchSpy.mockImplementation(async (url: unknown) => {
const value = String(url);
if (value === `http://127.0.0.1:${DESKTOP_A.port}/health`) return oldHealthGate;
if (value === `http://127.0.0.1:${DESKTOP_B.port}/health`) {
return jsonRes(desktopHealth(DESKTOP_B));
}
if (value === `http://127.0.0.1:${DESKTOP_B.port}${TOKEN_PATH}`) {
return jsonRes({ token: 'desktop-token-b' });
}
if (value === `http://127.0.0.1:${DESKTOP_B.port}/api/workspaces`) return jsonRes([]);
throw new Error(`unmocked fetch: ${value}`);
});
const gateId = a.armDesktopServiceGate();
const staleBinding = a.connectDesktopService(DESKTOP_A, gateId);
const staleRejected = expect(staleBinding).rejects.toThrow(/identity|superseded|changed/);
await vi.waitFor(() => expect(callsTo(fetchSpy, `:${DESKTOP_A.port}/health`)).toHaveLength(1));
await a.connectDesktopService(DESKTOP_B, gateId);
await a.getWorkspaces();
releaseOldHealth(jsonRes(desktopHealth(DESKTOP_A)));
await staleRejected;
expect(a.getServerUrl()).toBe(`http://127.0.0.1:${DESKTOP_B.port}`);
expect(a.isConnected).toBe(true);
expect(callsTo(fetchSpy, '/api/workspaces')).toHaveLength(1);
});
it('a current managed health deadline fails closed instead of releasing queued work', async () => {
vi.useFakeTimers();
enableTauri();
const a = new LocalAdapter(BASE);
fetchSpy.mockImplementation(() => new Promise<Response>(() => { /* never */ }));
const gateId = a.armDesktopServiceGate();
const binding = a.connectDesktopService(DESKTOP_A, gateId);
const queuedRequest = a.getWorkspaces();
const bindingRejected = expect(binding).rejects.toThrow(/timed out/);
const queuedRejected = expect(queuedRequest).rejects.toThrow(/timed out/);
await vi.advanceTimersByTimeAsync(16000);
await Promise.all([bindingRejected, queuedRejected]);
expect(a.isConnected).toBe(false);
expect(() => a.getServerUrl()).toThrow(/not ready/);
});
it('the outer connect watchdog fails closed when token bootstrap body stalls after healthy identity', async () => {
vi.useFakeTimers();
enableTauri();
const a = new LocalAdapter(BASE);
const hangingTokenBody = {
ok: true,
status: 200,
statusText: 'OK',
json: () => new Promise(() => { /* never */ }),
clone() { return this; },
} as unknown as Response;
routeMock(fetchSpy, [
['/health', () => jsonRes(desktopHealth(DESKTOP_A))],
[TOKEN_PATH, () => hangingTokenBody],
]);
const gateId = a.armDesktopServiceGate();
const binding = a.connectDesktopService(DESKTOP_A, gateId);
const queuedRequest = a.getWorkspaces();
const bindingRejected = expect(binding).rejects.toThrow(/timed out/);
const queuedRejected = expect(queuedRequest).rejects.toThrow(/timed out/);
await vi.advanceTimersByTimeAsync(16000);
await Promise.all([bindingRejected, queuedRejected]);
expect(callsTo(fetchSpy, '/health')).toHaveLength(1);
expect(callsTo(fetchSpy, TOKEN_PATH)).toHaveLength(1);
expect(a.isConnected).toBe(false);
expect(() => a.getServerUrl()).toThrow(/not ready/);
});
it('managed 401 token-body timeout rejects once and closes the verified gate', async () => {
vi.useFakeTimers();
enableTauri();
const a = new LocalAdapter(BASE);
let tokenCalls = 0;
let workspaceCalls = 0;
const hangingTokenBody = {
ok: true,
status: 200,
statusText: 'OK',
json: () => new Promise(() => { /* never */ }),
clone() { return this; },
} as unknown as Response;
routeMock(fetchSpy, [
['/health', () => jsonRes(desktopHealth(DESKTOP_A))],
[TOKEN_PATH, () => (++tokenCalls === 1
? jsonRes({ token: 'desktop-token-a' })
: hangingTokenBody)],
['/api/workspaces', () => {
workspaceCalls++;
return jsonRes({ error: 'Unauthorized' }, 401);
}],
]);
const gateId = a.armDesktopServiceGate();
await a.connectDesktopService(DESKTOP_A, gateId);
const request = a.getWorkspaces();
const rejected = expect(request).rejects.toThrow(/timed out/);
await vi.advanceTimersByTimeAsync(16000);
await rejected;
expect(tokenCalls).toBe(2);
expect(workspaceCalls).toBe(1);
expect(a.isConnected).toBe(false);
expect(() => a.getServerUrl()).toThrow(/not ready/);
});
it('a stale managed deadline cannot poison a newer verified generation', async () => {
vi.useFakeTimers();
enableTauri();
const a = new LocalAdapter(BASE);
fetchSpy.mockImplementation(async (url: unknown) => {
const value = String(url);
if (value === `http://127.0.0.1:${DESKTOP_A.port}/health`) {
return new Promise<Response>(() => { /* never */ });
}
if (value === `http://127.0.0.1:${DESKTOP_B.port}/health`) {
return jsonRes(desktopHealth(DESKTOP_B));
}
if (value === `http://127.0.0.1:${DESKTOP_B.port}${TOKEN_PATH}`) {
return jsonRes({ token: 'desktop-token-b' });
}
if (value === `http://127.0.0.1:${DESKTOP_B.port}/api/workspaces`) return jsonRes([]);
throw new Error(`unmocked fetch: ${value}`);
});
const firstGate = a.armDesktopServiceGate();
const staleBinding = a.connectDesktopService(DESKTOP_A, firstGate);
const staleRejected = expect(staleBinding).rejects.toThrow(/timed out/);
await Promise.resolve();
expect(callsTo(fetchSpy, `:${DESKTOP_A.port}/health`)).toHaveLength(1);
const secondGate = a.armDesktopServiceGate();
await a.connectDesktopService(DESKTOP_B, secondGate);
await a.getWorkspaces();
await vi.advanceTimersByTimeAsync(16000);
await staleRejected;
expect(a.getServerUrl()).toBe(`http://127.0.0.1:${DESKTOP_B.port}`);
expect(a.isConnected).toBe(true);
});
it('desktop gate APIs are inert in the browser and cannot change browser routing', async () => {
const a = new LocalAdapter(BASE);
routeMock(fetchSpy, [['/api/workspaces', () => jsonRes([])]]);
expect(a.armDesktopServiceGate()).toBe(0);
a.failDesktopServiceGate(new Error('ignored'), 0);
await a.getWorkspaces();
await expect(a.connectDesktopService(DESKTOP_A, 0)).rejects.toThrow(/only available inside Tauri/);
expect(a.getServerUrl()).toBe(BASE);
expect(callsTo(fetchSpy, '/api/workspaces')).toHaveLength(1);
});
it('managed SSE initial open revalidates identity and never opens on a same-port replacement', async () => {
enableTauri();
FakeEventSource.instances = [];
vi.stubGlobal('EventSource', FakeEventSource);
const a = new LocalAdapter(BASE);
let health = desktopHealth(DESKTOP_A);
routeMock(fetchSpy, [
['/health', () => jsonRes(health)],
[TOKEN_PATH, () => jsonRes({ token: 'desktop-token-a' })],
]);
const gateId = a.armDesktopServiceGate();
await a.connectDesktopService(DESKTOP_A, gateId);
health = { ...desktopHealth(DESKTOP_B), port: DESKTOP_A.port };
const unsubscribe = a.subscribeNotifications(() => {});
await flush();
expect(FakeEventSource.instances).toHaveLength(0);
expect(() => a.getServerUrl()).toThrow(/not ready/);
unsubscribe();
});
it('managed SSE retry revalidates identity after token refresh and refuses an unverified replacement', async () => {
vi.useFakeTimers();
enableTauri();
FakeEventSource.instances = [];
vi.stubGlobal('EventSource', FakeEventSource);
const a = new LocalAdapter(BASE);
let health = desktopHealth(DESKTOP_A);
let token = 'desktop-token-a';
routeMock(fetchSpy, [
['/health', () => jsonRes(health)],
[TOKEN_PATH, () => jsonRes({ token })],
]);
const gateId = a.armDesktopServiceGate();
await a.connectDesktopService(DESKTOP_A, gateId);
const unsubscribe = a.subscribeNotifications(() => {});
await vi.advanceTimersByTimeAsync(0);
expect(FakeEventSource.instances).toHaveLength(1);
health = { ...desktopHealth(DESKTOP_B), port: DESKTOP_A.port };
token = 'desktop-token-b';
FakeEventSource.instances[0].fireError();
await vi.advanceTimersByTimeAsync(1100);
expect(FakeEventSource.instances).toHaveLength(1);
expect(FakeEventSource.instances[0].closed).toBe(true);
expect(() => a.getServerUrl()).toThrow(/not ready/);
unsubscribe();
});
// ── setServerUrl epoch guard ─────────────────────────────────────────────
it('setServerUrl mid-flight: the stale connect cannot set connected state or the token', async () => {
@@ -288,6 +725,72 @@ describe('P1b auth gate', () => {
await expect(consume()).rejects.toThrow(AdapterHttpError);
});
it('fetchWithTimeout preserves fresh and pre-aborted caller cancellation without AbortSignal.any', async () => {
const anyDescriptor = Object.getOwnPropertyDescriptor(AbortSignal, 'any');
Object.defineProperty(AbortSignal, 'any', { configurable: true, value: undefined });
vi.useFakeTimers();
const caller = new AbortController();
fetchSpy.mockImplementation(async (_url, init) => new Promise<Response>((_resolve, reject) => {
const signal = (init as RequestInit | undefined)?.signal;
if (!(signal instanceof AbortSignal)) throw new Error('missing request signal');
if (signal.aborted) {
reject(new DOMException('The operation was aborted', 'AbortError'));
return;
}
signal.addEventListener(
'abort',
() => reject(new DOMException('The operation was aborted', 'AbortError')),
{ once: true },
);
}));
try {
const request = fetchWithTimeout(`${BASE}/slow`, { signal: caller.signal });
caller.abort();
await expect(request).rejects.toMatchObject({ name: 'AbortError' });
const preAborted = new AbortController();
preAborted.abort();
await expect(fetchWithTimeout(`${BASE}/already-stopped`, {
signal: preAborted.signal,
})).rejects.toMatchObject({ name: 'AbortError' });
expect(vi.getTimerCount()).toBe(0);
} finally {
if (anyDescriptor) Object.defineProperty(AbortSignal, 'any', anyDescriptor);
else Reflect.deleteProperty(AbortSignal, 'any');
}
});
it('abortAgent cancels only the requested chat session', async () => {
const a = new LocalAdapter(BASE);
const requestSignals: AbortSignal[] = [];
fetchSpy.mockImplementation(async (_url, init) => new Promise<Response>((_resolve, reject) => {
const signal = (init as RequestInit | undefined)?.signal;
if (!(signal instanceof AbortSignal)) throw new Error('missing request signal');
requestSignals.push(signal);
signal.addEventListener(
'abort',
() => reject(new DOMException('The operation was aborted', 'AbortError')),
{ once: true },
);
}));
const sessionA = a.sendMessage('ws1', 'first', 'session-a');
const sessionB = a.sendMessage('ws1', 'second', 'session-b');
const resultA = sessionA.next().catch((error: unknown) => error);
const resultB = sessionB.next().catch((error: unknown) => error);
await vi.waitFor(() => expect(requestSignals).toHaveLength(2));
await a.abortAgent('ws1', 'session-a');
expect(requestSignals[0].aborted).toBe(true);
expect(requestSignals[1].aborted).toBe(false);
await expect(resultA).resolves.toMatchObject({ name: 'AbortError' });
await a.abortAgent('ws1');
expect(requestSignals[1].aborted).toBe(true);
await expect(resultB).resolves.toMatchObject({ name: 'AbortError' });
});
// ── Body-envelope getters (fetchRaw contract — ApprovalModal flow et al) ──
it('installMcp resolves the 422 SecurityGate envelope (requiresApproval) instead of throwing', async () => {

View File

@@ -30,4 +30,41 @@ describe('model router request deadlines', () => {
45_000,
]);
});
it('allows chat time-to-first-token to exceed the generic request timeout', async () => {
const fetchSpy = vi.spyOn(client, 'fetch').mockResolvedValue(new Response([
'event: done',
'data: {"content":"ok"}',
'',
'',
].join('\n'), {
status: 200,
headers: { 'content-type': 'text/event-stream' },
}));
const events = [];
for await (const event of client.sendMessage(
'workspace-1',
'hello',
'session-1',
'writer',
undefined,
undefined,
'openai/requested-model',
)) {
events.push(event);
}
expect(fetchSpy).toHaveBeenCalledOnce();
expect(fetchSpy.mock.calls[0]?.[2]).toBe(45_000);
const request = fetchSpy.mock.calls[0]?.[1] as RequestInit;
expect(JSON.parse(request.body as string)).toMatchObject({
workspaceId: 'workspace-1',
message: 'hello',
sessionId: 'session-1',
persona: 'writer',
model: 'openai/requested-model',
});
expect(events).toEqual([{ type: 'done', data: { content: 'ok' } }]);
});
});

View File

@@ -14,6 +14,7 @@ import {
type FrameImportance,
type FrameSource,
type IdentityResponse,
type DesktopServiceEndpoint,
} from './tauri-bindings';
import type {
Workspace, WorkspaceContext, ChatMessage, MemoryFrame, Memory,
@@ -64,6 +65,31 @@ export interface AgentGroupRunResult {
message?: string;
}
export interface ManagedLocalRuntimeStatus {
source: 'waggle-managed';
supported: boolean;
installed: boolean;
running: boolean;
targetVersion: string | null;
version: string | null;
artifactSizeBytes: number | null;
downloadRequired: boolean;
dockerRequired: false;
reason?: string;
}
export interface LocalInferenceStatus {
servers: Array<Record<string, unknown>>;
ollamaInstalled: boolean;
ollamaRunning: boolean;
totalLocalModels: number;
offlineReady: boolean;
dockerRequired: false;
managedRuntime: ManagedLocalRuntimeStatus;
setupRequired: boolean;
setupMessage: string | null;
}
/**
* CC Sesija A §2.2 — map adapter `MemoryFrame.importance` (number 1-4) to the
* Tauri command's string enum. Inverse of IMPORTANCE_MAP.
@@ -97,6 +123,12 @@ export interface ChannelPairedSender {
export type ChannelPairings = Partial<Record<ChannelPlatform, ChannelPairedSender[]>>;
export interface BrowserCompanionPairingStatus {
paired: boolean;
extensionId: string | null;
pairedAt: string | null;
}
export function resolveDefaultServerUrl(
locationLike: Pick<Location, 'protocol' | 'hostname' | 'port' | 'origin'> | undefined =
typeof window !== 'undefined' ? window.location : undefined,
@@ -249,10 +281,28 @@ export interface EmbeddingRoutingStatus {
class LocalAdapter {
private baseUrl: string;
private readonly managedDesktop: boolean;
private desktopEndpoint: DesktopServiceEndpoint | null = null;
private desktopEndpointReady = false;
private desktopGateId = 0;
private desktopGate: {
id: number;
promise: Promise<void>;
resolve: () => void;
reject: (error: Error) => void;
status: 'pending' | 'ready' | 'failed';
error: Error | null;
} | null = null;
private authToken: string | null = null;
private ws: WebSocket | null = null;
/** P1b-SSE: one ref-counted reconnecting stream per (path, eventName). */
private sseStreams = new Map<string, { close: () => void; listeners: Set<(data: unknown) => void> }>();
/** Active chat requests, session-scoped with workspace-wide Stop fallback. */
private activeChatControllers = new Map<string, Set<AbortController>>();
private chatControllerKey(workspaceId: string, sessionId?: string): string {
return `${workspaceId}\u0000${sessionId ?? ''}`;
}
private _connected = false;
private _connectAttempted = false;
// P1b D3 gate state. _connectPromise doubles as the deferral gate: kept
@@ -267,13 +317,19 @@ class LocalAdapter {
private _epoch = 0;
constructor(serverUrl?: string) {
this.baseUrl = serverUrl || localStorage.getItem('waggle:server-url') || resolveDefaultServerUrl();
this.managedDesktop = isTauri();
this.baseUrl = this.managedDesktop
? resolveDefaultServerUrl()
: serverUrl || localStorage.getItem('waggle:server-url') || resolveDefaultServerUrl();
}
get isConnected() { return this._connected; }
get hasAttemptedConnect() { return this._connectAttempted; }
setServerUrl(url: string) {
if (this.managedDesktop) {
throw new Error('The desktop service endpoint is managed by Waggle');
}
this._epoch++;
this.baseUrl = url;
localStorage.setItem('waggle:server-url', url);
@@ -290,9 +346,147 @@ class LocalAdapter {
}
getServerUrl() {
if (this.managedDesktop && (!this.desktopEndpoint || !this.desktopEndpointReady)) {
throw new Error('The managed desktop service endpoint is not ready');
}
return this.baseUrl;
}
armDesktopServiceGate(): number {
if (!this.managedDesktop) return 0;
if (this.desktopGate?.status === 'pending') {
const superseded = new Error('The managed desktop service launch was superseded');
this.desktopGate.status = 'failed';
this.desktopGate.error = superseded;
this.desktopGate.reject(superseded);
}
this._epoch++;
this.desktopEndpoint = null;
this.desktopEndpointReady = false;
this.authToken = null;
this._connected = false;
this._connectAttempted = false;
this._connectPromise = null;
this._healthProbePromise = null;
this._refreshPromise = null;
const id = ++this.desktopGateId;
let resolve!: () => void;
let reject!: (error: Error) => void;
const promise = new Promise<void>((resolveGate, rejectGate) => {
resolve = resolveGate;
reject = rejectGate;
});
void promise.catch(() => { /* future requests observe the same failure */ });
this.desktopGate = {
id,
promise,
resolve,
reject,
status: 'pending',
error: null,
};
return id;
}
async connectDesktopService(
endpoint: DesktopServiceEndpoint,
gateId: number,
): Promise<SystemHealth> {
if (!this.managedDesktop) {
throw new Error('Desktop service binding is only available inside Tauri');
}
if (!Number.isInteger(endpoint.port) || endpoint.port < 1 || endpoint.port > 65535
|| typeof endpoint.instanceId !== 'string' || endpoint.instanceId.trim().length === 0) {
throw new Error('Tauri returned an invalid desktop service endpoint');
}
if (!this.desktopGate || this.desktopGate.id !== gateId
|| this.desktopGate.status !== 'pending') {
throw new Error('Ignoring a stale desktop service endpoint');
}
const bindEpoch = ++this._epoch;
this.baseUrl = `http://127.0.0.1:${endpoint.port}`;
this.desktopEndpoint = {
port: endpoint.port,
instanceId: endpoint.instanceId,
...(endpoint.bootstrapToken ? { bootstrapToken: endpoint.bootstrapToken } : {}),
};
this.desktopEndpointReady = false;
this.authToken = null;
this._connected = false;
this._connectAttempted = false;
this._connectPromise = null;
this._healthProbePromise = null;
this._refreshPromise = null;
try {
const health = await this.connect();
const record = health as SystemHealth & { instanceId?: unknown; port?: unknown };
if (record.instanceId !== endpoint.instanceId || record.port !== endpoint.port) {
throw new Error('Desktop service health identity does not match the Tauri endpoint');
}
if (!this.desktopGate || this.desktopGate.id !== gateId
|| bindEpoch !== this._epoch
|| this.desktopGate.status !== 'pending'
|| this.desktopEndpoint?.port !== endpoint.port
|| this.desktopEndpoint?.instanceId !== endpoint.instanceId) {
throw new Error('Desktop service launch changed during connection');
}
this.desktopGate.status = 'ready';
this.desktopGate.error = null;
this.desktopEndpointReady = true;
this.desktopGate.resolve();
return health;
} catch (error) {
this.failCurrentDesktopGeneration(error, bindEpoch);
throw error;
}
}
failDesktopServiceGate(error: unknown, gateId: number): void {
if (!this.managedDesktop || !this.desktopGate || this.desktopGate.id !== gateId) return;
if (this.desktopGate.status === 'failed') return;
const failure = error instanceof Error ? error : new Error(String(error));
this._epoch++;
this.desktopEndpoint = null;
this.desktopEndpointReady = false;
this.authToken = null;
this._connected = false;
this._connectPromise = null;
this._healthProbePromise = null;
this._refreshPromise = null;
this.desktopGate.status = 'failed';
this.desktopGate.error = failure;
this.desktopGate.reject(failure);
}
private failCurrentDesktopGeneration(error: unknown, epoch: number): void {
if (!this.managedDesktop || epoch !== this._epoch) return;
const gateId = this.desktopGate?.id;
if (gateId !== undefined) this.failDesktopServiceGate(error, gateId);
}
private async awaitDesktopServiceGate(): Promise<void> {
if (!this.managedDesktop) return;
while (true) {
const gate = this.desktopGate;
if (!gate) {
throw new Error('The managed desktop service gate was not armed');
}
try {
await gate.promise;
} catch (error) {
if (this.desktopGate !== gate) continue;
throw error;
}
if (this.desktopGate !== gate) continue;
if (gate.status === 'failed') {
throw gate.error ?? new Error('The managed desktop service failed');
}
if (gate.status === 'ready') return;
}
}
/**
* P1b D3: explicit re-probe that bypasses the retained settled-success
* memo. `connect()` deliberately dedups onto a successful attempt (the
@@ -315,10 +509,15 @@ class LocalAdapter {
* gated request via ensureReady's re-arm) starts a fresh attempt.
*/
connect(): Promise<SystemHealth> {
if (this.managedDesktop && !this.desktopEndpoint) {
return this.awaitDesktopServiceGate().then(() => this.connect());
}
if (this._connectPromise) return this._connectPromise;
const p = this.doConnect(this._epoch);
const epoch = this._epoch;
const p = this.doConnect(epoch);
this._connectPromise = p;
p.catch(() => {
p.catch((error) => {
this.failCurrentDesktopGeneration(error, epoch);
if (this._connectPromise === p) this._connectPromise = null;
});
return p;
@@ -339,18 +538,27 @@ class LocalAdapter {
try {
const data = await Promise.race([
(async () => {
const health = await this.healthProbe(epoch);
let health = await this.healthProbe(epoch);
if (this.managedDesktop && epoch !== this._epoch) {
throw new Error('Connection attempt was superseded by a newer desktop generation');
}
// D1: the sidecar requires a bearer token even on loopback. Fetch it
// from the auth-exempt, same-origin-gated bootstrap. (R1-001: it is
// NOT served by the unauthenticated /health.) Best-effort — if the
// bootstrap is unreachable we proceed token-less; the 401-refresh
// retry leg recovers as soon as the endpoint is reachable.
await this.fetchSessionToken(epoch);
if (this.managedDesktop) {
health = await this.revalidateDesktopEndpoint(epoch);
}
return health;
})(),
deadline,
]);
if (epoch === this._epoch) this._connected = true;
if (epoch !== this._epoch) {
throw new Error('Connection attempt was superseded by a newer desktop generation');
}
this._connected = true;
return data;
} catch (e) {
if (epoch === this._epoch) this._connected = false;
@@ -361,8 +569,8 @@ class LocalAdapter {
}
/**
* P1b D3: the deferral gate awaited by every non-exempt request.
* Four states:
* P1b D3: the connection gate awaited by every non-exempt request.
* Browser mode retains four states:
* - never attempted → pass through (keeps the adapter unit-test files,
* which construct LocalAdapter and call methods directly, gate-free;
* production arms the gate via boot-connect.ts, main.tsx's first import)
@@ -372,10 +580,13 @@ class LocalAdapter {
* onto it. This makes the gate self-healing on the default desktop path
* (webview up before the sidecar listens: the boot kickoff fails fast
* with ECONNREFUSED and must not permanently disarm the gate).
* A FAILED attempt always releases the gate the request proceeds and
* fails loudly with its own cause rather than hanging.
* A failed browser attempt releases the gate so the request fails with its
* own cause rather than hanging. Managed desktop mode first awaits the
* separate Rust-owned endpoint gate; a failed identity handshake stays
* closed until a newer lifecycle generation rearms it.
*/
private async ensureReady(): Promise<void> {
await this.awaitDesktopServiceGate();
if (!this._connectAttempted) return;
const gate = this._connectPromise ?? this.connect();
try { await gate; } catch { /* released — request fails with its own cause */ }
@@ -386,7 +597,7 @@ class LocalAdapter {
* null. The 401-refresh leg (refreshSessionToken) is the LOUD variant. */
private async fetchSessionToken(epoch: number): Promise<void> {
try {
const res = await this.request('/api/auth/session-token');
const res = await this.request('/api/auth/session-token', undefined, undefined, false, true);
if (res.ok) {
const body = (await res.json()) as { token?: string };
if (epoch === this._epoch) this.authToken = body.token ?? null;
@@ -419,6 +630,9 @@ class LocalAdapter {
if (epoch === this._epoch) this.authToken = body.token;
})(), CONNECT_DEADLINE_MS, 'session-token refresh');
this._refreshPromise = p;
if (this.managedDesktop) {
void p.catch((error) => this.failCurrentDesktopGeneration(error, epoch));
}
p.finally(() => {
if (this._refreshPromise === p) this._refreshPromise = null;
}).catch(() => { /* settled via callers */ });
@@ -451,18 +665,53 @@ class LocalAdapter {
if (this._healthProbePromise) return this._healthProbePromise;
const p = deadlined(this.doHealthProbe(epoch), CONNECT_DEADLINE_MS, 'health probe');
this._healthProbePromise = p;
void p.catch((error) => this.failCurrentDesktopGeneration(error, epoch));
p.finally(() => {
if (this._healthProbePromise === p) this._healthProbePromise = null;
}).catch(() => { /* settled via callers */ });
return p;
}
/**
* A managed token is process-scoped. Always perform a fresh, non-memoized
* identity probe after obtaining one so a same-port sidecar replacement
* cannot inherit the previous Rust-verified generation's trust.
*/
private async revalidateDesktopEndpoint(epoch: number): Promise<SystemHealth> {
if (epoch !== this._epoch) {
throw new Error('Desktop service generation changed before revalidation');
}
try {
const health = await deadlined(
this.doHealthProbe(epoch),
CONNECT_DEADLINE_MS,
'desktop health revalidation',
);
if (epoch !== this._epoch) {
throw new Error('Desktop service generation changed during revalidation');
}
return health;
} catch (error) {
this.failCurrentDesktopGeneration(error, epoch);
throw error;
}
}
private async doHealthProbe(epoch: number): Promise<SystemHealth> {
try {
const res = await this.request('/health');
const res = await this.request('/health', undefined, undefined, false, true);
if (!res.ok) throw new AdapterHttpError(res.status, res.statusText, await res.clone().json().catch(() => undefined));
return await res.json();
const data = await res.json() as SystemHealth & { instanceId?: unknown; port?: unknown };
if (this.managedDesktop && (
!this.desktopEndpoint
|| data.instanceId !== this.desktopEndpoint.instanceId
|| data.port !== this.desktopEndpoint.port
)) {
throw new Error('Desktop service health identity does not match the Tauri endpoint');
}
return data;
} catch (firstErr) {
if (this.managedDesktop) throw firstErr;
const fallbackServer = resolveDefaultServerUrl();
if (this.baseUrl === fallbackServer) throw firstErr;
try {
@@ -510,10 +759,21 @@ class LocalAdapter {
/** Shared request core: deferral gate → headers/token → fetch → 403 tier
* dispatch → 401 refresh-retry (token-versioned, once per request). */
private async request(path: string, init?: RequestInit, timeoutMs?: number, isRetry = false): Promise<Response> {
private async request(
path: string,
init?: RequestInit,
timeoutMs?: number,
isRetry = false,
bypassDesktopGate = false,
): Promise<Response> {
const purePath = path.split('?')[0];
const exempt = AUTH_EXEMPT_PATHS.has(purePath);
if (!bypassDesktopGate) await this.awaitDesktopServiceGate();
if (!exempt) await this.ensureReady();
// A restart can rearm the desktop gate while ensureReady() is awaiting an
// older connect attempt. Recheck immediately before the synchronous fetch
// call so that attempt cannot release a request onto the superseded port.
if (!bypassDesktopGate) await this.awaitDesktopServiceGate();
const issuedToken = this.authToken;
const headers: Record<string, string> = {
@@ -535,6 +795,13 @@ class LocalAdapter {
if (issuedToken) {
headers['Authorization'] = `Bearer ${issuedToken}`;
}
if (
this.managedDesktop
&& purePath === '/api/auth/session-token'
&& this.desktopEndpoint?.bootstrapToken
) {
headers['X-Waggle-Desktop-Bootstrap'] = this.desktopEndpoint.bootstrapToken;
}
const res = await fetchWithTimeout(`${this.baseUrl}${path}`, { ...init, headers }, timeoutMs);
if (res.status === 403) {
const clone = res.clone();
@@ -557,7 +824,11 @@ class LocalAdapter {
if (this.authToken === issuedToken) {
await this.refreshSessionToken();
}
return this.request(path, init, timeoutMs, true);
if (this.managedDesktop) {
await this.awaitDesktopServiceGate();
await this.revalidateDesktopEndpoint(this._epoch);
}
return this.request(path, init, timeoutMs, true, bypassDesktopGate);
}
return res;
}
@@ -634,12 +905,12 @@ class LocalAdapter {
return res.json();
}
async updateWorkspace(id: string, data: Partial<Workspace>): Promise<Workspace> {
async updateWorkspace(id: string, data: Partial<Pick<Workspace, 'persona' | 'agentGroupId' | 'templateId' | 'name' | 'group' | 'model' | 'status' | 'description' | 'type'>>): Promise<Workspace> {
const res = await this.fetch(`/api/workspaces/${id}`, { method: 'PUT', body: JSON.stringify(data) });
return res.json();
}
async patchWorkspace(id: string, data: Partial<Pick<Workspace, 'persona' | 'agentGroupId' | 'templateId' | 'name' | 'group' | 'model' | 'status' | 'description'>>): Promise<Workspace> {
async patchWorkspace(id: string, data: Partial<Pick<Workspace, 'persona' | 'agentGroupId' | 'templateId' | 'name' | 'group' | 'model' | 'status' | 'description' | 'type'>>): Promise<Workspace> {
const res = await this.fetch(`/api/workspaces/${id}`, { method: 'PATCH', body: JSON.stringify(data) });
return res.json();
}
@@ -795,23 +1066,35 @@ class LocalAdapter {
persona?: string,
autonomy?: { level: 'normal' | 'trusted' | 'yolo'; expiresAt?: number },
retry?: boolean,
model?: string,
): AsyncGenerator<StreamEvent> {
// CC Sesija A §2.2 — thread the user-selected Faza 1 GEPA shape into the
// chat body. Sidecar /api/chat ignores `shape` until A3.1 wires it into
// runRetrievalAgentLoop; carrying it now means A3.1 is a one-line server
// change with no client redeploy needed.
const shape = getSelectedShape();
const controller = new AbortController();
const chatControllerKey = this.chatControllerKey(workspaceId, sessionId);
let controllers = this.activeChatControllers.get(chatControllerKey);
if (!controllers) {
controllers = new Set();
this.activeChatControllers.set(chatControllerKey, controllers);
}
controllers.add(controller);
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
try {
const res = await this.fetch('/api/chat', {
method: 'POST',
body: JSON.stringify({ workspaceId, message, sessionId, persona, autonomy, shape, retry }),
});
body: JSON.stringify({ workspaceId, message, sessionId, persona, autonomy, shape, retry, model }),
signal: controller.signal,
}, MODEL_ROUTER_REQUEST_TIMEOUT_MS);
if (!res.body) return;
const reader = res.body.getReader();
reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let currentEventType = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
@@ -840,14 +1123,40 @@ class LocalAdapter {
}
}
}
} finally {
controller.abort();
controllers.delete(controller);
if (controllers.size === 0 && this.activeChatControllers.get(chatControllerKey) === controllers) {
this.activeChatControllers.delete(chatControllerKey);
}
if (reader) {
try { await reader.cancel(); } catch { /* stream already closed */ }
try { reader.releaseLock(); } catch { /* reader already released */ }
}
}
}
async abortAgent(workspaceId: string): Promise<void> {
await this.fetch(`/api/agent/abort`, { method: 'POST', body: JSON.stringify({ workspaceId }) });
async abortAgent(workspaceId: string, sessionId?: string): Promise<void> {
const controllerKeys = sessionId !== undefined
? [this.chatControllerKey(workspaceId, sessionId)]
: [...this.activeChatControllers.keys()].filter(
key => key.startsWith(`${workspaceId}\u0000`),
);
for (const controllerKey of controllerKeys) {
const controllers = this.activeChatControllers.get(controllerKey);
if (!controllers) continue;
this.activeChatControllers.delete(controllerKey);
for (const controller of controllers) controller.abort();
}
}
async clearHistory(sessionId: string): Promise<void> {
await this.fetch(`/api/chat/history?session=${sessionId}`, { method: 'DELETE' });
async clearHistory(
workspaceId: string | null,
sessionId: string,
): Promise<void> {
const params = new URLSearchParams({ session: sessionId });
if (workspaceId) params.set('workspace', workspaceId);
await this.fetch(`/api/chat/history?${params.toString()}`, { method: 'DELETE' });
}
async getHistory(workspaceId: string, sessionId: string): Promise<ChatMessage[]> {
@@ -1202,13 +1511,32 @@ class LocalAdapter {
return res.json();
}
async getLocalInferenceStatus(): Promise<{ servers: Array<Record<string, unknown>>; ollamaInstalled: boolean; totalLocalModels: number }> {
async getLocalInferenceStatus(): Promise<LocalInferenceStatus> {
const res = await this.fetch('/api/local-inference/status');
return res.json();
}
async pullLocalModel(model: string): Promise<{ ok: boolean }> {
const res = await this.fetch('/api/local-inference/pull', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model }) });
async bootstrapLocalRuntime(): Promise<{
ok: boolean;
installedNow: boolean;
startedNow: boolean;
endpoint: string;
dockerRequired: false;
}> {
const res = await this.fetch(
'/api/local-inference/bootstrap',
{ method: 'POST' },
45 * 60_000,
);
return res.json();
}
async pullLocalModel(model: string): Promise<{ ok: boolean; model: string; verifiedGeneration: boolean }> {
const res = await this.fetch(
'/api/local-inference/pull',
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model }) },
50 * 60_000,
);
return res.json();
}
@@ -2141,6 +2469,20 @@ class LocalAdapter {
return res.json();
}
async getBrowserCompanionPairing(): Promise<BrowserCompanionPairingStatus> {
const res = await this.fetch('/api/browser-ext/pairing');
return res.json();
}
async createBrowserCompanionPairingCode(): Promise<{ code: string; expiresAt: number }> {
const res = await this.fetch('/api/browser-ext/pairing-code', { method: 'POST' });
return res.json();
}
async revokeBrowserCompanionPairing(): Promise<void> {
await this.fetch('/api/browser-ext/pairing', { method: 'DELETE' });
}
async saveChannelConfig(
platform: ChannelPlatform,
config: {
@@ -2456,6 +2798,7 @@ class LocalAdapter {
// --- Health ---
async getSystemHealth(): Promise<SystemHealth> {
if (this.managedDesktop) await this.awaitDesktopServiceGate();
// Use the same auto-discovery fallback as connect() so the offline pill
// converges on the working URL even if useOfflineStatus polls before
// ServiceProvider's connect() effect runs (FR #10).
@@ -2480,7 +2823,7 @@ class LocalAdapter {
async connectConnector(id: string, credentials?: {
token?: string; apiKey?: string; refreshToken?: string;
expiresAt?: string; scopes?: string[]; email?: string;
expiresAt?: string; scopes?: string[]; email?: string; baseUrl?: string; instanceUrl?: string;
}): Promise<void> {
await this.fetch(`/api/connectors/${id}/connect`, {
method: 'POST',
@@ -3172,23 +3515,51 @@ class LocalAdapter {
es.onopen = () => { attempt = 0; onOpen?.(); };
es.onerror = () => {
es?.close();
if (cancelled) return;
scheduleRetry();
};
};
const scheduleRetry = () => {
if (cancelled) return;
const delay = Math.min(30000, 1000 * 2 ** attempt++);
retryTimer = setTimeout(() => {
// Sidecar restart rotates the token; the URL-baked one is then
// permanently stale. Best-effort refresh before each reopen —
// single-flighted, and a failure just means the next backoff round.
void this.refreshSessionToken().catch(() => { /* server still down */ })
.then(() => { if (!cancelled) open(); });
// Sidecar restart rotates the token; wait for the current desktop gate
// and a fresh token before constructing a URL for the replacement
// generation. Failed refreshes stay closed and back off again.
void this.refreshSessionToken()
.then(() => this.awaitDesktopServiceGate())
.then(async () => {
if (this.managedDesktop) {
await this.revalidateDesktopEndpoint(this._epoch);
}
})
.then(() => { if (!cancelled) open(); })
.catch(() => {
if (cancelled) return;
if (this.managedDesktop) scheduleRetry();
else open();
});
}, delay);
};
};
// Lazy-open: wait for the connect attempt to settle so the token exists.
// Never-attempted (unit tests) passes through immediately; a FAILED
// connect also releases — the stream 401s and enters the retry loop,
// which doubles as the recovery path.
void this.ensureReady().then(() => { if (!cancelled) open(); });
void this.ensureReady()
.then(() => this.awaitDesktopServiceGate())
.then(async () => {
if (this.managedDesktop) {
await this.revalidateDesktopEndpoint(this._epoch);
}
})
.then(() => { if (!cancelled) open(); })
.catch(() => {
if (cancelled) return;
if (this.managedDesktop) scheduleRetry();
else open();
});
return () => {
cancelled = true;
@@ -3641,6 +4012,9 @@ class LocalAdapter {
// --- WebSocket ---
connectWebSocket(onMessage: (data: unknown) => void): () => void {
if (this.managedDesktop && (!this.desktopEndpoint || !this.desktopEndpointReady)) {
throw new Error('The managed desktop service endpoint is not ready');
}
const wsUrl = this.baseUrl.replace('http', 'ws') + `/ws?token=${this.authToken}`;
this.ws = new WebSocket(wsUrl);
this.ws.onmessage = (e) => {

View File

@@ -12,6 +12,29 @@ export class NetworkError extends Error {
}
}
function combineAbortSignals(signals: AbortSignal[]): {
signal: AbortSignal;
cleanup: () => void;
} {
if (typeof AbortSignal.any === 'function') {
return { signal: AbortSignal.any(signals), cleanup: () => {} };
}
const controller = new AbortController();
const abort = () => controller.abort();
if (signals.some(signal => signal.aborted)) {
abort();
} else {
for (const signal of signals) signal.addEventListener('abort', abort, { once: true });
}
return {
signal: controller.signal,
cleanup: () => {
for (const signal of signals) signal.removeEventListener('abort', abort);
},
};
}
export async function fetchWithTimeout(
url: string,
options: RequestInit = {},
@@ -19,19 +42,26 @@ export async function fetchWithTimeout(
): Promise<Response> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
const combined = options.signal
? combineAbortSignals([options.signal, controller.signal])
: { signal: controller.signal, cleanup: () => {} };
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
signal: combined.signal,
});
return response;
} catch (err: unknown) {
if (err instanceof Error && err.name === 'AbortError') {
if (controller.signal.aborted && !options.signal?.aborted) {
throw new TimeoutError(url, timeoutMs);
}
// Caller cancellation (for example Chat Stop) is control flow, not a
// timeout/network outage. Preserve the native AbortError for the caller.
if (options.signal?.aborted) throw err;
throw new NetworkError(url, err instanceof Error ? err : undefined);
} finally {
clearTimeout(timeout);
combined.cleanup();
}
}

View File

@@ -57,17 +57,15 @@ describe('promptArgsForTool', () => {
});
describe('hermes', () => {
it('passes prompt as positional argument (best-effort convention)', () => {
expect(promptArgsForTool('hermes', 'summarize todays standup')).toEqual([
'summarize todays standup',
]);
it('does not send an unverified positional prompt during interactive launch', () => {
expect(promptArgsForTool('hermes', 'summarize todays standup')).toBeNull();
});
});
// ── GUI / folder-based tools ───────────────────────────────────────
describe('GUI-only / folder-based tools return null', () => {
it.each(['cursor', 'claude-desktop', 'codex-desktop'])(
it.each(['cursor', 'claude-desktop', 'codex-desktop', 'hermes-desktop'])(
'%s — no CLI prompt surface',
(toolId) => {
expect(promptArgsForTool(toolId, 'anything')).toBeNull();
@@ -87,7 +85,8 @@ describe('toolAcceptsInlinePrompt', () => {
['claude-code', true],
['openclaw', true],
['codex', true],
['hermes', true],
['hermes', false],
['hermes-desktop', false],
['cursor', false],
['claude-desktop', false],
['codex-desktop', false],

View File

@@ -12,7 +12,8 @@
* claude-code → ['--print', prompt]
* openclaw → ['--print', prompt] (Claude Code fork)
* codex → [prompt] (positional arg)
* hermes → [prompt] (positional arg — best-effort)
* hermes → null (captured-task API only)
* hermes-desktop → null (GUI only)
* cursor → null (folder-based; no CLI prompt)
* claude-desktop → null (GUI only)
* codex-desktop → null (GUI only)
@@ -63,20 +64,17 @@ export function promptArgsForTool(toolId: string, prompt: string): string[] | nu
case 'codex':
return [p];
// Hermes Agent CLI: positional-arg convention by analogy. The
// hive-mind-hooks-hermes package is a Wave 2/3 stub; this entry
// is documented as best-effort and should be verified once the
// Hermes CLI is exercised against a real binary.
case 'hermes':
return [p];
// GUI-only tools: no CLI prompt surface.
// GUI-only tools and Hermes interactive launch: no verified inline-prompt
// surface. Hermes captured tasks use the shared headless task contract,
// not this dock-launch helper.
// cursor: opens a folder (`cursor /path`), no --prompt flag.
// claude-desktop / codex-desktop: GUI binaries with no
// prompt-from-CLI handoff documented.
case 'cursor':
case 'claude-desktop':
case 'codex-desktop':
case 'hermes':
case 'hermes-desktop':
return null;
default:

View File

@@ -74,4 +74,63 @@ describe('renderChatMarkdown', () => {
const html = renderChatMarkdown('a\n\nb');
expect(html).toContain('<span class="block h-2">');
});
it('preserves wildcard operators inside inline code', () => {
const host = document.createElement('div');
host.innerHTML = renderChatMarkdown('Run `search_files("**/*")` without editing.');
expect(host.querySelector('code')?.textContent).toBe('search_files("**/*")');
expect(host.querySelector('code strong, code em')).toBeNull();
});
it('preserves emphasis and safe links wrapped around inline code', () => {
const host = document.createElement('div');
host.innerHTML = renderChatMarkdown('Use **`npm test`** or [`npm run build`](https://example.com).');
expect(host.querySelector('strong code')?.textContent).toBe('npm test');
expect(host.querySelector('a[href="https://example.com"] code')?.textContent).toBe('npm run build');
});
it('never restores an inline code tag inside a link attribute', () => {
const host = document.createElement('div');
host.innerHTML = renderChatMarkdown('[x](https://example.com/`fragment`)');
expect(host.querySelector('a')).toBeNull();
expect(host.querySelector('code')?.textContent).toBe('fragment');
});
it('preserves indentation and operators inside fenced code', () => {
const source = [
'```python',
'def retry(attempt: int) -> float:',
' return base_backoff_s * (2 ** attempt)',
'```',
].join('\n');
const host = document.createElement('div');
host.innerHTML = renderChatMarkdown(source);
expect(host.querySelector('pre code')?.textContent).toBe([
'def retry(attempt: int) -> float:',
' return base_backoff_s * (2 ** attempt)',
].join('\n'));
expect(host.querySelector('pre code strong, pre code em')).toBeNull();
});
it('renders an unfinished streaming fence as safe preformatted code', () => {
const host = document.createElement('div');
host.innerHTML = renderChatMarkdown('```python\nvalue = 2 ** attempt');
expect(host.querySelector('pre code')?.textContent).toBe('value = 2 ** attempt');
});
it('keeps fenced HTML inert and refuses unsafe language metadata', () => {
const safeHost = document.createElement('div');
safeHost.innerHTML = renderChatMarkdown('```html\n<script>alert(1)</script>\n```');
expect(safeHost.querySelector('script')).toBeNull();
expect(safeHost.querySelector('pre code')?.textContent).toBe('<script>alert(1)</script>');
const unsafeHtml = renderChatMarkdown('```\"><img src=x onerror=alert(1)>\nbody');
expect(unsafeHtml).not.toContain('<img');
expect(unsafeHtml).not.toContain('onerror="');
});
});

View File

@@ -21,21 +21,48 @@ function escapeHtml(text: string): string {
.replace(/"/g, '&quot;');
}
/** Inline rules (bold/italic/code/safe links) over ALREADY-ESCAPED text. */
function applyInline(escaped: string): string {
/** Non-code inline rules over ALREADY-ESCAPED text. */
function applyStyledText(escaped: string, protectedHrefToken = ''): string {
return escaped
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/`(.+?)`/g, '<code class="px-1 py-0.5 rounded bg-muted text-xs font-mono">$1</code>')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, label: string, url: string) => {
const u = String(url).trim();
const safe = /^https?:\/\//i.test(u) || u.startsWith('/') || u.startsWith('#');
const safeScheme = /^https?:\/\//i.test(u) || u.startsWith('/') || u.startsWith('#');
// A protected inline-code token is valid in link text, but never in an
// href: restoring a <code> tag inside an attribute would be unsafe.
const safe = safeScheme && (!protectedHrefToken || !u.includes(protectedHrefToken));
return safe
? `<a href="${u}" class="text-honey underline" target="_blank" rel="noopener noreferrer">${label}</a>`
: `${label} (${u})`;
});
}
/** Inline rules over ALREADY-ESCAPED text, with code isolated first. */
function applyInline(escaped: string): string {
let placeholderPrefix = '\uE000WAGGLE_CODE_';
while (escaped.includes(placeholderPrefix)) placeholderPrefix = `\uE000${placeholderPrefix}`;
const codeSegments: string[] = [];
const codePattern = /`([^`\n]+)`/g;
const protectedText = escaped.replace(codePattern, (_match, code: string) => {
const index = codeSegments.push(code) - 1;
return `${placeholderPrefix}${index}\uE001`;
});
let rendered = applyStyledText(protectedText, placeholderPrefix);
for (let index = 0; index < codeSegments.length; index++) {
const token = `${placeholderPrefix}${index}\uE001`;
const code = `<code class="px-1 py-0.5 rounded bg-muted text-xs font-mono">${codeSegments[index]}</code>`;
rendered = rendered.split(token).join(code);
}
return rendered;
}
function renderCodeBlock(lines: string[], language: string): string {
const languageAttribute = language ? ` data-language="${language}"` : '';
return `<pre class="my-2 max-w-full overflow-x-auto rounded-lg bg-muted p-3 text-xs leading-relaxed"><code class="font-mono whitespace-pre"${languageAttribute}>${lines.join('\n')}</code></pre>`;
}
export function renderSimpleMarkdown(text: string): string {
return applyInline(escapeHtml(text)).replace(/\n/g, '<br />');
}
@@ -51,7 +78,25 @@ export function renderSimpleMarkdown(text: string): string {
export function renderChatMarkdown(text: string): string {
const lines = escapeHtml(text).split('\n');
const out: string[] = [];
let fence: { language: string; lines: string[] } | null = null;
for (const line of lines) {
if (fence) {
if (/^\s*```\s*$/.test(line)) {
out.push(renderCodeBlock(fence.lines, fence.language));
fence = null;
} else {
fence.lines.push(line);
}
continue;
}
const fenceStart = /^\s*```\s*([A-Za-z0-9_+-]*)\s*$/.exec(line);
if (fenceStart) {
fence = { language: fenceStart[1], lines: [] };
continue;
}
const h3 = /^###\s+(.*)$/.exec(line);
const h2 = /^##\s+(.*)$/.exec(line);
const h1 = /^#\s+(.*)$/.exec(line);
@@ -75,5 +120,10 @@ export function renderChatMarkdown(text: string): string {
out.push(`<span class="block">${applyInline(line)}</span>`);
}
}
// During streaming, an opening fence may arrive before its closing marker.
// Render the partial body as code now; the next full-source render will close it.
if (fence) out.push(renderCodeBlock(fence.lines, fence.language));
return out.join('');
}

View File

@@ -31,6 +31,8 @@ import {
isFirstLaunch,
markFirstLaunchComplete,
resetFirstLaunch,
ensureDesktopService,
listenDesktopServiceLifecycle,
} from './tauri-bindings';
const mockedInvoke = vi.mocked(invoke);
@@ -51,6 +53,102 @@ describe('isTauri() runtime detection', () => {
});
});
describe('managed desktop service bindings', () => {
beforeEach(() => {
mockedInvoke.mockReset();
mockedListen.mockReset();
(window as unknown as { __TAURI_INTERNALS__: unknown }).__TAURI_INTERNALS__ = {};
});
afterEach(() => {
delete (window as unknown as { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__;
});
it('returns the exact validated endpoint from ensure_service', async () => {
mockedInvoke.mockResolvedValue({ port: 49151, instanceId: 'desktop-instance-a' });
await expect(ensureDesktopService()).resolves.toEqual({
port: 49151,
instanceId: 'desktop-instance-a',
});
expect(mockedInvoke).toHaveBeenCalledWith('ensure_service');
});
it.each([
[{ port: 0, instanceId: 'desktop-instance-a' }],
[{ port: 65536, instanceId: 'desktop-instance-a' }],
[{ port: 49151.5, instanceId: 'desktop-instance-a' }],
[{ port: 49151, instanceId: ' ' }],
[{ port: 49151 }],
])('rejects malformed ensure_service endpoint %#', async (endpoint) => {
mockedInvoke.mockResolvedValue(endpoint);
await expect(ensureDesktopService()).rejects.toThrow(/invalid desktop service endpoint/);
});
it('deduplicates paired restart signals, resets on ready, rejects malformed ready, and disposes both listeners', async () => {
const handlers = new Map<string, (event: { payload: unknown }) => void>();
const unlisteners = [vi.fn(), vi.fn()];
mockedListen.mockImplementation(async (eventName, eventHandler) => {
handlers.set(String(eventName), eventHandler as (event: { payload: unknown }) => void);
return unlisteners[handlers.size - 1];
});
const onEvent = vi.fn();
const dispose = await listenDesktopServiceLifecycle(onEvent);
expect([...handlers.keys()]).toEqual([
'waggle://service-status',
'waggle://service-restart-needed',
]);
handlers.get('waggle://service-restart-needed')?.({ payload: undefined });
handlers.get('waggle://service-status')?.({ payload: { status: 'restarting' } });
expect(onEvent).toHaveBeenCalledTimes(1);
expect(onEvent).toHaveBeenLastCalledWith({ status: 'restarting' });
handlers.get('waggle://service-status')?.({
payload: {
status: 'ready',
endpoint: { port: 49151, instanceId: 'desktop-instance-a' },
},
});
expect(onEvent).toHaveBeenLastCalledWith({
status: 'ready',
endpoint: { port: 49151, instanceId: 'desktop-instance-a' },
});
handlers.get('waggle://service-restart-needed')?.({ payload: undefined });
expect(onEvent).toHaveBeenLastCalledWith({ status: 'restarting' });
expect(onEvent).toHaveBeenCalledTimes(3);
handlers.get('waggle://service-status')?.({
payload: { status: 'ready', endpoint: { port: 0, instanceId: '' } },
});
expect(onEvent).toHaveBeenLastCalledWith({
status: 'failed',
error: 'Tauri emitted an invalid desktop service endpoint',
});
dispose();
expect(unlisteners[0]).toHaveBeenCalledOnce();
expect(unlisteners[1]).toHaveBeenCalledOnce();
});
it.each([
'waggle://service-status',
'waggle://service-restart-needed',
] as const)('disposes the surviving listener when %s registration fails', async (failedEvent) => {
const registrationError = new Error(`${failedEvent} registration failed`);
const survivingUnlisten = vi.fn();
mockedListen.mockImplementation(async (eventName) => {
if (eventName === failedEvent) throw registrationError;
return survivingUnlisten;
});
await expect(listenDesktopServiceLifecycle(vi.fn())).rejects.toBe(registrationError);
expect(survivingUnlisten).toHaveBeenCalledOnce();
});
});
describe('desktop shell event bindings', () => {
beforeEach(() => {
mockedListen.mockReset();
@@ -132,6 +230,20 @@ describe('desktop shell event bindings', () => {
expect(unlisten).toHaveBeenCalled();
}
});
it('disposes fulfilled listeners when a sibling shell registration rejects', async () => {
const firstUnlisten = vi.fn();
const thirdUnlisten = vi.fn();
const registrationError = new Error('shell listener registration failed');
mockedListen
.mockResolvedValueOnce(firstUnlisten)
.mockRejectedValueOnce(registrationError)
.mockResolvedValueOnce(thirdUnlisten);
await expect(listenDesktopShellEvents(vi.fn())).rejects.toBe(registrationError);
expect(firstUnlisten).toHaveBeenCalledOnce();
expect(thirdUnlisten).toHaveBeenCalledOnce();
});
});
describe('memory + identity bindings', () => {

View File

@@ -313,6 +313,90 @@ export function resetFirstLaunch(): Promise<void> {
// Desktop shell events
export interface DesktopServiceEndpoint {
port: number;
instanceId: string;
/** Per-launch secret delivered only through Tauri IPC. */
bootstrapToken?: string;
}
export type DesktopServiceLifecycleEvent =
| { status: 'restarting' }
| { status: 'ready'; endpoint: DesktopServiceEndpoint }
| { status: 'failed'; error?: string };
function parseDesktopServiceEndpoint(value: unknown): DesktopServiceEndpoint | null {
const record = recordPayload(value);
const port = record?.port;
const instanceId = record?.instanceId;
const bootstrapToken = record?.bootstrapToken;
return Number.isInteger(port) && (port as number) > 0 && (port as number) <= 65535
&& typeof instanceId === 'string' && instanceId.trim().length > 0
? {
port: port as number,
instanceId,
...(typeof bootstrapToken === 'string'
&& bootstrapToken.length >= 32
&& bootstrapToken.length <= 200
? { bootstrapToken }
: {}),
}
: null;
}
export async function ensureDesktopService(): Promise<DesktopServiceEndpoint> {
if (!isTauri()) throw new Error('Desktop service IPC is unavailable outside Tauri');
const endpoint = parseDesktopServiceEndpoint(await invoke<unknown>('ensure_service'));
if (!endpoint) throw new Error('Tauri returned an invalid desktop service endpoint');
return endpoint;
}
export async function listenDesktopServiceLifecycle(
onEvent: (event: DesktopServiceLifecycleEvent) => void,
): Promise<UnlistenFn> {
let restartPending = false;
const emitRestarting = () => {
if (restartPending) return;
restartPending = true;
onEvent({ status: 'restarting' });
};
const statusListener = listen<unknown>('waggle://service-status', (event) => {
const payload = recordPayload(event.payload);
if (payload?.status === 'restarting') {
emitRestarting();
} else if (payload?.status === 'failed') {
restartPending = false;
onEvent({ status: 'failed' });
} else if (payload?.status === 'ready') {
restartPending = false;
const endpoint = parseDesktopServiceEndpoint(payload.endpoint);
onEvent(endpoint
? { status: 'ready', endpoint }
: { status: 'failed', error: 'Tauri emitted an invalid desktop service endpoint' });
}
});
const restartListener = listen<unknown>('waggle://service-restart-needed', () => {
emitRestarting();
});
const [statusResult, restartResult] = await Promise.allSettled([
statusListener,
restartListener,
]);
if (statusResult.status === 'rejected') {
if (restartResult.status === 'fulfilled') restartResult.value();
throw statusResult.reason;
}
if (restartResult.status === 'rejected') {
statusResult.value();
throw restartResult.reason;
}
return () => {
statusResult.value();
restartResult.value();
};
}
export type DesktopNavigationPath = '/settings';
const DESKTOP_NAVIGATION_PATHS = new Set<DesktopNavigationPath>(['/settings']);
@@ -402,7 +486,7 @@ export function describeDesktopShellNotice(
export async function listenDesktopShellEvents(
onNotice: (notice: DesktopShellNotice) => void,
): Promise<UnlistenFn> {
const unlisteners = await Promise.all(
const listenerResults = await Promise.allSettled(
DESKTOP_SHELL_EVENTS.map((eventName) =>
listen<unknown>(eventName, (event) => {
const notice = describeDesktopShellNotice(eventName, event.payload);
@@ -412,6 +496,21 @@ export async function listenDesktopShellEvents(
}),
),
);
const unlisteners: UnlistenFn[] = [];
let registrationError: PromiseRejectedResult | undefined;
for (const result of listenerResults) {
if (result.status === 'fulfilled') {
unlisteners.push(result.value);
} else if (!registrationError) {
registrationError = result;
}
}
if (registrationError) {
for (const unlisten of unlisteners) {
unlisten();
}
throw registrationError.reason;
}
return () => {
for (const unlisten of unlisteners) {

View File

@@ -435,6 +435,8 @@ export interface ChatMessage {
feedback?: 'up' | 'down' | null;
pinned?: boolean;
persona?: string;
/** The model that actually produced this turn, as reported by the server. */
model?: string;
/**
* Lane C (Pillar 2.2/2.5): an optimistic user turn that was typed+sent while a
* previous reply was still streaming. It renders immediately with a truthful
@@ -442,6 +444,18 @@ export interface ChatMessage {
* never errors, never drops. Cleared to `false`/absent once dispatched.
*/
queued?: boolean;
/**
* Non-authoritative streaming preview. Draft text is display-only: it must
* never feed copy/pin/feedback, conversation context, or the settled cache.
* `done.content` is authoritative; legacy token streams that omit it settle
* from their accumulated token preview for backward compatibility.
*/
draft?: {
turnId: string;
revision: number;
content: string;
status: 'streaming' | 'stopped';
};
}
export interface ToolExecution {
@@ -724,7 +738,7 @@ export interface SystemHealth {
// `@waggle/shared` instead (richer status union incl. 'expired', category, authType).
export interface StreamEvent {
type: 'token' | 'step' | 'tool_start' | 'tool_end' | 'done' | 'error' | 'approval_request' | 'approval_required' | 'model_switch' | 'notification';
type: 'token' | 'draft_update' | 'step' | 'tool_start' | 'tool_end' | 'done' | 'error' | 'approval_request' | 'approval_required' | 'model_switch' | 'notification';
data: unknown;
}

113
apps/web/src/main.test.ts Normal file
View File

@@ -0,0 +1,113 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const reactMocks = vi.hoisted(() => ({
applyStoredThemeEarly: vi.fn(),
createRoot: vi.fn(() => ({ render: vi.fn() })),
flushSync: vi.fn((callback: () => void) => callback()),
}));
const bootMocks = vi.hoisted(() => ({
armBootConnection: vi.fn(),
}));
vi.mock('./boot-connect', () => ({
armBootConnection: bootMocks.armBootConnection,
}));
vi.mock('react-dom/client', () => ({
createRoot: reactMocks.createRoot,
}));
vi.mock('react-dom', () => ({
flushSync: reactMocks.flushSync,
}));
vi.mock('./App.tsx', () => ({
default: () => null,
}));
vi.mock('@/providers/ThemeProvider', () => ({
applyStoredThemeEarly: reactMocks.applyStoredThemeEarly,
}));
vi.mock('@/lib/posthog', () => ({
initPostHog: vi.fn(),
}));
describe('desktop startup surface', () => {
beforeEach(() => {
vi.resetModules();
bootMocks.armBootConnection.mockReset();
bootMocks.armBootConnection.mockResolvedValue(undefined);
document.body.innerHTML = '<div id="root"></div>';
vi.spyOn(console, 'error').mockImplementation(() => {});
});
it('keeps the startup surface mounted until the managed desktop service is ready', async () => {
let releaseService!: () => void;
const serviceReady = new Promise<void>((resolve) => { releaseService = resolve; });
bootMocks.armBootConnection.mockReturnValue(serviceReady);
let markAppEntryImported!: () => void;
const appEntryImported = new Promise<void>((resolve) => { markAppEntryImported = resolve; });
const mountApp = vi.fn();
vi.doMock('./app-entry', () => {
markAppEntryImported();
return { mountApp };
});
await import('./main');
const earlyImport = await Promise.race([
appEntryImported.then(() => true),
new Promise<false>((resolve) => setTimeout(() => resolve(false), 50)),
]);
expect(earlyImport).toBe(false);
expect(mountApp).not.toHaveBeenCalled();
expect(document.querySelector('[data-waggle-startup="loading"]')).not.toBeNull();
releaseService();
await vi.waitFor(() => expect(mountApp).toHaveBeenCalledOnce());
});
it('shows a visible alert when the application bundle cannot mount', async () => {
vi.doMock('./app-entry', () => ({
mountApp: vi.fn(() => {
throw new Error('simulated app bundle failure');
}),
}));
await import('./main');
await vi.waitFor(() => {
const alert = document.querySelector<HTMLElement>('[data-waggle-startup="failed"]');
expect(alert?.getAttribute('role')).toBe('alert');
expect(alert?.textContent).toContain('Waggle could not start');
});
});
it('keeps the app graph unloaded and shows a visible alert when service startup fails', async () => {
const mountApp = vi.fn();
bootMocks.armBootConnection.mockRejectedValue(new Error('managed service failed'));
vi.doMock('./app-entry', () => ({ mountApp }));
await import('./main');
await vi.waitFor(() => {
const alert = document.querySelector<HTMLElement>('[data-waggle-startup="failed"]');
expect(alert?.getAttribute('role')).toBe('alert');
expect(alert?.textContent).toContain('Waggle could not start');
});
expect(mountApp).not.toHaveBeenCalled();
});
it('marks the desktop shell ready only after the synchronous app mount', async () => {
vi.doUnmock('./app-entry');
await import('./main');
await vi.waitFor(() => {
const root = document.getElementById('root');
expect(reactMocks.applyStoredThemeEarly).toHaveBeenCalledOnce();
expect(reactMocks.createRoot).toHaveBeenCalledWith(root);
expect(root?.dataset.waggleUiReady).toBe('ready');
});
});
});

View File

@@ -1,19 +1,39 @@
// P1b D3: must be the FIRST import — arms the adapter's request-deferral gate
// before any other module in the import graph can evaluate (see boot-connect.ts).
import "./boot-connect";
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
import { applyStoredThemeEarly } from "@/providers/ThemeProvider";
import { armBootConnection } from './boot-connect';
// Apply the persisted theme before first paint to avoid a flash of the wrong
// theme (warm graphite/dark default; warm paper for light).
applyStoredThemeEarly();
const root = document.getElementById('root');
if (!root) throw new Error('Waggle root element is missing');
createRoot(document.getElementById("root")!).render(<App />);
const startup = document.createElement('main');
startup.setAttribute('role', 'status');
startup.setAttribute('aria-live', 'polite');
startup.dataset.waggleStartup = 'loading';
Object.assign(startup.style, {
alignItems: 'center',
color: '#f6f1e4',
display: 'flex',
fontFamily: 'system-ui, sans-serif',
fontSize: '16px',
justifyContent: 'center',
minHeight: '100vh',
});
// Initialize PostHog cloud analytics (DAY0-04).
// Non-blocking and lazy-loaded so analytics never bloats the startup bundle.
void import("@/lib/posthog")
.then(({ initPostHog }) => initPostHog())
.catch(() => {});
const startupMessage = document.createElement('p');
startupMessage.textContent = 'Starting Waggle…';
startup.append(startupMessage);
root.replaceChildren(startup);
const showStartupFailure = (error: unknown) => {
console.error('[waggle] UI startup failed', error);
startup.setAttribute('role', 'alert');
startup.dataset.waggleStartup = 'failed';
startupMessage.textContent = 'Waggle could not start. Close and reopen the app.';
};
try {
void armBootConnection()
.then(() => import('./app-entry'))
.then(({ mountApp }) => mountApp())
.catch((error) => showStartupFailure(error));
} catch (error) {
showStartupFailure(error);
}

View File

@@ -44,6 +44,13 @@ export interface InstallStore {
uninstall: (target: InstallTarget) => Promise<InstallOutcome>;
/** Re-read server truth (initial, on connect-settled, and on nav per D4). */
hydrate: () => Promise<void>;
/** Confirm a server-held marketplace proposal through the shared transaction state. */
confirmPackageProposal: (
packageId: number,
proposalId: string,
workspaceId: string,
sessionId: string,
) => Promise<void>;
}
const InstallContext = createContext<InstallStore | null>(null);
@@ -94,6 +101,27 @@ export const InstallProvider = ({ children }: { children: ReactNode }) => {
hydrateSeq.current += 1;
setInstalled(prev => { const n = new Set(prev); n.add(id); return n; });
}, []);
const confirmPackageProposal = useCallback(async (
packageId: number,
proposalId: string,
workspaceId: string,
sessionId: string,
) => {
const id = `pkg:${packageId}`;
addInstalling(id);
try {
await adapter.fetch(
`/api/capability-proposals/${encodeURIComponent(proposalId)}/confirm`,
{
method: 'POST',
body: JSON.stringify({ workspaceId, sessionId }),
},
);
markInstalled(id);
} finally {
clearInstalling(id);
}
}, [addInstalling, clearInstalling, markInstalled]);
const markUninstalled = useCallback((id: string) => {
hydrateSeq.current += 1;
setInstalled(prev => { const n = new Set(prev); n.delete(id); return n; });
@@ -296,7 +324,8 @@ export const InstallProvider = ({ children }: { children: ReactNode }) => {
install,
uninstall,
hydrate,
}), [installed, installing, hydrating, install, uninstall, hydrate]);
confirmPackageProposal,
}), [installed, installing, hydrating, install, uninstall, hydrate, confirmPackageProposal]);
return <InstallContext.Provider value={value}>{children}</InstallContext.Provider>;
};

View File

@@ -46,10 +46,23 @@ describe('build warning hygiene', () => {
}
});
it('keeps cloud analytics out of the startup bundle', () => {
it('arms the desktop gate before loading the app graph and keeps analytics lazy', () => {
const mainSource = readFileSync(join(sourceRoot, 'main.tsx'), 'utf8');
const appEntrySource = readFileSync(join(sourceRoot, 'app-entry.tsx'), 'utf8');
expect(mainSource).not.toContain('from "@/lib/posthog"');
expect(mainSource).toContain('import("@/lib/posthog")');
const armIndex = mainSource.indexOf('armBootConnection()');
const appImportIndex = mainSource.indexOf("import('./app-entry')");
expect(armIndex).toBeGreaterThanOrEqual(0);
expect(appImportIndex).toBeGreaterThan(armIndex);
expect(mainSource).toMatch(
/^import\s+\{\s*armBootConnection\s*\}\s+from\s+['"]\.\/boot-connect['"];\s*/,
);
expect(mainSource).not.toMatch(/from ['"].*App(?:\.tsx)?['"]/);
expect(mainSource).not.toMatch(/from ['"]\.\/app-entry['"]/);
expect(mainSource).not.toMatch(/posthog/i);
expect(mainSource).not.toMatch(/\bawait\b/);
expect(appEntrySource).not.toMatch(/^\s*import(?!\s*\()[^;\n]*['"]@\/lib\/posthog['"]/m);
expect(appEntrySource).toMatch(/import\(\s*['"]@\/lib\/posthog['"]\s*\)/);
});
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -41,6 +41,11 @@ describe('LauncherApp accessibility metadata', () => {
expect(prompt).toHaveAttribute('autocomplete', 'off');
expect(prompt.className).toContain('focus-visible:ring-2');
const scrollViewport = document.querySelector('[data-radix-scroll-area-viewport]');
expect(scrollViewport).not.toBeNull();
expect(scrollViewport).toHaveAttribute('tabindex', '0');
expect(scrollViewport).toHaveClass('focus-visible:outline-offset-[-2px]');
await waitFor(() => expect(mocks.adapter.detectTools).toHaveBeenCalled());
});
});

View File

@@ -17,10 +17,12 @@ import { renderHook, act, waitFor, cleanup } from '@testing-library/react';
import { CONNECT_SETTLED_EVENT } from '@/hooks/useRevalidateOnError';
const mocks = vi.hoisted(() => ({
toast: vi.fn(),
adapter: {
connect: vi.fn().mockResolvedValue(undefined),
getTier: vi.fn(),
getWorkspaces: vi.fn(),
createWorkspace: vi.fn(),
getPermissions: vi.fn().mockResolvedValue({ defaultAutonomy: 'normal', externalGates: {} }),
getAgentStatus: vi.fn().mockResolvedValue({ active: 0, agents: [] }),
getNotificationHistory: vi.fn().mockResolvedValue([]),
@@ -49,6 +51,10 @@ const mocks = vi.hoisted(() => ({
},
}));
vi.mock('@/lib/adapter', () => ({ adapter: mocks.adapter, default: vi.fn() }));
vi.mock('@/hooks/use-toast', () => ({
toast: mocks.toast,
useToast: () => ({ toast: mocks.toast, toasts: [], dismiss: vi.fn() }),
}));
/** AdapterHttpError stand-in — the real class is mocked away with the module,
* so consumers must duck-type on error.name (that is part of the contract). */
@@ -84,6 +90,45 @@ describe('useWorkspaces (P1b)', () => {
await waitFor(() => expect(result.current.workspaces).toHaveLength(2));
expect(result.current.error).toBeNull();
});
it('failed create does not invent or activate a phantom workspace', async () => {
window.localStorage.clear();
const { useWorkspaces } = await import('@/hooks/useWorkspaces');
const { readPersistedWorkspaceId } = await import('@/lib/workspace-selection');
mocks.adapter.getWorkspaces.mockResolvedValue([{ id: 'w1', name: 'Alpha', group: 'Personal' }]);
mocks.adapter.createWorkspace.mockRejectedValue(new Error('Local storage path is invalid'));
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const { result } = renderHook(() => useWorkspaces());
try {
await waitFor(() => expect(result.current.workspaces).toHaveLength(1));
act(() => result.current.selectWorkspace('w1'));
expect(readPersistedWorkspaceId()).toBe('w1');
let created: Awaited<ReturnType<typeof result.current.createWorkspace>> | undefined;
await act(async () => {
created = await result.current.createWorkspace({
name: 'Broken Linked Workspace',
group: 'Personal',
storageType: 'local',
storagePath: 'Z:\\missing-workspace',
});
});
expect(created).toBeNull();
expect(result.current.workspaces.map(workspace => workspace.id)).toEqual(['w1']);
expect(result.current.activeWorkspaceId).toBe('w1');
expect(readPersistedWorkspaceId()).toBe('w1');
expect(result.current.error).toBe('Local storage path is invalid');
expect(mocks.toast).toHaveBeenCalledWith({
title: "Couldn't create workspace",
description: 'Local storage path is invalid',
variant: 'destructive',
});
} finally {
consoleSpy.mockRestore();
}
});
});
// ── useBilling ─────────────────────────────────────────────────────────────

View File

@@ -6,7 +6,7 @@
* audit history drawer.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
import { render, screen, fireEvent, cleanup, waitFor, act } from '@testing-library/react';
import { TooltipProvider } from '@/components/ui/tooltip';
const mocks = vi.hoisted(() => ({
@@ -22,8 +22,10 @@ const mocks = vi.hoisted(() => ({
getExtendAudit: vi.fn(),
fetch: vi.fn(),
},
toast: vi.fn(),
}));
vi.mock('@/lib/adapter', () => ({ adapter: mocks.adapter, default: vi.fn() }));
vi.mock('@/hooks/use-toast', () => ({ useToast: () => ({ toast: mocks.toast }) }));
import ConnectorsApp, { buildRevokeRequest, resetConnectorsRouteCache, shouldResetCredentialInputs } from '@/components/os/apps/ConnectorsApp';
import { ServiceProvider } from '@/providers/ServiceProvider';
@@ -53,6 +55,12 @@ const JIRA_CONNECTOR = {
substrate: 'waggle', tools: [], category: 'productivity',
};
const SALESFORCE_CONNECTOR = {
id: 'salesforce', name: 'Salesforce', description: 'CRM', service: 'salesforce',
authType: 'bearer', status: 'disconnected', capabilities: ['read', 'write'],
substrate: 'waggle', tools: [], category: 'crm',
};
const renderApp = () => render(
<ServiceProvider><TooltipProvider><ConnectorsApp /></TooltipProvider></ServiceProvider>,
);
@@ -211,6 +219,14 @@ describe('ConnectorsApp — Connector Hub (S07)', () => {
expect(email).toHaveAttribute('spellcheck', 'false');
expect(email.className).toContain('focus-visible:ring-2');
const siteUrl = screen.getByRole('textbox', { name: /jira site url/i });
expect(siteUrl).toHaveAttribute('type', 'url');
expect(siteUrl).toHaveAttribute('name', 'connectorBaseUrl');
expect(siteUrl).toHaveAttribute('autocomplete', 'url');
expect(siteUrl).toHaveAttribute('spellcheck', 'false');
expect(siteUrl).toHaveAttribute('placeholder', 'https://your-team.atlassian.net');
expect(siteUrl.className).toContain('focus-visible:ring-2');
const token = screen.getByLabelText(/jira api token/i);
expect(token).toHaveAttribute('type', 'password');
expect(token).toHaveAttribute('name', 'connectorToken');
@@ -220,6 +236,215 @@ describe('ConnectorsApp — Connector Hub (S07)', () => {
expect(screen.getByRole('button', { name: /^connect$/i }).className).toContain('focus-visible:ring-2');
});
it('submits ordinary connector credentials through the connect endpoint only', async () => {
renderApp();
fireEvent.click(await screen.findByText('Slack'));
const connect = screen.getByRole('button', { name: /^connect$/i });
expect(connect).toBeDisabled();
fireEvent.change(screen.getByLabelText(/slack api token/i), {
target: { value: ' xoxb-connector-token ' },
});
expect(connect).toBeEnabled();
fireEvent.click(connect);
await waitFor(() => expect(mocks.adapter.connectConnector).toHaveBeenCalledWith('slack', {
token: 'xoxb-connector-token',
}));
expect(mocks.adapter.addVaultSecret).not.toHaveBeenCalled();
});
it('requires Jira email and site URL, then sends a trimmed credential tuple through connectConnector', async () => {
mocks.adapter.getConnectors.mockResolvedValue([...CONNECTORS, JIRA_CONNECTOR]);
renderApp();
fireEvent.click(await screen.findByText('Jira'));
const connect = screen.getByRole('button', { name: /^connect$/i });
fireEvent.change(screen.getByLabelText(/jira api token/i), {
target: { value: ' jira-token ' },
});
expect(connect).toBeDisabled();
fireEvent.change(screen.getByRole('textbox', { name: /atlassian account email/i }), {
target: { value: ' owner@example.com ' },
});
expect(connect).toBeDisabled();
fireEvent.change(screen.getByRole('textbox', { name: /jira site url/i }), {
target: { value: ' https://team.atlassian.net ' },
});
expect(connect).toBeEnabled();
fireEvent.click(connect);
await waitFor(() => expect(mocks.adapter.connectConnector).toHaveBeenCalledWith('jira', {
token: 'jira-token',
email: 'owner@example.com',
baseUrl: 'https://team.atlassian.net',
}));
expect(mocks.adapter.addVaultSecret).not.toHaveBeenCalled();
fireEvent.click(screen.getByText('Jira'));
expect(screen.getByLabelText(/jira api token/i)).toHaveValue('');
expect(screen.getByRole('textbox', { name: /atlassian account email/i })).toHaveValue('');
expect(screen.getByRole('textbox', { name: /jira site url/i })).toHaveValue('');
});
it('requires an accessible Salesforce instance URL and submits it with the token', async () => {
mocks.adapter.getConnectors.mockResolvedValue([...CONNECTORS, SALESFORCE_CONNECTOR]);
renderApp();
fireEvent.click(await screen.findByText('Salesforce'));
const instanceUrl = screen.getByRole('textbox', { name: /salesforce instance url/i });
expect(instanceUrl).toHaveAttribute('type', 'url');
expect(instanceUrl).toHaveAttribute('name', 'connectorInstanceUrl');
expect(instanceUrl).toHaveAttribute('autocomplete', 'url');
expect(instanceUrl).toHaveAttribute('spellcheck', 'false');
const connect = screen.getByRole('button', { name: /^connect$/i });
fireEvent.change(screen.getByLabelText(/salesforce api token/i), {
target: { value: ' salesforce-token ' },
});
expect(connect).toBeDisabled();
fireEvent.change(instanceUrl, {
target: { value: ' https://acme.my.salesforce.com ' },
});
expect(connect).toBeEnabled();
fireEvent.click(connect);
await waitFor(() => expect(mocks.adapter.connectConnector).toHaveBeenCalledWith('salesforce', {
token: 'salesforce-token',
instanceUrl: 'https://acme.my.salesforce.com',
}));
expect(mocks.adapter.addVaultSecret).not.toHaveBeenCalled();
});
it('preserves entered credentials and exposes the server error when connect is rejected', async () => {
mocks.adapter.getConnectors.mockResolvedValue([...CONNECTORS, SALESFORCE_CONNECTOR]);
mocks.adapter.connectConnector.mockRejectedValueOnce(new Error('Valid Salesforce instanceUrl required'));
renderApp();
fireEvent.click(await screen.findByText('Salesforce'));
const token = screen.getByLabelText(/salesforce api token/i);
const instanceUrl = screen.getByRole('textbox', { name: /salesforce instance url/i });
fireEvent.change(token, { target: { value: 'salesforce-token' } });
fireEvent.change(instanceUrl, { target: { value: 'https://acme.my.salesforce.com' } });
fireEvent.click(screen.getByRole('button', { name: /^connect$/i }));
await waitFor(() => expect(mocks.toast).toHaveBeenCalledWith({
title: 'Connection failed',
description: 'Valid Salesforce instanceUrl required',
variant: 'destructive',
}));
expect(token).toHaveValue('salesforce-token');
expect(instanceUrl).toHaveValue('https://acme.my.salesforce.com');
expect(screen.getByRole('button', { name: /^connect$/i })).toBeEnabled();
});
it('preserves the full Jira tuple and exposes an invalid-site server rejection for retry', async () => {
mocks.adapter.getConnectors.mockResolvedValue([...CONNECTORS, JIRA_CONNECTOR]);
mocks.adapter.connectConnector.mockRejectedValueOnce(new Error('Valid Jira baseUrl required'));
renderApp();
fireEvent.click(await screen.findByText('Jira'));
const token = screen.getByLabelText(/jira api token/i);
const email = screen.getByRole('textbox', { name: /atlassian account email/i });
const siteUrl = screen.getByRole('textbox', { name: /jira site url/i });
fireEvent.change(token, { target: { value: 'jira-token' } });
fireEvent.change(email, { target: { value: 'owner@example.com' } });
fireEvent.change(siteUrl, { target: { value: 'https://jira.example.com' } });
fireEvent.click(screen.getByRole('button', { name: /^connect$/i }));
await waitFor(() => expect(mocks.toast).toHaveBeenCalledWith({
title: 'Connection failed',
description: 'Valid Jira baseUrl required',
variant: 'destructive',
}));
expect(token).toHaveValue('jira-token');
expect(email).toHaveValue('owner@example.com');
expect(siteUrl).toHaveValue('https://jira.example.com');
expect(screen.getByRole('button', { name: /^connect$/i })).toBeEnabled();
});
it('locks connector switching while Jira connect is pending, then restores the retry tuple on rejection', async () => {
mocks.adapter.getConnectors.mockResolvedValue([
...CONNECTORS,
JIRA_CONNECTOR,
SALESFORCE_CONNECTOR,
]);
let rejectConnection!: (reason: Error) => void;
mocks.adapter.connectConnector.mockImplementationOnce(() => new Promise<void>((_resolve, reject) => {
rejectConnection = reject;
}));
renderApp();
fireEvent.click(await screen.findByText('Jira'));
const jiraRow = screen.getByText('Jira').closest('button')!;
const salesforceRow = screen.getByText('Salesforce').closest('button')!;
const token = screen.getByLabelText(/jira api token/i);
const email = screen.getByRole('textbox', { name: /atlassian account email/i });
const siteUrl = screen.getByRole('textbox', { name: /jira site url/i });
fireEvent.change(token, { target: { value: 'jira-token' } });
fireEvent.change(email, { target: { value: 'owner@example.com' } });
fireEvent.change(siteUrl, { target: { value: 'https://team.atlassian.net' } });
fireEvent.click(screen.getByRole('button', { name: /^connect$/i }));
await waitFor(() => expect(mocks.adapter.connectConnector).toHaveBeenCalledTimes(1));
expect(jiraRow).toBeDisabled();
expect(salesforceRow).toBeDisabled();
expect(token).toBeDisabled();
expect(email).toBeDisabled();
expect(siteUrl).toBeDisabled();
fireEvent.click(salesforceRow);
expect(screen.queryByLabelText(/salesforce api token/i)).not.toBeInTheDocument();
await act(async () => {
rejectConnection(new Error('Valid Jira baseUrl required'));
});
await waitFor(() => expect(mocks.toast).toHaveBeenCalledWith({
title: 'Connection failed',
description: 'Valid Jira baseUrl required',
variant: 'destructive',
}));
expect(token).toHaveValue('jira-token');
expect(email).toHaveValue('owner@example.com');
expect(siteUrl).toHaveValue('https://team.atlassian.net');
expect(jiraRow).toBeEnabled();
expect(salesforceRow).toBeEnabled();
expect(screen.getByRole('button', { name: /^connect$/i })).toBeEnabled();
});
it('clears token, email, and instance URL whenever the target connector changes', async () => {
mocks.adapter.getConnectors.mockResolvedValue([
...CONNECTORS,
JIRA_CONNECTOR,
SALESFORCE_CONNECTOR,
]);
renderApp();
fireEvent.click(await screen.findByText('Jira'));
fireEvent.change(screen.getByLabelText(/jira api token/i), { target: { value: 'jira-token' } });
fireEvent.change(screen.getByRole('textbox', { name: /atlassian account email/i }), {
target: { value: 'owner@example.com' },
});
fireEvent.change(screen.getByRole('textbox', { name: /jira site url/i }), {
target: { value: 'https://team.atlassian.net' },
});
fireEvent.click(screen.getByText('Salesforce'));
expect(screen.getByLabelText(/salesforce api token/i)).toHaveValue('');
const instanceUrl = screen.getByRole('textbox', { name: /salesforce instance url/i });
expect(instanceUrl).toHaveValue('');
fireEvent.change(screen.getByLabelText(/salesforce api token/i), { target: { value: 'sf-token' } });
fireEvent.change(instanceUrl, { target: { value: 'https://acme.my.salesforce.com' } });
fireEvent.click(screen.getByText('Jira'));
expect(screen.getByLabelText(/jira api token/i)).toHaveValue('');
expect(screen.getByRole('textbox', { name: /atlassian account email/i })).toHaveValue('');
expect(screen.getByRole('textbox', { name: /jira site url/i })).toHaveValue('');
fireEvent.click(screen.getByText('Salesforce'));
expect(screen.getByLabelText(/salesforce api token/i)).toHaveValue('');
expect(screen.getByRole('textbox', { name: /salesforce instance url/i })).toHaveValue('');
});
});
describe('pure helpers', () => {

View File

@@ -1,21 +1,23 @@
/**
* PR4 Phase D — the inline capability card (Variation B). Each kind routes
* through the shared install store (so a chat install reflects in the grid +
* count bar): connector token-paste / OAuth→Hub, mcp enable, marketplace
* resolve-then-install, starter via installPack.
* through a server-issued, scoped proposal for marketplace packages and the
* bundled install path for starter packs.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
import { act, render, renderHook, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
import type { ReactNode } from 'react';
import type { CapabilityRequest } from '@/components/os/apps/chat-blocks/CapabilityRequestCard';
import type { ContentBlock } from '@/lib/types';
const mocks = vi.hoisted(() => ({
adapter: {
getHistory: vi.fn().mockResolvedValue([]),
connect: vi.fn().mockResolvedValue(undefined),
forceReconnect: vi.fn().mockResolvedValue(undefined),
getConnectors: vi.fn().mockResolvedValue([]),
getMcps: vi.fn().mockResolvedValue([]),
getMarketplace: vi.fn().mockResolvedValue({ packages: [] }),
fetch: vi.fn().mockResolvedValue(new Response('{}', { status: 200 })),
searchMarketplace: vi.fn(),
installMarketplacePackage: vi.fn().mockResolvedValue(new Response('{}', { status: 200 })),
uninstallMarketplacePackage: vi.fn().mockResolvedValue(new Response('{}', { status: 200 })),
@@ -27,25 +29,76 @@ const mocks = vi.hoisted(() => ({
},
toast: vi.fn(),
}));
vi.mock('@/lib/adapter', () => ({ adapter: mocks.adapter, default: vi.fn() }));
vi.mock('@/lib/adapter', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/adapter')>();
return { ...actual, adapter: mocks.adapter, default: vi.fn() };
});
vi.mock('@/hooks/use-toast', () => ({ useToast: () => ({ toast: mocks.toast }) }));
import { ServiceProvider } from '@/providers/ServiceProvider';
import { InstallProvider } from '@/providers/InstallProvider';
import { InstallProvider, useInstallStore } from '@/providers/InstallProvider';
import { AdapterHttpError } from '@/lib/adapter';
import CapabilityRequestCard from '@/components/os/apps/chat-blocks/CapabilityRequestCard';
import BlockRenderer from '@/components/os/apps/chat-blocks/BlockRenderer';
const InstalledCountProbe = () => {
const { installedCount } = useInstallStore();
return <span data-testid="installed-count">{installedCount}</span>;
};
const wrapper = ({ children }: { children: ReactNode }) => (
<ServiceProvider><InstallProvider>{children}</InstallProvider></ServiceProvider>
);
const renderCard = (request: CapabilityRequest) => render(<CapabilityRequestCard request={request} />, { wrapper });
const DEFAULT_CONTEXT = { workspaceId: 'workspace-a', sessionId: 'session-a' };
const PROPOSAL_ID = '123e4567-e89b-42d3-a456-426614174000';
const marketplaceRequest = (overrides: Partial<CapabilityRequest> = {}): CapabilityRequest => ({
name: 'web-scraper',
source: 'marketplace',
kind: 'marketplace',
proposalId: PROPOSAL_ID,
expiresAt: '2999-01-01T00:00:00.000Z',
packageId: 7,
sourceId: 2,
publisher: 'Waggle Labs',
version: '1.2.3',
installType: 'skill',
manifestDigest: `sha256:${'a'.repeat(64)}`,
riskStatus: 'CLEAN',
riskScore: 100,
riskContentHash: 'b'.repeat(64),
riskBlocked: false,
riskDigest: `sha256:${'c'.repeat(64)}`,
...overrides,
});
const marketplaceMarker = (overrides: Partial<CapabilityRequest> = {}) =>
`<!--waggle:capability_request ${JSON.stringify(marketplaceRequest(overrides))}-->`;
const renderCard = (
request: CapabilityRequest,
context: { workspaceId?: string | null; sessionId?: string | null } = DEFAULT_CONTEXT,
) => render(
<>
<CapabilityRequestCard request={request} {...context} />
<InstalledCountProbe />
</>,
{ wrapper },
);
const renderBlocks = (
blocks: ContentBlock[],
context: { workspaceId?: string | null; sessionId?: string | null } = DEFAULT_CONTEXT,
) => render(<BlockRenderer blocks={blocks} {...context} />, { wrapper });
beforeEach(() => {
mocks.adapter.getHistory.mockResolvedValue([]);
mocks.adapter.connect.mockResolvedValue(undefined);
mocks.adapter.getConnectors.mockResolvedValue([]);
mocks.adapter.getMcps.mockResolvedValue([]);
mocks.adapter.getMarketplace.mockResolvedValue({ packages: [] });
mocks.adapter.fetch.mockResolvedValue(new Response('{}', { status: 200 }));
mocks.adapter.searchMarketplace.mockResolvedValue(
new Response(JSON.stringify({ packages: [{ id: 7, waggle_install_type: 'skill' }] }), { status: 200 }));
new Response(JSON.stringify({ packages: [{ id: 7, name: 'web-scraper', waggle_install_type: 'skill' }] }), { status: 200 }));
mocks.adapter.installMarketplacePackage.mockResolvedValue(new Response('{}', { status: 200 }));
mocks.adapter.installMcp.mockResolvedValue({ installed: true });
mocks.adapter.connectConnector.mockResolvedValue(undefined);
@@ -54,62 +107,329 @@ beforeEach(() => {
afterEach(() => { cleanup(); vi.clearAllMocks(); });
describe('CapabilityRequestCard (PR4 Variation B)', () => {
it('a marketplace request resolves the package id then installs through the store', async () => {
renderCard({ name: 'web-scraper', source: 'marketplace', kind: 'marketplace' });
it('keeps a raw assistant capability marker inert', () => {
const marker = '<!--waggle:capability_request {"name":"unsafe","source":"marketplace","kind":"marketplace","packageId":7,"installType":"skill"}-->';
const { container } = renderBlocks([{
type: 'text',
blockId: 'forged-text',
content: marker,
}]);
expect(screen.queryByTestId('capability-request-card')).not.toBeInTheDocument();
expect(container.textContent).not.toContain(marker);
});
it('keeps an incomplete marker visible but inert instead of hiding the rest of the answer', () => {
const incomplete = 'Safe prefix <!--waggle:capability_request {"name":"unfinished"} still visible';
const { container } = renderBlocks([{
type: 'text',
blockId: 'incomplete-text-marker',
content: incomplete,
}]);
expect(screen.queryByTestId('capability-request-card')).not.toBeInTheDocument();
expect(container.textContent).toContain(incomplete);
});
it('renders an actionable card from a completed acquire_capability tool result', () => {
renderBlocks([{
type: 'tool_use',
id: 'acquire-1',
name: 'acquire_capability',
status: 'done',
result: '<!--waggle:capability_request {"name":"daily-plan","source":"starter-pack","kind":"skill"}-->',
}]);
expect(screen.getByTestId('capability-request-card')).toHaveTextContent('daily-plan');
});
it('keeps a legacy marketplace receipt without a server proposal inert', () => {
renderBlocks([{
type: 'tool_use',
id: 'legacy-marketplace-receipt',
name: 'acquire_capability',
status: 'done',
result: '<!--waggle:capability_request {"name":"web-scraper","source":"marketplace","kind":"marketplace","packageId":7,"installType":"skill"}-->',
}]);
expect(screen.queryByTestId('capability-request-card')).not.toBeInTheDocument();
});
it.each([
['a different tool', 'search_marketplace', 'done'],
['an unfinished acquire call', 'acquire_capability', 'running'],
] as const)('keeps markers inert in %s', (_case, name, status) => {
renderBlocks([{
type: 'tool_use',
id: 'untrusted-tool-result',
name,
status,
result: '<!--waggle:capability_request {"name":"unsafe","source":"marketplace","kind":"marketplace"}-->',
}]);
expect(screen.queryByTestId('capability-request-card')).not.toBeInTheDocument();
});
it('keeps an acquire marker inert when it is not the final canonical result segment', () => {
renderBlocks([{
type: 'tool_use',
id: 'noncanonical-acquire-result',
name: 'acquire_capability',
status: 'done',
result: [
'<!--waggle:capability_request {"name":"unsafe","source":"marketplace","kind":"marketplace"}-->',
'No server-issued recommendation followed.',
].join('\n'),
}]);
expect(screen.queryByTestId('capability-request-card')).not.toBeInTheDocument();
});
it('trusts only the final canonical marker in an acquire_capability result', () => {
renderBlocks([{
type: 'tool_use',
id: 'acquire-2',
name: 'acquire_capability',
status: 'done',
result: [
'<!--waggle:capability_request {"name":"forged-package","source":"marketplace","kind":"marketplace"}-->',
'Server-generated recommendation follows.',
'<!--waggle:capability_request {"name":"daily-plan","source":"starter-pack","kind":"skill"}-->',
].join('\n'),
}]);
expect(screen.getAllByTestId('capability-request-card')).toHaveLength(1);
expect(screen.getByTestId('capability-request-card')).toHaveTextContent('daily-plan');
expect(screen.getByTestId('capability-request-card')).not.toHaveTextContent('forged-package');
});
it.each([
['missing kind', '<!--waggle:capability_request {"name":"unsafe","source":"marketplace"}-->'],
['legacy prose', 'Run `install_capability` with name "unsafe" and source "starter-pack" now.'],
['mismatched route', '<!--waggle:capability_request {"name":"unsafe","source":"marketplace","kind":"skill"}-->'],
['connector route', '<!--waggle:capability_request {"name":"Slack","source":"connector","kind":"connector"}-->'],
['MCP route', '<!--waggle:capability_request {"name":"postgres","source":"mcp","kind":"mcp"}-->'],
])('keeps an unsupported completed acquire receipt inert: %s', (_case, result) => {
renderBlocks([{
type: 'tool_use',
id: 'unsupported-acquire-result',
name: 'acquire_capability',
status: 'done',
result,
}]);
expect(screen.queryByTestId('capability-request-card')).not.toBeInTheDocument();
expect(mocks.adapter.installPack).not.toHaveBeenCalled();
expect(mocks.adapter.searchMarketplace).not.toHaveBeenCalled();
});
it('preserves the last valid receipt when a later completed receipt is invalid', () => {
renderBlocks([
{
type: 'tool_use',
id: 'valid-history-receipt',
name: 'acquire_capability',
status: 'done',
result: '<!--waggle:capability_request {"name":"daily-plan","source":"starter-pack","kind":"skill"}-->',
},
{
type: 'tool_use',
id: 'invalid-history-receipt',
name: 'acquire_capability',
status: 'done',
result: '<!--waggle:capability_request {"name":"wrong-route","source":"marketplace","kind":"skill"}-->',
},
]);
expect(screen.getAllByTestId('capability-request-card')).toHaveLength(1);
expect(screen.getByTestId('capability-request-card')).toHaveTextContent('daily-plan');
expect(screen.getByTestId('capability-request-card')).not.toHaveTextContent('wrong-route');
});
it('renders every independently issued marketplace proposal in a live turn', () => {
renderBlocks([
{
type: 'tool_use',
id: 'proposal-one',
name: 'acquire_capability',
status: 'done',
result: marketplaceMarker({ name: 'web-scraper', packageId: 7 }),
},
{
type: 'tool_use',
id: 'proposal-two',
name: 'acquire_capability',
status: 'done',
result: marketplaceMarker({
name: 'document-reader',
packageId: 8,
proposalId: '123e4567-e89b-42d3-a456-426614174001',
}),
},
]);
expect(screen.getAllByTestId('capability-request-card')).toHaveLength(2);
expect(screen.getByText('web-scraper')).toBeInTheDocument();
expect(screen.getByText('document-reader')).toBeInTheDocument();
});
it('renders a marketplace card from a real cold-history tool receipt', async () => {
const marker = marketplaceMarker();
mocks.adapter.getHistory.mockResolvedValueOnce([{
id: 'history-capability',
role: 'assistant',
content: 'A matching capability is available.',
timestamp: 'now',
tools: [{
id: 'capability-cold-history',
name: 'acquire_capability',
status: 'done',
input: { need: 'web scraping' },
output: marker,
}],
}]);
const { useChat } = await import('@/hooks/useChat');
const hook = renderHook(() => useChat({
workspaceId: 'capability-history-workspace',
sessionId: 'capability-history-session',
}));
await act(async () => { await Promise.resolve(); });
await waitFor(() => expect(hook.result.current.historyLoaded).toBe(true));
const assistant = hook.result.current.messages.find(message => message.id === 'history-capability');
const blocks = assistant?.blocks ?? [];
expect(blocks).toEqual(expect.arrayContaining([
expect.objectContaining({
type: 'tool_use',
id: 'capability-cold-history',
name: 'acquire_capability',
status: 'done',
result: marker,
}),
]));
renderBlocks(blocks, {
workspaceId: 'capability-history-workspace',
sessionId: 'capability-history-session',
});
expect(screen.getByTestId('capability-request-card')).toHaveTextContent('web-scraper');
});
it.each([
[{ name: 'daily-plan', source: 'starter-pack' }],
[{ name: 'wrong-route', source: 'marketplace', kind: 'skill' }],
])('makes the card itself fail closed for an unsupported request', (request) => {
renderCard(request as CapabilityRequest);
expect(screen.queryByTestId('capability-request-card')).not.toBeInTheDocument();
});
it('confirms a marketplace request by proposal id and exact chat scope', async () => {
renderCard(marketplaceRequest({ packageId: 73, installType: 'plugin' }));
await waitFor(() => expect(mocks.adapter.getMarketplace).toHaveBeenCalledTimes(2));
await act(async () => { await Promise.resolve(); });
fireEvent.click(screen.getByTestId('capability-request-install'));
await waitFor(() => expect(mocks.adapter.searchMarketplace).toHaveBeenCalledWith('web-scraper', 1));
await waitFor(() => expect(mocks.adapter.installMarketplacePackage).toHaveBeenCalledWith(7));
await waitFor(() => expect(mocks.adapter.fetch).toHaveBeenCalledWith(
`/api/capability-proposals/${PROPOSAL_ID}/confirm`,
{
method: 'POST',
body: JSON.stringify(DEFAULT_CONTEXT),
},
));
expect(mocks.adapter.installMarketplacePackage).not.toHaveBeenCalled();
expect(mocks.adapter.searchMarketplace).not.toHaveBeenCalled();
expect(await screen.findByText(/Done — available/)).toBeInTheDocument();
await waitFor(() => expect(screen.getByTestId('installed-count')).toHaveTextContent('1'));
});
it('does not consult poisoned fuzzy search results for a marketplace approval', async () => {
mocks.adapter.searchMarketplace.mockResolvedValue(new Response(JSON.stringify({
packages: [
{ id: 8, name: 'web-scraper-pro', waggle_install_type: 'skill' },
],
}), { status: 200 }));
renderCard(marketplaceRequest());
fireEvent.click(screen.getByTestId('capability-request-install'));
await waitFor(() => expect(mocks.adapter.fetch).toHaveBeenCalledTimes(1));
expect(mocks.adapter.installMarketplacePackage).not.toHaveBeenCalled();
expect(mocks.adapter.searchMarketplace).not.toHaveBeenCalled();
});
it('submits at most one confirmation when Install is clicked twice', async () => {
let release!: () => void;
mocks.adapter.fetch.mockImplementationOnce(() => new Promise<Response>((resolve) => {
release = () => resolve(new Response('{}', { status: 200 }));
}));
renderCard(marketplaceRequest());
const install = screen.getByTestId('capability-request-install');
fireEvent.click(install);
fireEvent.click(install);
expect(mocks.adapter.fetch).toHaveBeenCalledTimes(1);
release();
expect(await screen.findByText(/Done — available/)).toBeInTheDocument();
});
it('a token connector reveals an inline paste row and connects FE-direct (not over the approval wire)', async () => {
renderCard({ name: 'Slack', source: 'connector', kind: 'connector', connectorId: 'slack', authType: 'bearer' });
// The verb is Connect, not Install.
expect(screen.getByTestId('capability-request-install')).toHaveTextContent('Connect');
it.each([
[404, 'CAPABILITY_PROPOSAL_NOT_AVAILABLE', 'This install request is no longer available.'],
[409, 'CAPABILITY_PROPOSAL_ALREADY_USED', 'This install request was already used.'],
[410, 'CAPABILITY_PROPOSAL_EXPIRED', 'This install request expired. Ask Waggle to find it again.'],
[422, 'INSTALL_FAILED', 'Install failed'],
])('fails without a direct-install fallback after proposal HTTP %s', async (status, code, message) => {
mocks.adapter.fetch.mockRejectedValueOnce(new AdapterHttpError(
status,
'failed',
{ code, message: code === 'INSTALL_FAILED' ? message : undefined },
));
renderCard(marketplaceRequest());
fireEvent.click(screen.getByTestId('capability-request-install'));
const input = await screen.findByLabelText(/slack api token/i);
expect(input).toHaveAttribute('name', 'capabilityConnectorToken');
expect(input).toHaveAttribute('autocomplete', 'off');
fireEvent.change(input, { target: { value: 'xoxb-9' } });
fireEvent.click(screen.getByTestId('capability-connector-token-submit'));
await waitFor(() => expect(mocks.adapter.connectConnector).toHaveBeenCalledWith('slack', { token: 'xoxb-9' }));
expect(await screen.findByText(message)).toBeInTheDocument();
expect(mocks.adapter.fetch).toHaveBeenCalledTimes(1);
expect(mocks.adapter.installMarketplacePackage).not.toHaveBeenCalled();
expect(mocks.adapter.searchMarketplace).not.toHaveBeenCalled();
});
it('an OAuth connector hands off to the Hub (no inline token)', async () => {
const events: CustomEvent[] = [];
const listener = (e: Event) => events.push(e as CustomEvent);
window.addEventListener('waggle:open-app', listener);
try {
renderCard({ name: 'Google Calendar', source: 'connector', kind: 'connector', authType: 'oauth2' });
fireEvent.click(screen.getByTestId('capability-request-install'));
await waitFor(() => expect(events.some(e => e.detail.appId === 'connectors')).toBe(true));
expect(screen.queryByTestId('capability-connector-token-input')).not.toBeInTheDocument();
expect(mocks.adapter.connectConnector).not.toHaveBeenCalled();
} finally {
window.removeEventListener('waggle:open-app', listener);
}
it.each([
['missing id', { name: 'web-scraper', source: 'marketplace', kind: 'marketplace', installType: 'skill' }],
['zero id', { name: 'web-scraper', source: 'marketplace', kind: 'marketplace', packageId: 0, installType: 'skill' }],
['fractional id', { name: 'web-scraper', source: 'marketplace', kind: 'marketplace', packageId: 7.5, installType: 'skill' }],
['string id', { name: 'web-scraper', source: 'marketplace', kind: 'marketplace', packageId: '7', installType: 'skill' }],
['invalid install type', { name: 'web-scraper', source: 'marketplace', kind: 'marketplace', packageId: 7, installType: 'mcp_server' }],
['missing proposal', marketplaceRequest({ proposalId: undefined })],
['expired proposal', marketplaceRequest({ expiresAt: '2000-01-01T00:00:00.000Z' })],
])('fails closed for a marketplace request with %s', (_case, request) => {
renderCard(request as CapabilityRequest);
expect(screen.queryByTestId('capability-request-card')).not.toBeInTheDocument();
expect(mocks.adapter.installMarketplacePackage).not.toHaveBeenCalled();
});
it('an mcp request enables through the store', async () => {
renderCard({ name: 'postgres', source: 'mcp', kind: 'mcp' });
expect(screen.getByTestId('capability-request-install')).toHaveTextContent('Enable');
fireEvent.click(screen.getByTestId('capability-request-install'));
await waitFor(() => expect(mocks.adapter.installMcp).toHaveBeenCalledWith('postgres', undefined));
expect(await screen.findByText(/Done — available/)).toBeInTheDocument();
it.each([
['missing workspace', { workspaceId: null, sessionId: 'session-a' }],
['missing session', { workspaceId: 'workspace-a', sessionId: null }],
])('fails closed for a proposal with %s', (_case, context) => {
renderCard(marketplaceRequest(), context);
expect(screen.queryByTestId('capability-request-card')).not.toBeInTheDocument();
expect(mocks.adapter.fetch).not.toHaveBeenCalled();
});
it('a starter-pack request installs via installPack (bundled, not store-tracked)', async () => {
renderCard({ name: 'daily-plan', source: 'starter-pack' });
renderCard({ name: 'daily-plan', source: 'starter-pack', kind: 'skill' });
fireEvent.click(screen.getByTestId('capability-request-install'));
await waitFor(() => expect(mocks.adapter.installPack).toHaveBeenCalledWith('daily-plan'));
expect(mocks.adapter.installMarketplacePackage).not.toHaveBeenCalled();
});
it('Dismiss declines without installing', async () => {
renderCard({ name: 'web-scraper', source: 'marketplace', kind: 'marketplace' });
renderCard(marketplaceRequest());
fireEvent.click(screen.getByTestId('capability-request-decline'));
expect(await screen.findByText('Dismissed')).toBeInTheDocument();
expect(mocks.adapter.fetch).not.toHaveBeenCalled();
expect(mocks.adapter.installMarketplacePackage).not.toHaveBeenCalled();
});
});

View File

@@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => ({
getConnectors: vi.fn().mockResolvedValue([]),
getMcps: vi.fn().mockResolvedValue([]),
getMarketplace: vi.fn().mockResolvedValue({ packages: [] }),
fetch: vi.fn(),
installMarketplacePackage: vi.fn(),
uninstallMarketplacePackage: vi.fn().mockResolvedValue(undefined),
connectConnector: vi.fn().mockResolvedValue(undefined),
@@ -70,6 +71,7 @@ beforeEach(() => {
mocks.adapter.getConnectors.mockResolvedValue([]);
mocks.adapter.getMcps.mockResolvedValue([]);
mocks.adapter.getMarketplace.mockResolvedValue({ packages: [] });
mocks.adapter.fetch.mockResolvedValue(new Response('{}', { status: 200 }));
mocks.adapter.installMarketplacePackage.mockResolvedValue(new Response('{}', { status: 200 }));
mocks.adapter.uninstallMarketplacePackage.mockResolvedValue(undefined);
mocks.adapter.connectConnector.mockResolvedValue(undefined);
@@ -104,6 +106,54 @@ describe('InstallProvider — hydrate', () => {
});
describe('InstallProvider — install dispatcher', () => {
it('holds proposal confirmation in shared installing state and marks exact package on success', async () => {
let release!: () => void;
mocks.adapter.fetch.mockImplementationOnce(() => new Promise<Response>((resolve) => {
release = () => resolve(new Response('{}', { status: 200 }));
}));
const { result } = await mountStore();
let confirmation!: Promise<void>;
act(() => {
confirmation = result.current.confirmPackageProposal(
9,
'123e4567-e89b-42d3-a456-426614174000',
'workspace-a',
'session-a',
);
});
await waitFor(() => expect(result.current.isInstalling('pkg:9')).toBe(true));
expect(result.current.isInstalled('pkg:9')).toBe(false);
await act(async () => {
release();
await confirmation;
});
expect(result.current.isInstalling('pkg:9')).toBe(false);
expect(result.current.isInstalled('pkg:9')).toBe(true);
expect(mocks.adapter.fetch).toHaveBeenCalledWith(
'/api/capability-proposals/123e4567-e89b-42d3-a456-426614174000/confirm',
{ method: 'POST', body: JSON.stringify({ workspaceId: 'workspace-a', sessionId: 'session-a' }) },
);
expect(mocks.adapter.installMarketplacePackage).not.toHaveBeenCalled();
});
it('clears shared installing state when proposal confirmation fails', async () => {
mocks.adapter.fetch.mockRejectedValueOnce(new Error('expired'));
const { result } = await mountStore();
await expect(result.current.confirmPackageProposal(
9,
'123e4567-e89b-42d3-a456-426614174000',
'workspace-a',
'session-a',
)).rejects.toThrow('expired');
expect(result.current.isInstalling('pkg:9')).toBe(false);
expect(result.current.isInstalled('pkg:9')).toBe(false);
});
it('package install success flips installed + toasts Added', async () => {
mocks.adapter.installMarketplacePackage.mockResolvedValue(new Response('{}', { status: 200 }));
const { result } = await mountStore();

View File

@@ -24,6 +24,9 @@ const mocks = vi.hoisted(() => ({
// probe's honest "nothing to check" idle path.
probeModel: vi.fn().mockResolvedValue({ configured: false }),
probeProvider: vi.fn().mockResolvedValue({ configured: false, valid: false, verified: false }),
getBrowserCompanionPairing: vi.fn().mockResolvedValue({ paired: false, extensionId: null, pairedAt: null }),
createBrowserCompanionPairingCode: vi.fn(),
revokeBrowserCompanionPairing: vi.fn(),
},
}));
vi.mock('@/lib/adapter', () => ({ adapter: mocks.adapter, default: vi.fn() }));
@@ -80,4 +83,13 @@ describe('PR5 Settings reskin', () => {
fireEvent.click(screen.getByRole('button', { name: /everything/i }));
expect(await screen.findByRole('tab', { name: /advanced/i })).toBeInTheDocument();
});
it('exposes Browser Companion pairing in Advanced settings', async () => {
await renderSettings();
fireEvent.click(screen.getByRole('button', { name: /everything/i }));
fireEvent.click(await screen.findByRole('tab', { name: /advanced/i }));
expect(await screen.findByTestId('browser-companion-settings')).toBeInTheDocument();
expect(mocks.adapter.getBrowserCompanionPairing).toHaveBeenCalledOnce();
});
});

View File

@@ -63,6 +63,26 @@ const render = (props: ChatAppRenderProps) =>
afterEach(() => cleanup());
describe('Wave U Lane F fix 1 — message action row presence', () => {
it('keeps historical assistant attribution when the active persona changes', () => {
render({
messages: [{ ...assistantMsg, persona: 'researcher' }],
currentPersona: 'coder',
});
expect(screen.getByText('· Researcher')).toBeInTheDocument();
expect(screen.queryByText('· Coder')).not.toBeInTheDocument();
});
it('keeps historical model attribution when the active model changes', () => {
render({
messages: [{ ...assistantMsg, model: 'openai/historical-model' }],
currentModel: 'anthropic/current-model',
});
expect(screen.getByText('· Historical Model')).toBeInTheDocument();
expect(screen.queryByText('· Current Model')).not.toBeInTheDocument();
});
it('keeps the empty-state mascot intrinsically sized before image decode', () => {
render({ messages: [] });
const emptyState = screen.getByText("Pick a workspace and Waggle's ready").closest('div');

View File

@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';
import { config } from '../middleware';
import { config } from '../proxy';
describe('www Clerk middleware boundary', () => {
describe('www Clerk proxy boundary', () => {
it('protects only identity and server-owned flows', () => {
expect(config.matcher).toEqual([
'/account(.*)',

View File

@@ -1,6 +1,6 @@
'use client';
import { useEffect, useState, type CSSProperties, type ReactNode } from 'react';
import { useSyncExternalStore, type CSSProperties, type ReactNode } from 'react';
import { useTranslations } from 'next-intl';
import { detectOSFromUserAgent, type OSId } from '../_lib/os-detection';
import { emit, events } from '../_lib/event-taxonomy';
@@ -15,6 +15,10 @@ interface DownloadCTAProps {
const DOWNLOAD_URL = '/download';
const subscribeToClientEnvironment = () => () => {};
const getClientOS = () => detectOSFromUserAgent(navigator.userAgent);
const getServerOS = (): null => null;
/**
* OS-aware download CTA. Renders a generic "Download" label at SSR + first
* paint, then swaps to "Download for {os}" after hydration via
@@ -33,13 +37,11 @@ export default function DownloadCTA({
style,
}: DownloadCTAProps) {
const t = useTranslations('landing.download_cta');
const [os, setOS] = useState<OSId | null>(null);
useEffect(() => {
if (typeof navigator !== 'undefined') {
setOS(detectOSFromUserAgent(navigator.userAgent));
}
}, []);
const os = useSyncExternalStore<OSId | null>(
subscribeToClientEnvironment,
getClientOS,
getServerOS,
);
const label = children ?? (os ? t('with_os', { os }) : t('default'));

Some files were not shown because too many files have changed in this diff Show More