diff --git a/.github/workflows/deployment-guide-smoke.yml b/.github/workflows/deployment-guide-smoke.yml new file mode 100644 index 00000000..ff1d809e --- /dev/null +++ b/.github/workflows/deployment-guide-smoke.yml @@ -0,0 +1,72 @@ +name: Single-Tenant Deployment Guide Smoke + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +jobs: + configuration-contract: + name: Fail-closed configuration contract (not cloud evidence) + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.12" + cache: pip + + - name: Set up pnpm + uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4 + with: + version: 10.23.0 + + - name: Set up Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: "20" + cache: pnpm + cache-dependency-path: apps/web/pnpm-lock.yaml + + - name: Install test dependencies + run: | + python -m venv .venv + ./.venv/bin/python -m pip install -e '.[dev]' + + - name: Install web test dependencies + run: pnpm --dir apps/web install --frozen-lockfile + + - name: Test the exact-origin web trust contract + run: pnpm --dir apps/web test -- lib/api.test.ts + + - name: Test the deployment contract + run: >- + ./.venv/bin/python -m pytest + tests/unit/test_single_tenant_deployment.py + tests/unit/test_config.py::test_production_api_settings_do_not_require_migration_database_url + -q -p no:cacheprovider + + - name: Emit explicitly non-cloud evidence + run: | + mkdir -p artifacts/phase5 + ./.venv/bin/python scripts/run_single_tenant_deployment_smoke.py \ + --environment ephemeral_ci \ + --output artifacts/phase5/single-tenant-deployment-smoke.json + + - name: Upload sanitized configuration receipt + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: single-tenant-deployment-config-contract + path: artifacts/phase5/single-tenant-deployment-smoke.json + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/ops-evidence.yml b/.github/workflows/ops-evidence.yml new file mode 100644 index 00000000..08e9116d --- /dev/null +++ b/.github/workflows/ops-evidence.yml @@ -0,0 +1,92 @@ +name: Phase 5 Ops Evidence + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +jobs: + ops-evidence: + name: Backup, restore, upgrade, and monitoring evidence + runs-on: ubuntu-24.04 + timeout-minutes: 30 + services: + postgres: + image: pgvector/pgvector:pg16@sha256:1d533553fefe4f12e5d80c7b80622ba0c382abb5758856f52983d8789179f0fb + env: + POSTGRES_USER: alicebot_admin + POSTGRES_PASSWORD: ci + POSTGRES_DB: alicebot + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U alicebot_admin -d alicebot" + --health-interval 5s + --health-timeout 5s + --health-retries 20 + env: + DATABASE_ADMIN_URL: postgresql://alicebot_admin:ci@localhost:5432/alicebot + DATABASE_URL: postgresql://alicebot_app:ci@localhost:5432/alicebot + steps: + - name: Checkout full history + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.12" + cache: pip + + - name: Select PostgreSQL 16 client tools + run: | + sudo apt-get update + sudo apt-get install --yes --no-install-recommends postgresql-client-16 + echo "/usr/lib/postgresql/16/bin" >> "$GITHUB_PATH" + /usr/lib/postgresql/16/bin/pg_dump --version + /usr/lib/postgresql/16/bin/pg_restore --version + + - name: Install Alice and test dependencies + run: | + python -m venv .venv + ./.venv/bin/python -m pip install -e '.[dev]' + + - name: Bootstrap the role-separated application role + env: + PGHOST: localhost + PGPASSWORD: ci + POSTGRES_USER: alicebot_admin + POSTGRES_DB: alicebot + ALICEBOT_APP_USER: alicebot_app + ALICEBOT_APP_PASSWORD: ci + run: infra/postgres/init/001_roles.sh + + - name: Run evidence contract tests + run: >- + ./.venv/bin/python -m pytest + tests/unit/test_phase5_ops_evidence.py + tests/unit/test_phase5_enterprise_handoff_truth.py + -q -p no:cacheprovider + + - name: Execute both backend drills + run: | + mkdir -p artifacts/phase5 + ./.venv/bin/python scripts/run_phase5_ops_evidence.py \ + --backend all \ + --work-dir "${RUNNER_TEMP}" \ + --output artifacts/phase5/ops-evidence.json + + - name: Upload sanitized evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: phase5-ops-evidence + path: artifacts/phase5/ops-evidence.json + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5a179e6e..4c95b6de 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2222,7 +2222,10 @@ jobs: DATABASE_ADMIN_URL: postgresql://alicebot_admin:ci@localhost:5432/alicebot run: | unset ALICE_LEGACY_SURFACES ALICE_MCP_LEGACY_TOOLS ALICE_AGENT_API_KEY - ./.venv/bin/python -m pytest tests/integration/test_default_surface_integration.py -q -p no:cacheprovider --require-executed-tests + ./.venv/bin/python -m pytest \ + tests/integration/test_default_surface_integration.py \ + tests/integration/test_openai_agents_sdk_tool.py \ + -q -p no:cacheprovider --require-executed-tests web: name: Web tests, types, accessibility, and budgets diff --git a/.gitignore b/.gitignore index ad519b3e..a21b4af4 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,8 @@ REVIEW_REPORT.md !docs/handoff/2026-07-18-v0.12.0-phase3-structural-refactor/REVIEW_REPORT.md !docs/handoff/2026-07-19-v0.13.0-phase4-sprint4-synthesis/BUILD_REPORT.md !docs/handoff/2026-07-19-v0.13.0-phase4-sprint4-synthesis/REVIEW_REPORT.md +!docs/handoff/2026-07-21-v0.14.0-phase5-enterprise-track/BUILD_REPORT.md +!docs/handoff/2026-07-21-v0.14.0-phase5-enterprise-track/REVIEW_REPORT.md ARCHIVE_RECOMMENDATIONS.md RECOMMENDED_ADRS.md diff --git a/Makefile b/Makefile index 4ab6424e..c135c719 100644 --- a/Makefile +++ b/Makefile @@ -107,7 +107,7 @@ release-static: $(PYTHON) scripts/check_control_doc_truth.py $(PYTHON) scripts/release_check.py $(PYTHON) -m ruff check apps/api/src/alicebot_api scripts tests eval/longmemeval - $(PYTHON) -m mypy --ignore-missing-imports apps/api/src/alicebot_api scripts/release_check.py scripts/test_distribution_artifact.py scripts/normalize_sdist.py scripts/render_release_body.py scripts/decode_github_release_body.py scripts/prepare_mainprotect_update.py scripts/check_python_coverage.py scripts/check_control_doc_truth.py scripts/check_github_release_checks.py scripts/check_release_controls_attestation.py + $(PYTHON) -m mypy --ignore-missing-imports apps/api/src/alicebot_api scripts/release_check.py scripts/test_distribution_artifact.py scripts/normalize_sdist.py scripts/render_release_body.py scripts/decode_github_release_body.py scripts/prepare_mainprotect_update.py scripts/check_python_coverage.py scripts/check_control_doc_truth.py scripts/check_github_release_checks.py scripts/check_release_controls_attestation.py scripts/run_phase5_ops_evidence.py scripts/_phase5_ops_seed.py scripts/run_single_tenant_deployment_smoke.py release-identity: git fetch --no-tags origin main diff --git a/README.md b/README.md index a3a258e6..a47405ef 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,9 @@ What that means in practice: - [Custom agent guide](https://github.com/samrusani/AliceBot/blob/main/docs/alpha/custom-agent-guide.md) - [Known limitations](https://github.com/samrusani/AliceBot/blob/main/docs/alpha/known-limitations.md) - [Backup and restore](https://github.com/samrusani/AliceBot/blob/main/docs/alpha/backup-and-restore.md) +- [Disaster recovery](https://github.com/samrusani/AliceBot/blob/main/docs/runbooks/disaster-recovery.md) +- [Health and monitoring](https://github.com/samrusani/AliceBot/blob/main/docs/runbooks/health-and-monitoring.md) +- [Upgrade v0.12.0 to current](https://github.com/samrusani/AliceBot/blob/main/docs/runbooks/upgrade-v0.12-to-current.md) - [Security and privacy](https://github.com/samrusani/AliceBot/blob/main/docs/alpha/security-and-privacy.md) - [v0.10.4 release notes](https://github.com/samrusani/AliceBot/blob/main/docs/release/v0.10.4-release-notes.md) - [v0.11.0 release notes](https://github.com/samrusani/AliceBot/blob/main/docs/release/v0.11.0-release-notes.md) diff --git a/SECURITY.md b/SECURITY.md index 76c3d30a..9a2b7711 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,8 +1,12 @@ # Security Policy -## Supported Scope +## Supported Versions -Alice is pre-1.0 software. Security fixes target the latest minor release series (currently `v0.9.x`); older tags are not maintained. Security posture in this repo is scoped to the shipped local/runtime surfaces and deterministic verification paths on the current baseline. +Alice is pre-1.0 software. Security fixes target the latest published minor +release series. Older minor series and development snapshots are not maintained +with security backports; move to the latest published release before reporting +or validating a fix. The default branch is useful for reproducing a forthcoming +fix, but it is not a supported release until it is tagged and published. ## Reporting a Vulnerability @@ -17,14 +21,52 @@ Do not open public issues for active security vulnerabilities. ## Security Boundaries -- Postgres remains the system of record. -- User-owned data paths are RLS-governed. -- Public CLI/MCP/importer surfaces should not bypass trust/provenance boundaries. +- Alice is local-first, single-user, self-hosted software. It is not a + multi-tenant service boundary. +- **Keyless means local-machine-owner trust.** A keyless vNext deployment is + safe only when the API remains on loopback and every local process and OS user + that can reach it is trusted as the Alice owner. In this mode a caller-supplied + `user_id` is routing context, not proof of identity. +- Once a user has any active agent API key, protected `/v0/vnext` requests for + that user reject keyless access and require `Authorization: Bearer + alice_sk_...`. A browser-clipper one-time capability is a deliberately narrow + exception for its single capture route; it is not a general API credential. +- Do not expose a keyless API or MCP process to a LAN, container peer, public + interface, or untrusted reverse-proxy client. Remote access requires active + agent keys, a TLS-terminating authenticated reverse proxy, a restrictive CORS + allowlist, and host/firewall controls. +- PostgreSQL runtime access uses the application role and per-user RLS. Keep the + admin database credential confined to migrations and administrative recovery. + SQLite is a single-user on-ramp protected by owner-only filesystem + permissions, not by database RLS. +- Public CLI, MCP, connector, and importer surfaces must preserve provenance and + policy boundaries. Imported and provider-returned text is data, never policy. - Consequential side effects remain approval-bounded. +The shipped threat model and current evidence boundaries are documented in +[`docs/security/`](docs/security/README.md). + +## Browser Clipper Credentials + +Visited-page JavaScript is hostile credential context. The bookmarklet must +never contain, request, or persist an agent API key or reusable connector +`capture_token`. + +The trusted Alice UI issues a short-lived, origin-bound, one-time capability for +the selected page origin. The visited page can observe that narrow capability, +so it may make the one authorized submission; it cannot replay it, redeem it +from another origin, or turn it into general Alice access. Trusted API clients +may still use Bearer authentication plus a reusable `capture_token`, but must +never pass that reusable token to a bookmarklet or other visited-page script. + ## Hardening Notes -- keep `.env` local and do not commit secrets -- keep local services bound to loopback where possible -- treat per-agent API keys (`alicebot agent keys create`) as secrets; they are stored hashed, printed exactly once, and can be revoked with `alicebot agent keys revoke` -- run verification commands before release tagging +- Keep `.env` files local and do not commit secrets. +- Keep API and web services bound to loopback unless the authenticated TLS + deployment boundary above is in place. +- Treat per-agent API keys (`alicebot agent keys create`) as secrets. Alice + stores a SHA-256 verifier and short identification prefix, displays the raw + key once, and supports revocation with `alicebot agent keys revoke`. +- Treat logs, exports, backups, and imported source archives as sensitive user + data even when application secrets have been redacted. +- Run the release verification and security evidence commands before tagging. diff --git a/apps/api/alembic/versions/20260721_0094_browser_clip_capabilities.py b/apps/api/alembic/versions/20260721_0094_browser_clip_capabilities.py new file mode 100644 index 00000000..83776989 --- /dev/null +++ b/apps/api/alembic/versions/20260721_0094_browser_clip_capabilities.py @@ -0,0 +1,76 @@ +"""Add one-time, origin-bound browser clip capabilities. + +Only a SHA-256 digest is persisted. Expiry is derived from the database +clock and redemption is a single conditional UPDATE so concurrent requests +cannot both consume the same capability. + +Revision ID: 20260721_0094 +Revises: 20260721_0093 +""" + +from __future__ import annotations + +from alembic import op + + +revision = "20260721_0094" +down_revision = "20260721_0093" +branch_labels = None +depends_on = None + + +_UPGRADE_SCHEMA = """ + CREATE TABLE browser_clip_capabilities ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + capability_hash text NOT NULL UNIQUE, + origin text NOT NULL, + expires_at timestamptz NOT NULL, + consumed_at timestamptz NULL, + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + UNIQUE (id, user_id), + CONSTRAINT browser_clip_capabilities_hash_check + CHECK (capability_hash ~ '^[0-9a-f]{64}$'), + CONSTRAINT browser_clip_capabilities_origin_length_check + CHECK (length(origin) BETWEEN 8 AND 2048), + CONSTRAINT browser_clip_capabilities_expiry_range_check + CHECK ( + expires_at > created_at + AND expires_at <= created_at + interval '5 minutes' + ), + CONSTRAINT browser_clip_capabilities_consumed_range_check + CHECK (consumed_at IS NULL OR consumed_at >= created_at) + ); + + CREATE INDEX browser_clip_capabilities_live_expiry_idx + ON browser_clip_capabilities (user_id, expires_at) + WHERE consumed_at IS NULL; + + CREATE INDEX browser_clip_capabilities_consumed_idx + ON browser_clip_capabilities (user_id, consumed_at) + WHERE consumed_at IS NOT NULL; + """ + +_GRANTS = ( + "GRANT SELECT, INSERT, UPDATE, DELETE ON browser_clip_capabilities TO alicebot_app", +) + +_POLICY = """ + CREATE POLICY browser_clip_capabilities_is_owner + ON browser_clip_capabilities + USING (user_id = app.current_user_id()) + WITH CHECK (user_id = app.current_user_id()); + """ + + +def upgrade() -> None: + op.execute(_UPGRADE_SCHEMA) + for statement in _GRANTS: + op.execute(statement) + op.execute("ALTER TABLE browser_clip_capabilities ENABLE ROW LEVEL SECURITY") + op.execute("ALTER TABLE browser_clip_capabilities FORCE ROW LEVEL SECURITY") + op.execute(_POLICY) + + +def downgrade() -> None: + op.execute("DROP TABLE IF EXISTS browser_clip_capabilities") diff --git a/apps/api/src/alicebot_api/browser_clip_capabilities.py b/apps/api/src/alicebot_api/browser_clip_capabilities.py new file mode 100644 index 00000000..870618cb --- /dev/null +++ b/apps/api/src/alicebot_api/browser_clip_capabilities.py @@ -0,0 +1,219 @@ +"""Short-lived, origin-bound, one-time browser clip capabilities.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime +from hashlib import sha256 +from ipaddress import IPv4Address, IPv6Address +import re +import secrets +from typing import Protocol +import unicodedata +from urllib.parse import urlsplit + +from alicebot_api.vnext_repositories import JsonObject + + +BROWSER_CLIP_CAPABILITY_PREFIX = "alice_clip_" +BROWSER_CLIP_CAPABILITY_TTL_SECONDS = 120 +_BROWSER_CLIP_CAPABILITY_PATTERN = re.compile( + rf"{re.escape(BROWSER_CLIP_CAPABILITY_PREFIX)}[A-Za-z0-9_-]{{43}}\Z", + flags=re.ASCII, +) +_DNS_LABEL_PATTERN = re.compile(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\Z", flags=re.ASCII) + + +class BrowserClipCapabilityValidationError(ValueError): + """Raised when a browser clip capability is malformed or cannot be redeemed.""" + + +class BrowserClipCapabilityStore(Protocol): + def create_browser_clip_capability( + self, + *, + capability_hash: str, + origin: str, + ttl_seconds: int, + ) -> JsonObject: ... + + def consume_browser_clip_capability( + self, + *, + capability_hash: str, + origin: str, + ) -> JsonObject | None: ... + + +@dataclass(frozen=True, slots=True) +class IssuedBrowserClipCapability: + capability: str + origin: str + expires_at: datetime + + def to_record(self) -> JsonObject: + return { + "status": "issued", + "capability": self.capability, + "origin": self.origin, + "expires_at": self.expires_at, + "one_time": True, + } + + +def _validated_url_text(value: str) -> str: + if not isinstance(value, str) or not value: + raise BrowserClipCapabilityValidationError("browser clip origin is required") + if "\\" in value or any( + character.isspace() or unicodedata.category(character) in {"Cc", "Cf", "Cs"} + for character in value + ): + raise BrowserClipCapabilityValidationError("browser clip origin is invalid") + return value + + +def _canonical_hostname(hostname: str) -> str: + if ":" in hostname: + if "%" in hostname: + raise BrowserClipCapabilityValidationError("browser clip origin is invalid") + try: + return f"[{IPv6Address(hostname).compressed}]" + except ValueError as exc: + raise BrowserClipCapabilityValidationError("browser clip origin is invalid") from exc + + ipv4_candidate = hostname[:-1] if hostname.endswith(".") else hostname + try: + return str(IPv4Address(ipv4_candidate)) + except ValueError: + # Numeric-looking alternatives such as 127.1 and 0x7f000001 are + # interpreted as IPv4 by browsers but not by ipaddress. Reject them + # instead of binding a capability to a different serialized origin. + if ipv4_candidate.rsplit(".", 1)[-1].isdigit() or ipv4_candidate.casefold().startswith("0x"): + raise BrowserClipCapabilityValidationError("browser clip origin is invalid") + + try: + ascii_hostname = hostname.casefold().encode("idna").decode("ascii").casefold() + except UnicodeError as exc: + raise BrowserClipCapabilityValidationError("browser clip origin is invalid") from exc + rooted = ascii_hostname.endswith(".") + unrooted_hostname = ascii_hostname[:-1] if rooted else ascii_hostname + if len(unrooted_hostname) > 253: + raise BrowserClipCapabilityValidationError("browser clip origin is invalid") + labels = unrooted_hostname.split(".") + if not labels or any(_DNS_LABEL_PATTERN.fullmatch(label) is None for label in labels): + raise BrowserClipCapabilityValidationError("browser clip origin is invalid") + return f"{unrooted_hostname}." if rooted else unrooted_hostname + + +def _normalized_http_origin(value: str, *, allow_url_path: bool) -> str: + candidate = _validated_url_text(value) + try: + parsed = urlsplit(candidate) + port = parsed.port + except ValueError as exc: + raise BrowserClipCapabilityValidationError("browser clip origin is invalid") from exc + scheme = parsed.scheme.casefold() + if scheme not in {"http", "https"} or parsed.hostname is None or not parsed.netloc: + raise BrowserClipCapabilityValidationError("browser clip origin must use http or https") + if parsed.username is not None or parsed.password is not None: + raise BrowserClipCapabilityValidationError("browser clip origin must not contain credentials") + if parsed.netloc.endswith(":"): + raise BrowserClipCapabilityValidationError("browser clip origin is invalid") + if not allow_url_path and (parsed.path or "?" in candidate or "#" in candidate): + raise BrowserClipCapabilityValidationError("browser clip origin must not contain a path, query, or fragment") + hostname = _canonical_hostname(parsed.hostname) + default_port = (scheme == "http" and port == 80) or (scheme == "https" and port == 443) + authority = hostname if port is None or default_port else f"{hostname}:{port}" + return f"{scheme}://{authority}" + + +def normalize_browser_clip_origin(value: str) -> str: + """Normalize an operator-supplied origin and reject URL-shaped values.""" + + return _normalized_http_origin(value, allow_url_path=False) + + +def browser_clip_url_origin(value: str) -> str: + """Extract the normalized origin from a captured page URL.""" + + return _normalized_http_origin(value, allow_url_path=True) + + +def browser_clip_capability_hash(capability: str) -> str: + return sha256(capability.encode("utf-8")).hexdigest() + + +def _capability_expiry(row: JsonObject) -> datetime: + raw_expiry = row.get("expires_at") + if isinstance(raw_expiry, datetime): + expiry = raw_expiry + elif isinstance(raw_expiry, str): + try: + expiry = datetime.fromisoformat(raw_expiry.replace("Z", "+00:00")) + except ValueError as exc: + raise RuntimeError("browser clip capability store returned an invalid expiry") from exc + else: + raise RuntimeError("browser clip capability store did not return an expiry") + if expiry.tzinfo is None: + raise RuntimeError("browser clip capability store returned a timezone-naive expiry") + return expiry.astimezone(UTC) + + +def issue_browser_clip_capability( + store: BrowserClipCapabilityStore, + *, + origin: str, +) -> IssuedBrowserClipCapability: + normalized_origin = normalize_browser_clip_origin(origin) + capability = f"{BROWSER_CLIP_CAPABILITY_PREFIX}{secrets.token_urlsafe(32)}" + if _BROWSER_CLIP_CAPABILITY_PATTERN.fullmatch(capability) is None: # pragma: no cover - stdlib contract guard + raise RuntimeError("secure token generation returned an invalid browser clip capability") + row = store.create_browser_clip_capability( + capability_hash=browser_clip_capability_hash(capability), + origin=normalized_origin, + ttl_seconds=BROWSER_CLIP_CAPABILITY_TTL_SECONDS, + ) + if row.get("origin") != normalized_origin: + raise RuntimeError("browser clip capability store returned a different origin") + return IssuedBrowserClipCapability( + capability=capability, + origin=normalized_origin, + expires_at=_capability_expiry(row), + ) + + +def consume_browser_clip_capability( + store: BrowserClipCapabilityStore, + *, + capability: str, + capture_url: str, + request_origin: str | None, +) -> JsonObject: + if not isinstance(capability, str) or _BROWSER_CLIP_CAPABILITY_PATTERN.fullmatch(capability) is None: + raise BrowserClipCapabilityValidationError("browser clip capability is invalid") + if request_origin is None: + raise BrowserClipCapabilityValidationError("browser clip origin is required") + normalized_request_origin = normalize_browser_clip_origin(request_origin) + if browser_clip_url_origin(capture_url) != normalized_request_origin: + raise BrowserClipCapabilityValidationError("browser clip capability origin does not match") + row = store.consume_browser_clip_capability( + capability_hash=browser_clip_capability_hash(capability), + origin=normalized_request_origin, + ) + if row is None: + raise BrowserClipCapabilityValidationError("browser clip capability is invalid") + return row + + +__all__ = [ + "BROWSER_CLIP_CAPABILITY_PREFIX", + "BROWSER_CLIP_CAPABILITY_TTL_SECONDS", + "BrowserClipCapabilityStore", + "BrowserClipCapabilityValidationError", + "IssuedBrowserClipCapability", + "browser_clip_capability_hash", + "browser_clip_url_origin", + "consume_browser_clip_capability", + "issue_browser_clip_capability", + "normalize_browser_clip_origin", +] diff --git a/apps/api/src/alicebot_api/config.py b/apps/api/src/alicebot_api/config.py index b8461bd5..29ff3bda 100644 --- a/apps/api/src/alicebot_api/config.py +++ b/apps/api/src/alicebot_api/config.py @@ -475,10 +475,6 @@ def _validate_settings( ) if settings.database_url == DEFAULT_DATABASE_URL: raise ValueError("DATABASE_URL must be overridden outside development/test environments") - if settings.database_admin_url == DEFAULT_DATABASE_ADMIN_URL: - raise ValueError( - "DATABASE_ADMIN_URL must be overridden outside development/test environments" - ) return settings diff --git a/apps/api/src/alicebot_api/main.py b/apps/api/src/alicebot_api/main.py index c5751047..55b98c95 100644 --- a/apps/api/src/alicebot_api/main.py +++ b/apps/api/src/alicebot_api/main.py @@ -12,6 +12,8 @@ Request, Response, ) +from fastapi.exception_handlers import request_validation_exception_handler +from fastapi.exceptions import RequestValidationError from fastapi.openapi.utils import get_openapi from pydantic import TypeAdapter from fastapi.responses import JSONResponse @@ -275,6 +277,7 @@ def _openapi_tag_for_path(path: str) -> str: ("POST", "/v0/vnext/projects"), ("POST", "/v0/vnext/connectors/telegram/sync"), ("POST", "/v0/vnext/connectors/local-folder/sync"), + ("POST", "/v0/vnext/connectors/browser-clipper/capabilities"), ("POST", "/v0/vnext/connectors/browser-clipper/capture"), ("POST", "/v0/vnext/agents/ingest-output"), ("POST", "/v0/vnext/artifacts/{artifact_id}/insight-feedback"), @@ -497,6 +500,23 @@ def openapi(self) -> dict[str, Any]: "description", f"{summary}." if isinstance(summary, str) and summary else "AliceBot API operation.", ) + if operation_key == ("POST", "/v0/vnext/connectors/browser-clipper/capture"): + request_body = operation.get("requestBody") + if not isinstance(request_body, dict): # pragma: no cover - FastAPI contract guard + raise RuntimeError("browser clip capture request body is missing from OpenAPI") + request_content = request_body.get("content") + if not isinstance(request_content, dict): # pragma: no cover - FastAPI contract guard + raise RuntimeError("browser clip capture request content is missing from OpenAPI") + request_content["text/plain"] = { + "schema": { + "type": "string", + "contentMediaType": "application/json", + "description": ( + "JSON-encoded VNextBrowserClipperCaptureRequest used by the " + "CORS-safelisted one-time bookmarklet transport." + ), + } + } responses = operation.get("responses") if not isinstance(responses, dict): continue @@ -551,6 +571,34 @@ def openapi(self) -> dict[str, Any]: version=__version__, description="AliceBot local-first continuity, retrieval, and agentic-memory API.", ) + + +@app.exception_handler(RequestValidationError) +async def _alice_request_validation_error( + request: Request, + exc: RequestValidationError, +) -> Response: + """Keep one-time browser credentials out of framework validation bodies.""" + + if ( + request.method.upper() == "POST" + and request.url.path == "/v0/vnext/connectors/browser-clipper/capture" + ): + return JSONResponse( + status_code=422, + content={ + "detail": [ + { + "type": "value_error", + "loc": ["body"], + "msg": "Input validation failed", + } + ] + }, + ) + return await request_validation_exception_handler(request, exc) + + from alicebot_api.routers import providers # noqa: E402 from alicebot_api.routers.providers import redact_url_credentials # noqa: E402 from alicebot_api.routers import workspaces # noqa: E402 @@ -701,6 +749,7 @@ async def receive() -> dict[str, object]: ("GET", "/v0/vnext/workspace"), ("PATCH", "/v0/vnext/connectors/{connector_name}/config"), ("POST", "/v0/vnext/beliefs/{belief_id}/review"), + ("POST", "/v0/vnext/connectors/browser-clipper/capabilities"), ("POST", "/v0/vnext/connectors/browser-clipper/capture"), ("POST", "/v0/vnext/connectors/local-folder/sync"), ("POST", "/v0/vnext/connectors/telegram/sync"), @@ -716,6 +765,78 @@ async def receive() -> dict[str, object]: } ) +_BROWSER_CLIP_SIMPLE_CAPTURE_PATH = "/v0/vnext/connectors/browser-clipper/capture" +_BROWSER_CLIP_SIMPLE_BODY_MAX_BYTES = 3_000_000 + + +async def _prepare_browser_clip_simple_request( + request: Request, +) -> tuple[Request, dict[str, object] | None]: + """Translate the clipper's CORS-safelisted body into the JSON contract. + + A bookmarklet executes inside an untrusted visited page. Its one-time + capability is therefore sent as a simple ``text/plain`` request without + reusable credentials or custom headers. Keep this exception confined to + the exact capture route and bound the body before parsing it. + """ + + if request.method.upper() != "POST" or request.url.path != _BROWSER_CLIP_SIMPLE_CAPTURE_PATH: + return request, None + media_type = request.headers.get("content-type", "").split(";", 1)[0].strip().casefold() + if media_type != "text/plain": + return request, None + + raw_content_length = request.headers.get("content-length") + if raw_content_length is not None: + try: + content_length = int(raw_content_length) + except ValueError as exc: + raise ValueError("browser clip request content length is invalid") from exc + if content_length < 0 or content_length > _BROWSER_CLIP_SIMPLE_BODY_MAX_BYTES: + raise ValueError("browser clip request body is too large") + + buffered_body = bytearray() + async for chunk in request.stream(): + if len(buffered_body) + len(chunk) > _BROWSER_CLIP_SIMPLE_BODY_MAX_BYTES: + raise ValueError("browser clip request body is too large") + buffered_body.extend(chunk) + raw_body = bytes(buffered_body) + if len(raw_body) > _BROWSER_CLIP_SIMPLE_BODY_MAX_BYTES: # pragma: no cover - accumulator fence + raise ValueError("browser clip request body is too large") + # BaseHTTPMiddleware captured this original request's wrapped receiver + # before dispatch. Populate its replay cache only after bounded streaming; + # otherwise the downstream app sees an empty body. + request._body = raw_body # type: ignore[attr-defined] + try: + parsed_body = json.loads(raw_body) + except (json.JSONDecodeError, UnicodeDecodeError) as exc: + raise ValueError("browser clip request body is invalid") from exc + if not isinstance(parsed_body, dict): + raise ValueError("browser clip request body must be an object") + capability = parsed_body.get("capture_capability") + if not isinstance(capability, str) or not capability.strip(): + raise ValueError("browser clip simple requests require a capability") + + headers = [ + (name, value) + for name, value in request.scope.get("headers", []) + if name.lower() != b"content-type" + ] + headers.append((b"content-type", b"application/json")) + # BaseHTTPMiddleware dispatches the downstream app with the original ASGI + # scope, so mutate that shared scope in place before handing it a replayable + # request body. + request.scope["headers"] = headers + + async def receive() -> dict[str, object]: + return { + "type": "http.request", + "body": raw_body, + "more_body": False, + } + + return Request(request.scope, receive), parsed_body + def _matched_vnext_route_path(request: Request) -> str: for route in app.router.routes: @@ -798,8 +919,18 @@ async def _vnext_protected_http_auth( if request.method == "OPTIONS": return await call_next(request) + try: + request, simple_capture_payload = await _prepare_browser_clip_simple_request(request) + except ValueError: + return _vnext_public_error_response( + status_code=400, + detail="vNext browser clip capture request is invalid", + ) + payload: dict[str, object] = {} - if request.method not in {"GET", "HEAD", "OPTIONS"}: + if simple_capture_payload is not None: + payload = simple_capture_payload + elif request.method not in {"GET", "HEAD", "OPTIONS"}: try: candidate = await request.json() except (json.JSONDecodeError, UnicodeDecodeError): @@ -828,15 +959,32 @@ async def _vnext_protected_http_auth( try: settings = get_settings() route_path = _matched_vnext_route_path(request) - identity, route_decision = await run_in_threadpool( - _resolve_vnext_http_auth, - settings=settings, - user_id=user_id, - raw_key=agent_key_from_authorization(request.headers.get("authorization")), - payload=payload, - method=request.method, - route_path=route_path, + capability_capture = ( + request.method.upper() == "POST" + and route_path == _BROWSER_CLIP_SIMPLE_CAPTURE_PATH + and isinstance(payload.get("capture_capability"), str) + and bool(str(payload["capture_capability"]).strip()) ) + if capability_capture: + # The capability is the narrow credential for this endpoint. Its + # hash/origin/user/expiry/consumption checks run atomically in the + # handler's capture transaction. + identity = None + route_decision = _vnext_central_route_policy( + identity=None, + method=request.method, + route_path=route_path, + ) + else: + identity, route_decision = await run_in_threadpool( + _resolve_vnext_http_auth, + settings=settings, + user_id=user_id, + raw_key=agent_key_from_authorization(request.headers.get("authorization")), + payload=payload, + method=request.method, + route_path=route_path, + ) if route_decision is not None and route_decision.decision == "blocked": return _vnext_permission_response(route_decision) except AgentKeyAuthenticationError as exc: @@ -991,7 +1139,9 @@ async def enforce_authenticated_user_identity( settings = get_settings() - if settings.app_env not in {"development", "test"}: + if settings.app_env not in {"development", "test"} and not ( + request.url.path == "/v0/vnext" or request.url.path.startswith("/v0/vnext/") + ): if not settings.legacy_v0_enabled_outside_dev: return JSONResponse( status_code=404, diff --git a/apps/api/src/alicebot_api/openapi_operation_contracts.py b/apps/api/src/alicebot_api/openapi_operation_contracts.py index 00aec51e..edf9d9e6 100644 --- a/apps/api/src/alicebot_api/openapi_operation_contracts.py +++ b/apps/api/src/alicebot_api/openapi_operation_contracts.py @@ -1056,6 +1056,19 @@ def _closed_source_schema( ), ), ), + ("POST", "/v0/vnext/connectors/browser-clipper/capabilities"): ( + "CreateVnextBrowserClipCapabilitySuccessResponse", + _operation_schema( + "CreateVnextBrowserClipCapabilitySuccessResponse", + ( + "status", + "capability", + "origin", + "expires_at", + "one_time", + ), + ), + ), ("POST", "/v0/vnext/connectors/browser-clipper/capture"): ( "CaptureVnextBrowserClipSuccessResponse", _operation_schema( @@ -2260,10 +2273,17 @@ def _closed_source_schema( objects=("connector",), strings=("status",) ), ("POST", "/v0/vnext/connectors/telegram/sync"): _typed_properties(objects=("telegram",), strings=("status",)), - ("POST", "/v0/vnext/connectors/local-folder/sync"): _typed_properties( - objects=("local_folder",), strings=("status",) - ), - ("POST", "/v0/vnext/connectors/browser-clipper/capture"): _typed_properties( + ("POST", "/v0/vnext/connectors/local-folder/sync"): _typed_properties( + objects=("local_folder",), strings=("status",) + ), + ("POST", "/v0/vnext/connectors/browser-clipper/capabilities"): { + "status": {"type": "string"}, + "capability": {"type": "string", "readOnly": True}, + "origin": {"type": "string", "format": "uri"}, + "expires_at": {"type": "string", "format": "date-time"}, + "one_time": {"type": "boolean"}, + }, + ("POST", "/v0/vnext/connectors/browser-clipper/capture"): _typed_properties( objects=("browser_clipper",), strings=("status",) ), ("POST", "/v0/vnext/agents/ingest-output"): _typed_properties(objects=("ingest_output",), strings=("status",)), @@ -2755,6 +2775,10 @@ def _closed_source_schema( ("POST", "/v0/vnext/connectors/{connector_name}/sync"): (_CONNECTOR_SYNC_RESPONSE_FIELDS, None), ("POST", "/v0/vnext/connectors/telegram/sync"): (_CONNECTOR_SYNC_RESPONSE_FIELDS, None), ("POST", "/v0/vnext/connectors/local-folder/sync"): (_CONNECTOR_SYNC_RESPONSE_FIELDS, None), + ("POST", "/v0/vnext/connectors/browser-clipper/capabilities"): ( + ("status", "capability", "origin", "expires_at", "one_time"), + None, + ), ("POST", "/v0/vnext/connectors/browser-clipper/capture"): (_CONNECTOR_SYNC_RESPONSE_FIELDS, None), ("POST", "/v0/vnext/agents/ingest-output"): ( ("status", "source_id", "artifact_id", "memory_id", "policy_decision"), @@ -3043,9 +3067,19 @@ def _closed_source_schema( for _operation_key, (_fields, _required) in _OPENAPI_SOURCE_AUDITED_RESPONSES.items(): _component_name, _previous_schema = OPENAPI_OPERATION_RESPONSE_SCHEMAS[_operation_key] + _source_properties = ( + _OPENAPI_EXPLICIT_PROPERTY_SCHEMAS[_operation_key] + if _operation_key == ("POST", "/v0/vnext/connectors/browser-clipper/capabilities") + else None + ) OPENAPI_OPERATION_RESPONSE_SCHEMAS[_operation_key] = ( _component_name, - _closed_source_schema(_component_name, _fields, required=_required), + _closed_source_schema( + _component_name, + _fields, + required=_required, + properties=_source_properties, + ), ) diff --git a/apps/api/src/alicebot_api/routers/vnext_memories.py b/apps/api/src/alicebot_api/routers/vnext_memories.py index 6d737ab2..fc8fcd03 100644 --- a/apps/api/src/alicebot_api/routers/vnext_memories.py +++ b/apps/api/src/alicebot_api/routers/vnext_memories.py @@ -9,6 +9,11 @@ from fastapi.responses import JSONResponse from pydantic import Field +from alicebot_api.browser_clip_capabilities import ( + BrowserClipCapabilityValidationError, + consume_browser_clip_capability, + issue_browser_clip_capability, +) from alicebot_api.config import get_settings from alicebot_api.db import user_connection from alicebot_api.mcp_tools import redact_memory_flow @@ -138,11 +143,22 @@ class VNextBrowserClipperCaptureRequest(VNextAgentRequest): page_text: str | None = Field(default=None, min_length=1, max_length=500_000) user_note: str | None = Field(default=None, min_length=1, max_length=20_000) capture_token: str | None = Field(default=None, min_length=1, max_length=500) + capture_capability: str | None = Field( + default=None, + min_length=1, + max_length=200, + json_schema_extra={"writeOnly": True}, + ) captured_at: str | None = Field(default=None, min_length=1, max_length=120) domain: VNextDomain = "professional" sensitivity: VNextSensitivity = "private" +class VNextBrowserClipperCapabilityRequest(VNextAgentRequest): + user_id: UUID + origin: str = Field(min_length=1, max_length=2048) + + class VNextAgentOutputIngestRequest(VNextAgentRequest): user_id: UUID agent_id: str = Field(min_length=1, max_length=160) @@ -564,14 +580,31 @@ def sync_vnext_local_folder_connector(request: VNextLocalFolderSyncRequest) -> J @connectors_router.post("/v0/vnext/connectors/browser-clipper/capture") -def capture_vnext_browser_clip(request: VNextBrowserClipperCaptureRequest) -> JSONResponse: +def capture_vnext_browser_clip( + request: VNextBrowserClipperCaptureRequest, + origin: str | None = Header(default=None), +) -> JSONResponse: settings = get_settings() try: with user_connection(settings.database_url, request.user_id) as conn: - result = VNextConnectorService(PostgresVNextStore(conn), defer_embeddings=True).capture_browser_clip( + store = PostgresVNextStore(conn) + capability_authorized = request.capture_capability is not None + if capability_authorized: + if request.capture_token is not None: + raise BrowserClipCapabilityValidationError( + "browser clip credentials are mutually exclusive" + ) + consume_browser_clip_capability( + store, + capability=request.capture_capability or "", + capture_url=request.url, + request_origin=origin if isinstance(origin, str) else None, + ) + result = VNextConnectorService(store, defer_embeddings=True).capture_browser_clip( request.model_dump(mode="json"), default_domain=request.domain, default_sensitivity=request.sensitivity, + capability_authorized=capability_authorized, ) payload = result.to_record() _persist_vnext_deferred_embeddings( @@ -579,7 +612,7 @@ def capture_vnext_browser_clip(request: VNextBrowserClipperCaptureRequest) -> JS user_id=request.user_id, result=result, ) - except VNextConnectorValidationError: + except (BrowserClipCapabilityValidationError, VNextConnectorValidationError): return _vnext_public_error_response(status_code=400, detail="vNext browser clip capture request is invalid") return JSONResponse( status_code=201 if payload["status"] in {"ok", "partial", "duplicate"} else 400, @@ -587,6 +620,30 @@ def capture_vnext_browser_clip(request: VNextBrowserClipperCaptureRequest) -> JS ) +@connectors_router.post("/v0/vnext/connectors/browser-clipper/capabilities") +def create_vnext_browser_clip_capability( + request: VNextBrowserClipperCapabilityRequest, +) -> JSONResponse: + settings = get_settings() + try: + with user_connection(settings.database_url, request.user_id) as conn: + issued = issue_browser_clip_capability( + PostgresVNextStore(conn), + origin=request.origin, + ) + payload = issued.to_record() + except BrowserClipCapabilityValidationError: + return _vnext_public_error_response( + status_code=400, + detail="vNext browser clip capability request is invalid", + ) + return JSONResponse( + status_code=201, + content=jsonable_encoder(payload), + headers={"Cache-Control": "no-store", "Pragma": "no-cache"}, + ) + + @connectors_router.post("/v0/vnext/agents/ingest-output") def ingest_vnext_agent_output( request: VNextAgentOutputIngestRequest, diff --git a/apps/api/src/alicebot_api/sqlite_schema.py b/apps/api/src/alicebot_api/sqlite_schema.py index 7df97ce3..76657c19 100644 --- a/apps/api/src/alicebot_api/sqlite_schema.py +++ b/apps/api/src/alicebot_api/sqlite_schema.py @@ -628,6 +628,43 @@ def _sql_list(values: tuple[str, ...]) -> str: CHECK (permission_profile IN ({_PERMISSION_PROFILES_SQL})) ) """, + f""" + CREATE TABLE IF NOT EXISTS browser_clip_capabilities ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + capability_hash TEXT NOT NULL UNIQUE, + origin TEXT NOT NULL, + expires_at TEXT NOT NULL, + consumed_at TEXT NULL, + created_at TEXT NOT NULL DEFAULT {_NOW_UTC_ISO_SQL}, + UNIQUE (id, user_id), + CONSTRAINT browser_clip_capabilities_hash_check + CHECK ( + length(capability_hash) = 64 + AND capability_hash = lower(capability_hash) + AND capability_hash NOT GLOB '*[^0-9a-f]*' + ), + CONSTRAINT browser_clip_capabilities_origin_length_check + CHECK (length(origin) BETWEEN 8 AND 2048), + CONSTRAINT browser_clip_capabilities_expiry_range_check + CHECK ( + julianday(expires_at) IS NOT NULL + AND julianday(created_at) IS NOT NULL + AND julianday(expires_at) > julianday(created_at) + -- One millisecond absorbs SQLite julianday floating-point error at + -- the exact 300-second boundary; the write seam still rejects >300. + AND julianday(expires_at) <= julianday(created_at) + (300.001 / 86400.0) + ), + CONSTRAINT browser_clip_capabilities_consumed_range_check + CHECK ( + consumed_at IS NULL + OR ( + julianday(consumed_at) IS NOT NULL + AND julianday(consumed_at) >= julianday(created_at) + ) + ) + ) + """, # Entity substrate (mirrors Postgres migration 20260705_0078): # entities.normalized_name is the resolution key, aliases is a JSON # array of alternate normalized names. @@ -977,6 +1014,16 @@ def _sql_list(values: tuple[str, ...]) -> str: CREATE INDEX IF NOT EXISTS agent_api_keys_user_agent_idx ON agent_api_keys (user_id, agent_id) """, + """ + CREATE INDEX IF NOT EXISTS browser_clip_capabilities_live_expiry_idx + ON browser_clip_capabilities (user_id, expires_at) + WHERE consumed_at IS NULL + """, + """ + CREATE INDEX IF NOT EXISTS browser_clip_capabilities_consumed_idx + ON browser_clip_capabilities (user_id, consumed_at) + WHERE consumed_at IS NOT NULL + """, # Mirrors vnext_entities_user_normalized_name_idx and # entity_relationship_events_entity_changed_idx from Postgres # migration 20260705_0078. diff --git a/apps/api/src/alicebot_api/sqlite_store.py b/apps/api/src/alicebot_api/sqlite_store.py index b6298d37..cda9f719 100644 --- a/apps/api/src/alicebot_api/sqlite_store.py +++ b/apps/api/src/alicebot_api/sqlite_store.py @@ -78,6 +78,10 @@ PROVENANCE_COLUMNS as PROVENANCE_COLUMNS, REVISION_COLUMNS as REVISION_COLUMNS, ) +from alicebot_api.vnext_stores.sqlite.browser_clip_capabilities import ( + consume_browser_clip_capability as _browser_clip_consume_capability, + create_browser_clip_capability as _browser_clip_create_capability, +) from alicebot_api.vnext_stores.sqlite.embedding_cas import ( _embedding_content_sha256_sqlite as _embedding_content_sha256_sqlite, _ensure_embedding_content_sha256_sqlite as _ensure_embedding_content_sha256_sqlite, @@ -661,6 +665,9 @@ def create_source(self, source: JsonObject, *, actor_type: str = "system") -> VN ) return row + create_browser_clip_capability = _browser_clip_create_capability + consume_browser_clip_capability = _browser_clip_consume_capability + def get_or_create_source( self, source: JsonObject, diff --git a/apps/api/src/alicebot_api/vnext_connectors.py b/apps/api/src/alicebot_api/vnext_connectors.py index 0d300e6f..3ee90b8d 100644 --- a/apps/api/src/alicebot_api/vnext_connectors.py +++ b/apps/api/src/alicebot_api/vnext_connectors.py @@ -23,7 +23,12 @@ from alicebot_api.vnext_event_log import append_event from alicebot_api.vnext_project_scope import resolve_project_scope from alicebot_api.vnext_repositories import JsonObject -from alicebot_api.vnext_secrets import SecretProvider, default_secret_provider, redact_secret_fields +from alicebot_api.vnext_secrets import ( + SecretProvider, + default_secret_provider, + redact_secret_fields, + redact_secret_value, +) CONNECTOR_ITEM_IMPORT_ERROR_CODE = "connector_item_import_failed" @@ -1582,10 +1587,11 @@ def capture_browser_clip( *, default_domain: str | None = None, default_sensitivity: str | None = None, + capability_authorized: bool = False, ) -> ConnectorSyncResult: config = self.get_config("browser_clipper") secret_ref = _as_optional_text(config.get("secret_ref")) - if secret_ref is not None: + if secret_ref is not None and not capability_authorized: expected_token = self._resolve_secret(secret_ref) provided_token = _as_optional_text(payload.get("capture_token")) if not expected_token or not provided_token or not hmac.compare_digest(provided_token, expected_token): @@ -1599,7 +1605,18 @@ def capture_browser_clip( }, ) raise VNextConnectorValidationError("browser clipper capture token is invalid") - sanitized_payload = cast(JsonObject, redact_secret_fields(dict(payload))) + sanitized_payload_value: object = dict(payload) + capability = _as_optional_text(payload.get("capture_capability")) + if capability_authorized: + if capability is None: + raise VNextConnectorValidationError("browser clipper capability is required") + sanitized_payload_value = redact_secret_value(sanitized_payload_value, capability) + if not isinstance(sanitized_payload_value, dict): # pragma: no cover - recursive redactor contract + raise RuntimeError("browser clipper payload redaction returned a non-object") + # Capabilities authorize only this transaction. Never persist even a + # redacted marker that would imply they are connector source content. + sanitized_payload_value.pop("capture_capability", None) + sanitized_payload = cast(JsonObject, redact_secret_fields(sanitized_payload_value)) return self.sync_items( "browser_clipper", [sanitized_payload], diff --git a/apps/api/src/alicebot_api/vnext_secrets.py b/apps/api/src/alicebot_api/vnext_secrets.py index 2158faf4..e5eed2db 100644 --- a/apps/api/src/alicebot_api/vnext_secrets.py +++ b/apps/api/src/alicebot_api/vnext_secrets.py @@ -58,6 +58,28 @@ def redact_secret_fields(value: object) -> object: return value +def redact_secret_value(value: object, secret_value: str) -> object: + """Remove every occurrence of one secret from a JSON-like value. + + Key-name redaction cannot protect a credential that an untrusted caller + copies into an otherwise ordinary field. This value-based pass is intended + for short-lived credentials known at the ingestion boundary. + """ + + if not secret_value: + return value + if isinstance(value, str): + return value.replace(secret_value, "***") + if isinstance(value, dict): + return { + str(key).replace(secret_value, "***"): redact_secret_value(item, secret_value) + for key, item in value.items() + } + if isinstance(value, list): + return [redact_secret_value(item, secret_value) for item in value] + return value + + @dataclass(slots=True) class EnvironmentSecretProvider: """Resolve refs such as env:CONNECTOR_API_TOKEN without persisting values.""" @@ -232,4 +254,5 @@ def default_secret_provider() -> SecretProvider: "is_secret_key", "redact_secret_fields", "redact_secret_text", + "redact_secret_value", ] diff --git a/apps/api/src/alicebot_api/vnext_store.py b/apps/api/src/alicebot_api/vnext_store.py index 073673a8..57b1c012 100644 --- a/apps/api/src/alicebot_api/vnext_store.py +++ b/apps/api/src/alicebot_api/vnext_store.py @@ -52,6 +52,10 @@ PROVENANCE_COLUMNS as PROVENANCE_COLUMNS, REVISION_COLUMNS as REVISION_COLUMNS, ) +from alicebot_api.vnext_stores.postgres.browser_clip_capabilities import ( + consume_browser_clip_capability as _browser_clip_consume_capability, + create_browser_clip_capability as _browser_clip_create_capability, +) from alicebot_api.vnext_stores.postgres.embedding_cas import ( _MEMORY_EMBEDDING_CONTENT_SHA256_SQL as _MEMORY_EMBEDDING_CONTENT_SHA256_SQL, _PYTHON_312_STRIP_CHARS_SQL as _PYTHON_312_STRIP_CHARS_SQL, @@ -917,9 +921,12 @@ def connector_storage_status(self) -> VNextRow: to_regclass('public.scheduler_runs') IS NOT NULL AS scheduler_runs_exists, (SELECT extversion FROM pg_extension WHERE extname = 'vector') AS pgvector_version, NULL::text AS migration_revision - """, + """, ) + create_browser_clip_capability = _browser_clip_create_capability + consume_browser_clip_capability = _browser_clip_consume_capability + def list_sources( self, *, diff --git a/apps/api/src/alicebot_api/vnext_stores/postgres/browser_clip_capabilities.py b/apps/api/src/alicebot_api/vnext_stores/postgres/browser_clip_capabilities.py new file mode 100644 index 00000000..a79d803f --- /dev/null +++ b/apps/api/src/alicebot_api/vnext_stores/postgres/browser_clip_capabilities.py @@ -0,0 +1,101 @@ +"""PostgreSQL persistence for one-time browser clip capabilities.""" + +from __future__ import annotations + +BROWSER_CLIP_CAPABILITY_COLUMNS = """ + id, + user_id, + origin, + expires_at, + consumed_at, + created_at + """ + + +def create_browser_clip_capability( + self, + *, + capability_hash: str, + origin: str, + ttl_seconds: int, +) -> dict[str, object]: + """Persist only a digest, with expiry derived from the database clock.""" + + if not 1 <= ttl_seconds <= 300: + raise ValueError("browser clip capability TTL must be between 1 and 300 seconds") + + with self.conn.cursor() as cur: + cur.execute( + """ + DELETE FROM browser_clip_capabilities + WHERE user_id = app.current_user_id() + AND ( + expires_at < clock_timestamp() - interval '1 day' + OR consumed_at < clock_timestamp() - interval '1 day' + ) + """ + ) + return self._fetch_one( + "create_browser_clip_capability", + f""" + WITH issue_clock AS MATERIALIZED ( + SELECT clock_timestamp() AS issued_at + ) + INSERT INTO browser_clip_capabilities ( + user_id, + capability_hash, + origin, + expires_at, + created_at + ) + SELECT + app.current_user_id(), + %s, + %s, + issue_clock.issued_at + make_interval(secs => %s), + issue_clock.issued_at + FROM issue_clock + RETURNING {BROWSER_CLIP_CAPABILITY_COLUMNS} + """, + (capability_hash, origin, ttl_seconds), + ) + + +def consume_browser_clip_capability( + self, + *, + capability_hash: str, + origin: str, +) -> dict[str, object] | None: + """Atomically redeem one live capability for the current RLS user.""" + + return self._fetch_optional_one( + f""" + WITH redemption_clock AS MATERIALIZED ( + SELECT clock_timestamp() AS redeemed_at + ) + UPDATE browser_clip_capabilities + SET consumed_at = redemption_clock.redeemed_at + FROM redemption_clock + WHERE user_id = app.current_user_id() + AND capability_hash = %s + AND origin = %s + AND consumed_at IS NULL + AND expires_at > redemption_clock.redeemed_at + RETURNING {BROWSER_CLIP_CAPABILITY_COLUMNS} + """, + (capability_hash, origin), + ) + + +for _method in (create_browser_clip_capability, consume_browser_clip_capability): + _method.__module__ = "alicebot_api.vnext_store" + _method.__qualname__ = f"PostgresVNextStore.{_method.__name__}" +del _method + + +__all__ = [ + "BROWSER_CLIP_CAPABILITY_COLUMNS", + "consume_browser_clip_capability", + "create_browser_clip_capability", +] diff --git a/apps/api/src/alicebot_api/vnext_stores/sqlite/browser_clip_capabilities.py b/apps/api/src/alicebot_api/vnext_stores/sqlite/browser_clip_capabilities.py new file mode 100644 index 00000000..14bce91d --- /dev/null +++ b/apps/api/src/alicebot_api/vnext_stores/sqlite/browser_clip_capabilities.py @@ -0,0 +1,105 @@ +"""SQLite persistence for one-time browser clip capabilities.""" + +from __future__ import annotations + +from uuid import uuid4 + + +BROWSER_CLIP_CAPABILITY_COLUMNS = """ + id, + user_id, + origin, + expires_at, + consumed_at, + created_at + """ + + +def create_browser_clip_capability( + self, + *, + capability_hash: str, + origin: str, + ttl_seconds: int, +) -> dict[str, object]: + """Persist only a digest, with expiry derived from the database clock.""" + + if not 1 <= ttl_seconds <= 300: + raise ValueError("browser clip capability TTL must be between 1 and 300 seconds") + + self._execute( + """ + DELETE FROM browser_clip_capabilities + WHERE user_id = ? + AND ( + julianday(expires_at) < julianday('now', '-1 day') + OR julianday(consumed_at) < julianday('now', '-1 day') + ) + """, + (self.user_id,), + ) + return self._fetch_one( + "create_browser_clip_capability", + f""" + INSERT INTO browser_clip_capabilities ( + id, + user_id, + capability_hash, + origin, + expires_at, + created_at + ) + VALUES ( + ?, + ?, + ?, + ?, + strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?), + strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + ) + RETURNING {BROWSER_CLIP_CAPABILITY_COLUMNS} + """, + ( + str(uuid4()), + self.user_id, + capability_hash, + origin, + f"+{ttl_seconds} seconds", + ), + ) + + +def consume_browser_clip_capability( + self, + *, + capability_hash: str, + origin: str, +) -> dict[str, object] | None: + """Atomically redeem one live capability for the bound SQLite user.""" + + return self._fetch_optional_one( + f""" + UPDATE browser_clip_capabilities + SET consumed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + WHERE user_id = ? + AND capability_hash = ? + AND origin = ? + AND consumed_at IS NULL + AND julianday(expires_at) > julianday('now') + RETURNING {BROWSER_CLIP_CAPABILITY_COLUMNS} + """, + (self.user_id, capability_hash, origin), + ) + + +for _method in (create_browser_clip_capability, consume_browser_clip_capability): + _method.__module__ = "alicebot_api.sqlite_store" + _method.__qualname__ = f"SQLiteVNextStore.{_method.__name__}" +del _method + + +__all__ = [ + "BROWSER_CLIP_CAPABILITY_COLUMNS", + "consume_browser_clip_capability", + "create_browser_clip_capability", +] diff --git a/apps/web/app/approvals/loading.tsx b/apps/web/app/approvals/loading.tsx index 2a777b6c..46a519a6 100644 --- a/apps/web/app/approvals/loading.tsx +++ b/apps/web/app/approvals/loading.tsx @@ -4,7 +4,7 @@ import { StatusBadge } from "../../components/status-badge"; export default function Loading() { return ( -
+
({ - default: ({ href, children, className }: { href: string; children: React.ReactNode; className?: string }) => ( - {children} + default: ({ + href, + children, + className, + "aria-current": ariaCurrent, + }: { + href: string; + children: React.ReactNode; + className?: string; + "aria-current"?: React.AriaAttributes["aria-current"]; + }) => ( + + {children} + ), })); @@ -112,6 +125,13 @@ describe("ApprovalsPage", () => { afterEach(cleanup); + it("announces the loading route without interrupting the operator", () => { + const { container } = render(); + + expect(container.firstElementChild).toHaveAttribute("aria-busy", "true"); + expect(container.firstElementChild).toHaveAttribute("aria-live", "polite"); + }); + it("returns not found before any reads when legacy surfaces are disabled", async () => { legacySurfacesEnabledMock.mockReturnValue(false); @@ -150,6 +170,10 @@ describe("ApprovalsPage", () => { expect(screen.getByText("Live API")).toBeInTheDocument(); expect(screen.getAllByText("Release Publisher").length).toBeGreaterThan(0); + expect(screen.getByRole("link", { name: /Release Publisher/ })).toHaveAttribute( + "aria-current", + "page", + ); expect(getApprovalDetailMock).toHaveBeenCalledWith( "https://api.example.com", liveApproval.id, @@ -172,4 +196,23 @@ describe("ApprovalsPage", () => { expect(screen.getByText("Fixture-backed")).toBeInTheDocument(); expect(screen.getByText(/total approvals/i)).toBeInTheDocument(); }); + + it("marks a failed live detail read unavailable and keeps every mutation disabled", async () => { + getApiConfigMock.mockReturnValue({ apiBaseUrl: "https://api.example.com", userId: "user-1" }); + hasLiveApiConfigMock.mockReturnValue(true); + listApprovalsMock.mockResolvedValue({ items: [liveApproval] }); + getApprovalDetailMock.mockRejectedValue(new Error("approval detail down")); + + render( + await ApprovalsPage({ + searchParams: Promise.resolve({ approval: liveApproval.id }), + }), + ); + + expect(screen.getByText("Approval detail unavailable")).toBeInTheDocument(); + expect(screen.queryByText("Live approval detail")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Execute approved request" })).toBeDisabled(); + expect(listToolExecutionsMock).not.toHaveBeenCalled(); + expect(getToolExecutionMock).not.toHaveBeenCalled(); + }); }); diff --git a/apps/web/app/approvals/page.tsx b/apps/web/app/approvals/page.tsx index 63f79f5d..6be52a70 100644 --- a/apps/web/app/approvals/page.tsx +++ b/apps/web/app/approvals/page.tsx @@ -51,7 +51,7 @@ export default async function ApprovalsPage({ const selected = items.find((item) => item.id === selectedId) ?? items[0] ?? null; let detail = selected; - let detailSource: ApiSource = selected ? listSource : "fixture"; + let detailSource: ApiSource | "unavailable" = selected ? listSource : "fixture"; if (selected && liveModeReady && listSource === "live") { try { @@ -59,14 +59,18 @@ export default async function ApprovalsPage({ detail = payload.approval; detailSource = "live"; } catch { - detail = getFixtureApproval(selected.id) ?? selected; - detailSource = detail === selected ? "live" : "fixture"; + const fixtureDetail = getFixtureApproval(selected.id); + detail = fixtureDetail ?? selected; + detailSource = fixtureDetail ? "fixture" : "unavailable"; } } - let execution = detail ? getFixtureExecutionByApprovalId(detail.id) : null; + let execution = detailSource === "unavailable" || !detail ? null : getFixtureExecutionByApprovalId(detail.id); let executionSource: ApiSource | null = execution ? "fixture" : null; - let executionUnavailableMessage: string | null = null; + let executionUnavailableMessage: string | null = + detailSource === "unavailable" + ? "Execution review was not loaded because the selected approval detail is unavailable." + : null; if (detail && liveModeReady && detailSource === "live") { try { @@ -98,7 +102,7 @@ export default async function ApprovalsPage({ const pageMode = combinePageModes( listSource, - detail ? detailSource : null, + detail && detailSource !== "unavailable" ? detailSource : null, execution ? executionSource : null, ); diff --git a/apps/web/app/artifacts/loading.tsx b/apps/web/app/artifacts/loading.tsx index 0992d729..430f7dd0 100644 --- a/apps/web/app/artifacts/loading.tsx +++ b/apps/web/app/artifacts/loading.tsx @@ -4,7 +4,7 @@ import { StatusBadge } from "../../components/status-badge"; export default function Loading() { return ( -
+
{ cleanup(); }); + it("announces the loading route without interrupting the operator", () => { + const { container } = render(); + + expect(container.firstElementChild).toHaveAttribute("aria-busy", "true"); + expect(container.firstElementChild).toHaveAttribute("aria-live", "polite"); + }); + it("keeps route state explicit when live chunks fall back to fixture", async () => { const fixtureArtifact = taskArtifactFixtures[0]; if (!fixtureArtifact) { @@ -189,4 +197,37 @@ describe("ArtifactsPage", () => { expect(screen.getByRole("heading", { name: "Persisted identity" })).toBeInTheDocument(); expect(screen.getByRole("heading", { name: "No persisted chunks" })).toBeInTheDocument(); }); + + it("marks a failed live detail read unavailable and does not invent chunk state", async () => { + const artifact = { + id: "artifact-live-without-fixture", + task_id: "task-live-1", + task_workspace_id: "workspace-live-1", + status: "registered", + ingestion_status: "ingested", + relative_path: "reports/live-only.md", + media_type_hint: "text/markdown", + created_at: "2026-07-21T09:00:00Z", + updated_at: "2026-07-21T09:05:00Z", + }; + getApiConfigMock.mockReturnValue({ apiBaseUrl: "https://api.example.com", userId: "user-1" }); + hasLiveApiConfigMock.mockReturnValue(true); + listTaskArtifactsMock.mockResolvedValue({ + items: [artifact], + summary: { total_count: 1, order: ["created_at_asc", "id_asc"] }, + }); + getTaskArtifactDetailMock.mockRejectedValue(new Error("artifact detail down")); + + render( + await ArtifactsPage({ + searchParams: Promise.resolve({ artifact: artifact.id }), + }), + ); + + expect(screen.getByText("Detail unavailable")).toBeInTheDocument(); + expect(screen.queryByText("Live detail")).not.toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Chunk review unavailable" })).toBeInTheDocument(); + expect(screen.getByRole("alert")).toHaveTextContent("artifact detail down"); + expect(listTaskArtifactChunksMock).not.toHaveBeenCalled(); + }); }); diff --git a/apps/web/app/artifacts/page.tsx b/apps/web/app/artifacts/page.tsx index f45035f8..4f16f8cb 100644 --- a/apps/web/app/artifacts/page.tsx +++ b/apps/web/app/artifacts/page.tsx @@ -78,7 +78,9 @@ export default async function ArtifactsPage({ const selectedFromList = artifacts.find((item) => item.id === selectedArtifactId) ?? null; let selectedArtifact = selectedFromList; - let selectedArtifactSource: ApiSource | null = selectedArtifact ? artifactListSource : null; + let selectedArtifactSource: ApiSource | "unavailable" | null = selectedArtifact + ? artifactListSource + : null; let selectedArtifactUnavailableReason: string | undefined; if (selectedFromList && liveModeReady && artifactListSource === "live") { @@ -91,6 +93,8 @@ export default async function ArtifactsPage({ if (fixtureArtifact) { selectedArtifact = fixtureArtifact; selectedArtifactSource = "fixture"; + } else { + selectedArtifactSource = "unavailable"; } selectedArtifactUnavailableReason = error instanceof Error ? error.message : "Selected artifact detail could not be loaded."; @@ -99,7 +103,15 @@ export default async function ArtifactsPage({ let chunks = selectedArtifact ? getFixtureTaskArtifactChunks(selectedArtifact.id) : []; let chunkSummary = selectedArtifact ? getFixtureTaskArtifactChunkSummary(selectedArtifact.id) : null; - let chunkSource: ApiSource | "unavailable" | null = selectedArtifact ? "fixture" : null; + let chunkSource: ApiSource | "unavailable" | null = selectedArtifact + ? selectedArtifactSource === "unavailable" + ? "unavailable" + : "fixture" + : null; + if (chunkSource === "unavailable") { + chunks = []; + chunkSummary = null; + } let chunkUnavailableReason: string | undefined; if (selectedArtifact && liveModeReady && selectedArtifactSource === "live") { @@ -132,7 +144,7 @@ export default async function ArtifactsPage({ const pageMode = combinePageModes( artifactListSource, - selectedArtifactSource, + selectedArtifactSource === "unavailable" ? null : selectedArtifactSource, chunkSource === "unavailable" ? null : chunkSource, ); diff --git a/apps/web/app/entities/loading.tsx b/apps/web/app/entities/loading.tsx index 4893c663..e0c9fa1e 100644 --- a/apps/web/app/entities/loading.tsx +++ b/apps/web/app/entities/loading.tsx @@ -4,7 +4,7 @@ import { StatusBadge } from "../../components/status-badge"; export default function Loading() { return ( -
+
{ cleanup(); }); + it("announces the loading route without interrupting the operator", () => { + const { container } = render(); + + expect(container.firstElementChild).toHaveAttribute("aria-busy", "true"); + expect(container.firstElementChild).toHaveAttribute("aria-live", "polite"); + }); + it("uses fixture-backed entity workspace state when live API config is absent", async () => { render(await EntitiesPage({ searchParams: Promise.resolve({}) })); @@ -204,5 +212,35 @@ describe("EntitiesPage", () => { expect(screen.getByText("Edge review unavailable")).toBeInTheDocument(); expect(screen.getByText("Edges unavailable")).toBeInTheDocument(); expect(screen.getByText("edges down")).toBeInTheDocument(); + expect(screen.getByRole("alert")).toHaveTextContent("edges down"); + }); + + it("marks a failed live detail read unavailable and does not invent edge state", async () => { + const entity = { + id: "entity-live-without-fixture", + entity_type: "project", + name: "Live-only entity", + source_memory_ids: ["memory-live-1"], + created_at: "2026-07-21T09:00:00Z", + }; + getApiConfigMock.mockReturnValue({ apiBaseUrl: "https://api.example.com", userId: "user-1" }); + hasLiveApiConfigMock.mockReturnValue(true); + listEntitiesMock.mockResolvedValue({ + items: [entity], + summary: { total_count: 1, order: ["created_at_asc", "id_asc"] }, + }); + getEntityDetailMock.mockRejectedValue(new Error("entity detail down")); + + render( + await EntitiesPage({ + searchParams: Promise.resolve({ entity: entity.id }), + }), + ); + + expect(screen.getByText("Detail unavailable")).toBeInTheDocument(); + expect(screen.queryByText("Live detail")).not.toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Edge review unavailable" })).toBeInTheDocument(); + expect(screen.getByRole("alert")).toHaveTextContent("entity detail down"); + expect(listEntityEdgesMock).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/app/entities/page.tsx b/apps/web/app/entities/page.tsx index e3a0368a..566410c2 100644 --- a/apps/web/app/entities/page.tsx +++ b/apps/web/app/entities/page.tsx @@ -77,7 +77,9 @@ export default async function EntitiesPage({ const selectedFromList = entities.find((item) => item.id === selectedEntityId) ?? null; let selectedEntity = selectedFromList; - let selectedEntitySource: ApiSource | null = selectedEntity ? entityListSource : null; + let selectedEntitySource: ApiSource | "unavailable" | null = selectedEntity + ? entityListSource + : null; let selectedEntityUnavailableReason: string | undefined; if (selectedFromList && liveModeReady && entityListSource === "live") { @@ -90,6 +92,8 @@ export default async function EntitiesPage({ if (fixtureEntity) { selectedEntity = fixtureEntity; selectedEntitySource = "fixture"; + } else { + selectedEntitySource = "unavailable"; } selectedEntityUnavailableReason = error instanceof Error ? error.message : "Selected entity detail could not be loaded."; @@ -98,7 +102,15 @@ export default async function EntitiesPage({ let edges = selectedEntity ? getFixtureEntityEdges(selectedEntity.id) : []; let edgeSummary = selectedEntity ? getFixtureEntityEdgeSummary(selectedEntity.id) : null; - let edgeSource: ApiSource | "unavailable" | null = selectedEntity ? "fixture" : null; + let edgeSource: ApiSource | "unavailable" | null = selectedEntity + ? selectedEntitySource === "unavailable" + ? "unavailable" + : "fixture" + : null; + if (edgeSource === "unavailable") { + edges = []; + edgeSummary = null; + } let edgeUnavailableReason: string | undefined; if (selectedEntity && liveModeReady && selectedEntitySource === "live") { @@ -125,7 +137,7 @@ export default async function EntitiesPage({ const pageMode = combinePageModes( entityListSource, - selectedEntitySource, + selectedEntitySource === "unavailable" ? null : selectedEntitySource, edgeSource === "unavailable" ? null : edgeSource, ); diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 7288b69f..855d31ab 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -233,6 +233,7 @@ code, .shell-column { display: grid; gap: 22px; + align-content: start; } .shell-topbar { @@ -928,6 +929,7 @@ code, display: grid; gap: 24px; grid-template-columns: minmax(340px, 0.94fr) minmax(0, 1.2fr); + align-items: flex-start; } .list-panel__header { diff --git a/apps/web/app/memories/loading.tsx b/apps/web/app/memories/loading.tsx index 21eafd97..b0063f57 100644 --- a/apps/web/app/memories/loading.tsx +++ b/apps/web/app/memories/loading.tsx @@ -4,7 +4,7 @@ import { StatusBadge } from "../../components/status-badge"; export default function Loading() { return ( -
+
{ cleanup(); }); + it("announces the loading route without interrupting the operator", () => { + const { container } = render(); + + expect(container.firstElementChild).toHaveAttribute("aria-busy", "true"); + expect(container.firstElementChild).toHaveAttribute("aria-live", "polite"); + }); + it("uses fixture-backed memory workspace state when live API config is absent", async () => { render(await MemoriesPage({ searchParams: Promise.resolve({}) })); @@ -135,6 +143,41 @@ describe("MemoriesPage", () => { expect(getMemoryHygieneDashboardMock).not.toHaveBeenCalled(); }); + it("renders a truthful empty live memory list without selecting or mutating a fixture", async () => { + getApiConfigMock.mockReturnValue({ + apiBaseUrl: "https://api.example.com", + userId: "user-1", + defaultThreadId: "thread-1", + defaultToolId: "tool-1", + }); + hasLiveApiConfigMock.mockReturnValue(true); + listMemoriesMock.mockResolvedValue({ + items: [], + summary: { + status: "active", + limit: 20, + returned_count: 0, + total_count: 0, + has_more: false, + order: ["updated_at_desc", "created_at_desc", "id_desc"], + }, + }); + listMemoryReviewQueueMock.mockRejectedValue(new Error("queue unavailable")); + getMemoryEvaluationSummaryMock.mockRejectedValue(new Error("summary unavailable")); + getMemoryTrustDashboardMock.mockRejectedValue(new Error("trust unavailable")); + getMemoryHygieneDashboardMock.mockRejectedValue(new Error("hygiene unavailable")); + listOpenLoopsMock.mockRejectedValue(new Error("open loops unavailable")); + + render(await MemoriesPage({ searchParams: Promise.resolve({}) })); + + expect(screen.getByRole("heading", { name: "No active memories" })).toBeInTheDocument(); + expect(screen.getAllByRole("heading", { name: "No memory selected" }).length).toBeGreaterThan(0); + expect(screen.getByRole("heading", { name: "Label form is disabled" })).toBeInTheDocument(); + expect(getMemoryDetailMock).not.toHaveBeenCalled(); + expect(getMemoryRevisionsMock).not.toHaveBeenCalled(); + expect(listMemoryLabelsMock).not.toHaveBeenCalled(); + }); + it("renders live-backed memory summary, detail, revisions, and labels when live reads succeed", async () => { getApiConfigMock.mockReturnValue({ apiBaseUrl: "https://api.example.com", @@ -541,10 +584,18 @@ describe("MemoriesPage", () => { expect(screen.getAllByText("Insufficient sample").length).toBeGreaterThan(0); expect(screen.getByText("Detail read")).toBeInTheDocument(); expect(screen.getByText("detail down")).toBeInTheDocument(); - expect(screen.getByText("Revisions unavailable")).toBeInTheDocument(); - expect(screen.getByText("revisions down")).toBeInTheDocument(); - expect(screen.getByText("Labels unavailable")).toBeInTheDocument(); - expect(screen.getByText("labels down")).toBeInTheDocument(); + expect(screen.getByText("Detail unavailable")).toBeInTheDocument(); + expect(screen.queryByText("Live detail")).not.toBeInTheDocument(); + expect( + screen.getByRole("heading", { name: "Revision history unavailable" }), + ).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Review labels unavailable" })).toBeInTheDocument(); + expect(screen.queryByText("No revisions returned")).not.toBeInTheDocument(); + expect(screen.queryByText("No labels yet")).not.toBeInTheDocument(); + expect(screen.queryByText("0 total labels")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Submit review label" })).toBeDisabled(); + expect(getMemoryRevisionsMock).not.toHaveBeenCalled(); + expect(listMemoryLabelsMock).not.toHaveBeenCalled(); }); it("starts independent selected-record reads in bounded parallel waves", async () => { diff --git a/apps/web/app/memories/page.tsx b/apps/web/app/memories/page.tsx index 1392de3a..87ded28e 100644 --- a/apps/web/app/memories/page.tsx +++ b/apps/web/app/memories/page.tsx @@ -6,6 +6,7 @@ import { MemoryList } from "../../components/memory-list"; import { MemoryRevisionList } from "../../components/memory-revision-list"; import { MemorySummary } from "../../components/memory-summary"; import { PageHeader } from "../../components/page-header"; +import { SectionCard } from "../../components/section-card"; import type { ApiSource, MemoryHygieneDashboardSummary, @@ -440,7 +441,9 @@ export default async function MemoriesPage({ selectedQueueIndex >= 0 ? visibleMemories[selectedQueueIndex + 1]?.id ?? null : null; let selectedMemory = selectedFromVisibleList; - let selectedMemorySource: ApiSource | null = selectedMemory ? selectedListSource : null; + let selectedMemorySource: ApiSource | "unavailable" | null = selectedMemory + ? selectedListSource + : null; let selectedMemoryUnavailableReason: string | undefined; const selectedOpenLoopId = resolveSelectedOpenLoopId(requestedOpenLoopId, openLoops); @@ -473,6 +476,8 @@ export default async function MemoriesPage({ if (fixtureMemory) { selectedMemory = fixtureMemory; selectedMemorySource = "fixture"; + } else { + selectedMemorySource = "unavailable"; } selectedMemoryUnavailableReason = memoryDetailResult.reason instanceof Error @@ -508,14 +513,30 @@ export default async function MemoriesPage({ let revisionSummary: MemoryRevisionReviewListSummary | null = selectedMemory ? getFixtureMemoryRevisionSummary(selectedMemory.id) : null; - let revisionSource: ApiSource | "unavailable" | null = selectedMemory ? "fixture" : null; + let revisionSource: ApiSource | "unavailable" | null = selectedMemory + ? selectedMemorySource === "unavailable" + ? "unavailable" + : "fixture" + : null; + if (revisionSource === "unavailable") { + revisions = []; + revisionSummary = null; + } let revisionUnavailableReason: string | undefined; let labels = selectedMemory ? getFixtureMemoryLabels(selectedMemory.id) : []; let labelSummary: MemoryReviewLabelSummary | null = selectedMemory ? getFixtureMemoryLabelSummary(selectedMemory.id) : null; - let labelSource: ApiSource | "unavailable" | null = selectedMemory ? "fixture" : null; + let labelSource: ApiSource | "unavailable" | null = selectedMemory + ? selectedMemorySource === "unavailable" + ? "unavailable" + : "fixture" + : null; + if (labelSource === "unavailable") { + labels = []; + labelSummary = null; + } let labelUnavailableReason: string | undefined; if (selectedMemory && liveModeReady && selectedMemorySource === "live") { @@ -557,7 +578,7 @@ export default async function MemoriesPage({ labelSource = "fixture"; } else { labels = []; - labelSummary = getFixtureMemoryLabelSummary(selectedMemory.id); + labelSummary = null; labelSource = "unavailable"; } labelUnavailableReason = @@ -574,7 +595,7 @@ export default async function MemoriesPage({ trustDashboardSource, hygieneDashboardSource, openLoopSource, - selectedMemorySource, + selectedMemorySource === "unavailable" ? null : selectedMemorySource, selectedOpenLoopSource, revisionSource === "unavailable" ? null : revisionSource, labelSource === "unavailable" ? null : labelSource, @@ -617,17 +638,11 @@ export default async function MemoriesPage({ unavailableReason={hygieneDashboardUnavailableReason} /> -
-
-
-

Trust dashboard

-

Canonical quality posture

-
-

- One deterministic view for gate posture, queue aging, retrieval quality, correction - recurrence, and recommended next review action. -

-
+

Source: {trustDashboardSource === "live" ? "Live dashboard" : "Fixture dashboard"} @@ -685,19 +700,13 @@ export default async function MemoriesPage({

{trustDashboard.recommended_review.reason}

-
- -
-
-
-

Open-loop backbone

-

Unresolved commitment review

-
-

- Review unresolved loops with deterministic ordering and inspect one selected loop in detail. -

-
+ +

Source: {openLoopSource === "live" ? "Live list" : "Fixture list"} @@ -767,7 +776,7 @@ export default async function MemoriesPage({

)}
-
+
-
-
-
-

Typed metadata

-

Memory classification and confidence

-
-

- Typed metadata remains visible in the review workspace with explicit safe fallbacks. -

-
+ {selectedMemory ? (
@@ -833,7 +837,7 @@ export default async function MemoriesPage({ ) : (

Select a memory to inspect typed metadata fields.

)} -
+
+
{ cleanup(); }); + it("announces the loading route without interrupting the operator", () => { + const { container } = render(); + + expect(container.firstElementChild).toHaveAttribute("aria-busy", "true"); + expect(container.firstElementChild).toHaveAttribute("aria-live", "polite"); + }); + it("shows fixture mode when live api config is absent", async () => { render(await TracesPage({ searchParams: Promise.resolve({}) })); expect(screen.getByText("Fixture-backed")).toBeInTheDocument(); expect(screen.getByText("Trace and explain-why review")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /Context compile review/ })).toHaveAttribute( + "aria-current", + "page", + ); }); it("shows api unavailable chip when live trace list fails", async () => { diff --git a/apps/web/components/approval-detail.tsx b/apps/web/components/approval-detail.tsx index 28e8fe0a..1c5879cd 100644 --- a/apps/web/components/approval-detail.tsx +++ b/apps/web/components/approval-detail.tsx @@ -33,7 +33,7 @@ function formatAttributeValue(value: unknown) { type ApprovalDetailProps = { initialApproval: ApprovalItem | null; - detailSource: ApiSource; + detailSource: ApiSource | "unavailable"; initialExecution: ToolExecutionItem | null; executionSource?: ApiSource | null; executionUnavailableMessage?: string | null; @@ -112,7 +112,13 @@ export function ApprovalDetail({
Detail source
-
{detailSource === "live" ? "Live approval detail" : "Fixture detail fallback"}
+
+ {detailSource === "live" + ? "Live approval detail" + : detailSource === "fixture" + ? "Fixture detail fallback" + : "Approval detail unavailable"} +
Routing trace
diff --git a/apps/web/components/approval-list.tsx b/apps/web/components/approval-list.tsx index 62c6aff7..f06c090b 100644 --- a/apps/web/components/approval-list.tsx +++ b/apps/web/components/approval-list.tsx @@ -54,6 +54,7 @@ export function ApprovalList({ key={item.id} href={`/approvals?approval=${item.id}`} className={`list-row${item.id === selectedId ? " is-selected" : ""}`} + aria-current={item.id === selectedId ? "page" : undefined} >
diff --git a/apps/web/components/artifact-chunk-list.tsx b/apps/web/components/artifact-chunk-list.tsx index f7c169ab..d545eae4 100644 --- a/apps/web/components/artifact-chunk-list.tsx +++ b/apps/web/components/artifact-chunk-list.tsx @@ -47,7 +47,7 @@ export function ArtifactChunkList({
{unavailableReason ? ( -
+

Chunk read

{unavailableReason}

@@ -97,7 +97,11 @@ export function ArtifactChunkList({
- {unavailableReason ?

Live chunk read failed: {unavailableReason}

: null} + {unavailableReason ? ( +

+ Live chunk read failed: {unavailableReason} +

+ ) : null}
{chunks.map((chunk) => ( diff --git a/apps/web/components/artifact-detail.tsx b/apps/web/components/artifact-detail.tsx index 89a0d164..c98fe351 100644 --- a/apps/web/components/artifact-detail.tsx +++ b/apps/web/components/artifact-detail.tsx @@ -57,7 +57,7 @@ export function ArtifactDetail({ artifact, source, unavailableReason }: Artifact
{unavailableReason ? ( -
+

Detail read

{unavailableReason}

diff --git a/apps/web/components/artifact-list.tsx b/apps/web/components/artifact-list.tsx index 1ba6ae2e..8e2659ad 100644 --- a/apps/web/components/artifact-list.tsx +++ b/apps/web/components/artifact-list.tsx @@ -71,7 +71,11 @@ export function ArtifactList({
- {unavailableReason ?

Live list read failed: {unavailableReason}

: null} + {unavailableReason ? ( +

+ Live list read failed: {unavailableReason} +

+ ) : null}
{artifacts.map((artifact) => ( diff --git a/apps/web/components/browser-clipper.test.ts b/apps/web/components/browser-clipper.test.ts new file mode 100644 index 00000000..ab018467 --- /dev/null +++ b/apps/web/components/browser-clipper.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; + +import { buildBrowserClipperBookmarklet } from "./vnext-workspace-model"; + +describe("browser clipper bookmarklet", () => { + const configEncoding = /^(?:[A-Za-z0-9._~-]|%25[0-9A-F]{2})+$/; + const capability = "alice_clip_one_time_capability"; + const input = { + endpoint: "http://127.0.0.1:8000/v0/vnext/connectors/browser-clipper/capture", + userId: "user-1", + capability, + origin: "https://example.com", + domain: "professional" as const, + sensitivity: "private" as const, + }; + const bookmarklet = buildBrowserClipperBookmarklet({ + ...input, + }); + + function decodedConfig(value: string) { + const match = value.match(/decodeURIComponent\("([^"\\]+)"\)/); + expect(match).not.toBeNull(); + const encoded = match?.[1] ?? ""; + expect(encoded).toMatch(configEncoding); + const executableEncoding = encoded.replaceAll("%25", "%"); + return JSON.parse(decodeURIComponent(executableEncoding)); + } + + it("uses only an origin-bound one-time capability in visited-page code", () => { + const protocol = new URL(bookmarklet).protocol; + expect(protocol).toBe("javascript:"); + expect(protocol).not.toBe("data:"); + expect(protocol).not.toBe("vbscript:"); + expect(decodedConfig(bookmarklet)).toEqual({ + endpoint: input.endpoint, + user_id: input.userId, + capture_capability: input.capability, + expected_origin: input.origin, + domain: input.domain, + sensitivity: input.sensitivity, + }); + expect(bookmarklet).toContain("location.origin!==expected_origin"); + expect(bookmarklet).not.toContain("capture_token"); + expect(bookmarklet).not.toContain("Authorization"); + expect(bookmarklet).not.toContain("agent_api_key"); + }); + + it("uses a CORS-safelisted opaque transport without claiming the write succeeded", () => { + expect(bookmarklet).toContain('await fetch(endpoint,{method:"POST",mode:"no-cors",body:JSON.stringify(body)})'); + expect(bookmarklet).not.toContain("headers:"); + expect(bookmarklet).not.toContain("Content-Type"); + expect(bookmarklet).not.toContain(".ok"); + expect(bookmarklet).not.toContain(".status"); + expect(bookmarklet).toContain("Alice clip request submitted. Verify it in the Alice Inbox."); + expect(bookmarklet).toContain("Alice clip request failed before submission."); + }); + + it("does not prompt the hostile page context for endpoint, identity, or reusable credentials", () => { + expect(bookmarklet).not.toContain("Alice API endpoint"); + expect(bookmarklet).not.toContain("Alice user id"); + expect(bookmarklet).not.toContain("clipper token"); + expect(bookmarklet.match(/prompt\(/g)).toHaveLength(1); + expect(bookmarklet).toContain('prompt("Optional note","")'); + }); + + it("round-trips adversarial Unicode inputs through a strict non-executable encoding", () => { + const adversarial = `double" single' slash\\ 100% / %41 close\u2028\u2029 café 雪 😀`; + const adversarialInput = { + endpoint: `https://example.com/capture/${adversarial}`, + userId: `user-${adversarial}`, + capability: `alice_clip_${adversarial}`, + origin: `https://example.com/${adversarial}`, + domain: "professional" as const, + sensitivity: "private" as const, + }; + const injected = buildBrowserClipperBookmarklet({ + ...adversarialInput, + }); + + expect(decodedConfig(injected)).toEqual({ + endpoint: adversarialInput.endpoint, + user_id: adversarialInput.userId, + capture_capability: adversarialInput.capability, + expected_origin: adversarialInput.origin, + domain: adversarialInput.domain, + sensitivity: adversarialInput.sensitivity, + }); + expect(injected).not.toContain(""); + expect(injected).not.toContain("\\"); + expect(injected).not.toContain("\u2028"); + expect(injected).not.toContain("\u2029"); + }); +}); diff --git a/apps/web/components/entity-detail.tsx b/apps/web/components/entity-detail.tsx index 1668a576..5e1114ab 100644 --- a/apps/web/components/entity-detail.tsx +++ b/apps/web/components/entity-detail.tsx @@ -56,7 +56,7 @@ export function EntityDetail({ entity, source, unavailableReason }: EntityDetail
{unavailableReason ? ( -
+

Detail read

{unavailableReason}

diff --git a/apps/web/components/entity-edge-list.tsx b/apps/web/components/entity-edge-list.tsx index 0e368ab6..d62a25a5 100644 --- a/apps/web/components/entity-edge-list.tsx +++ b/apps/web/components/entity-edge-list.tsx @@ -62,7 +62,7 @@ export function EntityEdgeList({
{unavailableReason ? ( -
+

Edge read

{unavailableReason}

@@ -110,7 +110,11 @@ export function EntityEdgeList({
- {unavailableReason ?

Live edge read failed: {unavailableReason}

: null} + {unavailableReason ? ( +

+ Live edge read failed: {unavailableReason} +

+ ) : null}
{edges.map((edge) => ( diff --git a/apps/web/components/entity-list.tsx b/apps/web/components/entity-list.tsx index a0fbb101..f82c1c0a 100644 --- a/apps/web/components/entity-list.tsx +++ b/apps/web/components/entity-list.tsx @@ -71,7 +71,11 @@ export function EntityList({
- {unavailableReason ?

Live list read failed: {unavailableReason}

: null} + {unavailableReason ? ( +

+ Live list read failed: {unavailableReason} +

+ ) : null}
{entities.map((entity) => ( diff --git a/apps/web/components/memory-label-list.tsx b/apps/web/components/memory-label-list.tsx index f646d7c9..a92a2f80 100644 --- a/apps/web/components/memory-label-list.tsx +++ b/apps/web/components/memory-label-list.tsx @@ -42,6 +42,21 @@ export function MemoryLabelList({ ); } + if (source === "unavailable") { + return ( + +
+

Label read unavailable

+

{unavailableReason ?? "Memory labels could not be loaded for this memory."}

+
+
+ ); + } + return ( { + afterEach(() => { + cleanup(); + }); + + it("does not turn unavailable revision history into a successful empty result", () => { + render( + , + ); + + expect( + screen.getByRole("heading", { name: "Revision history unavailable" }), + ).toBeInTheDocument(); + expect(screen.getByRole("alert")).toHaveTextContent("detail unavailable"); + expect(screen.queryByText("No revisions returned")).not.toBeInTheDocument(); + }); + + it("does not fabricate zero label counts when label history is unavailable", () => { + render( + , + ); + + expect(screen.getByRole("heading", { name: "Review labels unavailable" })).toBeInTheDocument(); + expect(screen.getByRole("alert")).toHaveTextContent("labels unavailable"); + expect(screen.queryByText("No labels yet")).not.toBeInTheDocument(); + expect(screen.queryByText("0 total labels")).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/memory-revision-list.tsx b/apps/web/components/memory-revision-list.tsx index b80b3769..f44c4c34 100644 --- a/apps/web/components/memory-revision-list.tsx +++ b/apps/web/components/memory-revision-list.tsx @@ -58,6 +58,21 @@ export function MemoryRevisionList({ ); } + if (source === "unavailable") { + return ( + +
+

Revision read unavailable

+

{unavailableReason ?? "Revision history could not be loaded for this memory."}

+
+
+ ); + } + return (
diff --git a/apps/web/components/vnext-brain-workspace.tsx b/apps/web/components/vnext-brain-workspace.tsx index 9d4a32bd..8a73ffb7 100644 --- a/apps/web/components/vnext-brain-workspace.tsx +++ b/apps/web/components/vnext-brain-workspace.tsx @@ -39,6 +39,7 @@ import { generateVNextProjectUpdate, generateVNextWeeklySynthesis, getVNextWorkspace, + issueVNextBrowserClipCapability, patchVNextSchedulerWorkflow, rateVNextArtifactQuality, reviewVNextArtifact, @@ -59,7 +60,6 @@ import { EmptyState } from "./empty-state"; import { SectionCard } from "./section-card"; import { StatusBadge } from "./status-badge"; import { - BROWSER_CLIPPER_BOOKMARKLET, COMPARISON_ARTIFACT_TYPES, FIXTURE_DOCTOR, INITIAL_CONNECTORS, @@ -79,6 +79,7 @@ import { asDomain, asRecord, asSensitivity, + buildBrowserClipperBookmarklet, connectorHealth, createSummary, domainLabel, @@ -121,12 +122,12 @@ export function VNextBrainWorkspace({ const [pendingAction, setPendingAction] = useState(""); const [statusText, setStatusText] = useState( liveModeReady - ? "Loading live vNext workspace from the local API." - : "Demo mode is using fixture data. Add local API config to use live mode.", + ? "Loading live vNext workspace from the trusted API." + : "Demo mode is using fixture data. Add trusted API config to use live mode.", ); const [statusTone, setStatusTone] = useState<"info" | "success" | "danger">("info"); const [actionLog, setActionLog] = useState([ - liveModeReady ? "Live workspace is default for this local configuration." : "Fixture demo mode is active.", + liveModeReady ? "Live workspace is default for this trusted configuration." : "Fixture demo mode is active.", ]); const [operatorAgentApiKey, setOperatorAgentApiKey] = useState(""); const [operatorAgentApiKeyActive, setOperatorAgentApiKeyActive] = useState(false); @@ -171,7 +172,7 @@ export function VNextBrainWorkspace({ const [answer, setAnswer] = useState({ question, summary: - "Ask Alice will call the live context-pack endpoint when local API configuration is present.", + "Ask Alice will call the live context-pack endpoint when trusted API configuration is present.", memoriesUsed: [], contradictions: [], why: ["No retrieval has run yet in this session."], @@ -217,6 +218,8 @@ export function VNextBrainWorkspace({ const [localFolderIgnores, setLocalFolderIgnores] = useState("generated,.git,node_modules,.venv,.cache"); const [browserClipUrl, setBrowserClipUrl] = useState("https://example.test/article"); const [browserClipSelection, setBrowserClipSelection] = useState("Fact: Browser clipper test content remains untrusted."); + const [browserClipperPreparedOrigin, setBrowserClipperPreparedOrigin] = useState(""); + const [browserClipperExpiresAt, setBrowserClipperExpiresAt] = useState(""); const dailyArtifact = latestArtifact(workspace.artifacts, "daily_brief"); const weeklyArtifact = latestArtifact(workspace.artifacts, "weekly_synthesis"); @@ -321,6 +324,11 @@ export function VNextBrainWorkspace({ } }, [selectedConnectorId, workspace.connectorHealth]); + useEffect(() => { + setBrowserClipperPreparedOrigin(""); + setBrowserClipperExpiresAt(""); + }, [apiBaseUrl, browserClipUrl, connectorDomain, connectorSensitivity, userId]); + function handleOperatorAgentApiKeyChange(value: string) { clearVNextOperatorAgentApiKey(); setOperatorAgentApiKeyActive(false); @@ -364,7 +372,7 @@ export function VNextBrainWorkspace({ async function runLiveAction(label: string, action: () => Promise, successMessage: string) { if (!liveModeReady || !apiBaseUrl || !userId) { setStatusTone("danger"); - setStatusText("Live write is unavailable without local API configuration. Use demo mode for fixture actions."); + setStatusText("Live write is unavailable without trusted API configuration. Use demo mode for fixture actions."); return; } setPendingAction(label); @@ -1497,6 +1505,57 @@ export function VNextBrainWorkspace({ ); } + async function handlePrepareBrowserClipper() { + if (!liveModeReady || !apiBaseUrl || !userId) { + setStatusTone("danger"); + setStatusText("A one-time browser clip requires a live local Alice API session."); + return; + } + if (!navigator.clipboard?.writeText) { + setStatusTone("danger"); + setStatusText("Clipboard access is unavailable. Open Alice on localhost or HTTPS and allow clipboard access."); + return; + } + + let origin: string; + try { + const targetUrl = new URL(browserClipUrl); + if ( + (targetUrl.protocol !== "http:" && targetUrl.protocol !== "https:") || + targetUrl.origin === "null" + ) { + throw new TypeError("unsupported browser origin"); + } + origin = targetUrl.origin; + } catch { + setStatusTone("danger"); + setStatusText("Enter the full HTTP or HTTPS page URL you intend to clip."); + return; + } + + await runLiveAction( + "Preparing one-time browser clip...", + async () => { + const issued = await issueVNextBrowserClipCapability(apiBaseUrl, { + user_id: userId, + origin, + }); + const bookmarklet = buildBrowserClipperBookmarklet({ + endpoint: browserClipperEndpoint, + userId, + capability: issued.capability, + origin: issued.origin, + domain: connectorDomain, + sensitivity: connectorSensitivity, + }); + await navigator.clipboard.writeText(bookmarklet); + setBrowserClipperPreparedOrigin(issued.origin); + setBrowserClipperExpiresAt(issued.expires_at); + }, + "One-time browser clipper copied. Paste it into a bookmark URL and use it before it expires.", + ); + } + async function handleRunDoctor(fixSafe: boolean) { if (fixSafe && typeof window !== "undefined" && !window.confirm("Run vNext doctor --fix-safe?")) { return; @@ -1596,7 +1655,7 @@ export function VNextBrainWorkspace({ />

The key is held only in this mounted browser session, cleared on edit, clear, or - unmount, and forwarded only to loopback /v0/vnext requests. It is never + unmount, and forwarded only to trusted loopback or same-origin HTTPS /v0/vnext requests. It is never read from environment variables, local storage, URLs, logs, or error output.

@@ -2875,7 +2934,7 @@ export function VNextBrainWorkspace({ ); }) ) : ( - + )}
@@ -3084,7 +3143,7 @@ export function VNextBrainWorkspace({
{selectedConnector.id === "browser_clipper" ? (
- + Default domain: {domainLabel(connector.defaultDomain)} Default sensitivity: {sensitivityLabel(connector.defaultSensitivity)} {connector.id === "browser_clipper" ? ( - Secret: {connectorHealth(workspace, connector.id)?.secret_configured ? "Configured" : "No secret"} + Direct API secret: {connectorHealth(workspace, connector.id)?.secret_configured ? "Configured" : "No secret"} ) : null} Captured: {connectorHealth(workspace, connector.id)?.items_captured ?? 0} Failed: {connectorHealth(workspace, connector.id)?.items_failed ?? 0} @@ -3196,23 +3255,37 @@ export function VNextBrainWorkspace({
{selectedConnector.id === "browser_clipper" ? (
-
- -