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}
-
-
-
-
+
+
Source: {openLoopSource === "live" ? "Live list" : "Fixture list"}
@@ -767,7 +776,7 @@ export default async function MemoriesPage({
)}
-
+
-
-
+
{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" ? (
-
Secret ref
+
Trusted-client capture secret ref
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" ? (
-
- Bookmarklet
-
-
- The bookmarklet never receives or prompts for an agent API key because it runs in
- the visited page context. It works only in zero-active-key local compatibility
- mode. After provisioning a key, capture with a trusted API client that sends both
- Bearer authentication and the configured capture token.
+ Alice issues a short-lived, one-time bookmarklet for the origin of the page URL
+ below. The visited page receives neither your reusable capture token nor your
+ agent API key. Prepare a fresh bookmarklet for each clip, paste it into a bookmark
+ URL, and use it before expiry.
+
+
+ Issue and copy one-time bookmarklet
+
+
+ {browserClipperPreparedOrigin && browserClipperExpiresAt ? (
+
+ Prepared for {browserClipperPreparedOrigin}; expires at {browserClipperExpiresAt}.
+ The capability remains only in the copied bookmarklet and is never rendered or persisted by this console.
+
+ ) : null}
+ {!liveModeReady ? (
+
Connect the local Alice API to issue a real one-time capability.
+ ) : null}
) : null}
{selectedConnector.id === "browser_clipper" ? (
-
Test clip URL
+
Page URL
{
const fetchMock = vi.fn();
@@ -12,6 +45,7 @@ describe("VNextBrainWorkspace operator authentication", () => {
clearVNextOperatorAgentApiKey();
window.localStorage.clear();
window.sessionStorage.clear();
+ fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
fetchMock.mockImplementation(() =>
Promise.resolve(
@@ -27,6 +61,57 @@ describe("VNextBrainWorkspace operator authentication", () => {
cleanup();
clearVNextOperatorAgentApiKey();
vi.unstubAllGlobals();
+ vi.unstubAllEnvs();
+ });
+
+ it("loads the remote same-origin page with the entered key without forwarding it to an evil origin", async () => {
+ const agentApiKey = "alice_sk_remote_operator_secret";
+ vi.stubEnv("NEXT_PUBLIC_ALICEBOT_API_BASE_URL", "https://alice.example.com");
+ vi.stubEnv("NEXT_PUBLIC_ALICEBOT_USER_ID", "user-1");
+ fetchMock.mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
+ const url = String(input);
+ const authorization = new Headers(init?.headers).get("Authorization");
+ if (url.startsWith("https://alice.example.com/") && authorization === `Bearer ${agentApiKey}`) {
+ return Promise.resolve(
+ new Response(JSON.stringify(EMPTY_LIVE_WORKSPACE), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ }),
+ );
+ }
+ return Promise.resolve(
+ new Response(JSON.stringify({ detail: "Authentication required" }), {
+ status: 401,
+ headers: { "Content-Type": "application/json" },
+ }),
+ );
+ });
+
+ render(await VNextPage({}));
+
+ expect(screen.getByText("Live default")).toBeInTheDocument();
+ await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
+ const input = screen.getByLabelText("Unbound admin_agent API key");
+ fireEvent.change(input, { target: { value: agentApiKey } });
+ await waitFor(() =>
+ expect(screen.getByRole("button", { name: "Use key for this session" })).toBeEnabled(),
+ );
+ fireEvent.click(screen.getByRole("button", { name: "Use key for this session" }));
+
+ await waitFor(() =>
+ expect(
+ screen.getAllByText("Live vNext workspace loaded with operator authentication.").length,
+ ).toBeGreaterThan(0),
+ );
+ const [liveUrl, liveInit] = fetchMock.mock.calls[1] as [string, RequestInit];
+ expect(liveUrl).toBe("https://alice.example.com/v0/vnext/workspace?user_id=user-1");
+ expect(new Headers(liveInit.headers).get("Authorization")).toBe(`Bearer ${agentApiKey}`);
+
+ await requestJson("https://evil.example", "/v0/vnext/workspace").catch(() => null);
+ const [evilUrl, evilInit] = fetchMock.mock.calls[2] as [string, RequestInit];
+ expect(evilUrl).toBe("https://evil.example/v0/vnext/workspace");
+ expect(new Headers(evilInit.headers).get("Authorization")).toBeNull();
+ expect(fetchMock.mock.calls.map(([url]) => String(url)).join(" ")).not.toContain(agentApiKey);
});
it("holds an admin key only for the mounted local console session", async () => {
@@ -80,4 +165,71 @@ describe("VNextBrainWorkspace operator authentication", () => {
const [, unmountedInit] = fetchMock.mock.calls[4] as [string, RequestInit];
expect(new Headers(unmountedInit.headers).get("Authorization")).toBeNull();
});
+
+ it("copies a capability bookmarklet without rendering or persisting the capability", async () => {
+ const capability = "alice_clip_one_time_ui_secret";
+ const writeText = vi.fn().mockResolvedValue(undefined);
+ vi.stubGlobal("navigator", { clipboard: { writeText } });
+ fetchMock.mockImplementation((input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url.endsWith("/v0/vnext/connectors/browser-clipper/capabilities")) {
+ return Promise.resolve(
+ new Response(
+ JSON.stringify({
+ status: "issued",
+ capability,
+ origin: "https://example.com",
+ expires_at: "2026-07-21T12:02:00Z",
+ one_time: true,
+ }),
+ { status: 200, headers: { "Content-Type": "application/json" } },
+ ),
+ );
+ }
+ return Promise.resolve(
+ new Response(JSON.stringify({ detail: "Authentication required" }), {
+ status: 401,
+ headers: { "Content-Type": "application/json" },
+ }),
+ );
+ });
+
+ render(
+
,
+ );
+ await waitFor(() => expect(fetchMock).toHaveBeenCalled());
+
+ fireEvent.change(screen.getByLabelText("Connector"), {
+ target: { value: "browser_clipper" },
+ });
+ fireEvent.change(screen.getByLabelText("Page URL"), {
+ target: { value: "https://example.com/article" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Issue and copy one-time bookmarklet" }));
+
+ await waitFor(() => expect(writeText).toHaveBeenCalledTimes(1));
+ const copiedBookmarklet = String(writeText.mock.calls[0]?.[0]);
+ expect(copiedBookmarklet).toContain(capability);
+ expect(copiedBookmarklet).not.toContain("capture_token");
+ expect(copiedBookmarklet).not.toContain("Authorization");
+
+ const capabilityCall = fetchMock.mock.calls.find(([url]) =>
+ String(url).endsWith("/v0/vnext/connectors/browser-clipper/capabilities"),
+ ) as [string, RequestInit] | undefined;
+ expect(capabilityCall).toBeDefined();
+ expect(capabilityCall?.[0]).not.toContain(capability);
+ expect(JSON.parse(String(capabilityCall?.[1].body))).toEqual({
+ user_id: "user-1",
+ origin: "https://example.com",
+ });
+ expect(window.localStorage.length).toBe(0);
+ expect(window.sessionStorage.length).toBe(0);
+ expect(window.location.href).not.toContain(capability);
+ expect(document.documentElement.outerHTML).not.toContain(capability);
+ expect(screen.getByText(/Prepared for https:\/\/example.com/)).toBeInTheDocument();
+ });
});
diff --git a/apps/web/components/vnext-workspace-model.ts b/apps/web/components/vnext-workspace-model.ts
index 15859cfa..7a8ec889 100644
--- a/apps/web/components/vnext-workspace-model.ts
+++ b/apps/web/components/vnext-workspace-model.ts
@@ -1249,8 +1249,59 @@ export const COMPARISON_ARTIFACT_TYPES = [
{ artifactType: "contradiction_report", label: "Contradiction Report" },
];
-export const BROWSER_CLIPPER_BOOKMARKLET =
- 'javascript:(async()=>{try{const endpoint=prompt("Alice API endpoint","http://127.0.0.1:8000/v0/vnext/connectors/browser-clipper/capture");if(!endpoint)return;const user_id=prompt("Alice user id","00000000-0000-0000-0000-000000000001");if(!user_id)return;const capture_token=prompt("Optional Alice clipper token","");const user_note=prompt("Optional note","");const s=window.getSelection().toString();const body={user_id,url:location.href,title:document.title,selected_text:s||null,page_text:s?null:document.body.innerText.slice(0,20000),user_note:user_note||null,domain:"professional",sensitivity:"private"};if(capture_token)body.capture_token=capture_token;const r=await fetch(endpoint,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)});alert(r.ok?"Alice clip saved":"Alice clip failed: "+r.status)}catch(e){alert("Alice clip failed")}})();';
+export type BrowserClipperBookmarkletInput = {
+ endpoint: string;
+ userId: string;
+ capability: string;
+ origin: string;
+ domain: Domain;
+ sensitivity: Sensitivity;
+};
+
+const BOOKMARKLET_CONFIG_ENCODING = /^(?:[A-Za-z0-9._~-]|%25[0-9A-F]{2})+$/;
+
+function encodeBrowserClipperConfig(input: BrowserClipperBookmarkletInput) {
+ const componentEncoded = encodeURIComponent(
+ JSON.stringify({
+ endpoint: input.endpoint,
+ user_id: input.userId,
+ capture_capability: input.capability,
+ expected_origin: input.origin,
+ domain: input.domain,
+ sensitivity: input.sensitivity,
+ }),
+ ).replace(/[!'()*]/g, (character) =>
+ `%${character.charCodeAt(0).toString(16).toUpperCase()}`,
+ );
+ // A javascript: URL is percent-decoded once before execution. Preserve the
+ // component encoding through that URL layer so runtime decodeURIComponent
+ // receives data, never source text.
+ const encoded = componentEncoded.replaceAll("%", "%25");
+ if (!BOOKMARKLET_CONFIG_ENCODING.test(encoded)) {
+ throw new Error("browser clipper config encoding failed");
+ }
+ return encoded;
+}
+
+export function buildBrowserClipperBookmarklet({
+ endpoint,
+ userId,
+ capability,
+ origin,
+ domain,
+ sensitivity,
+}: BrowserClipperBookmarkletInput) {
+ const encodedConfig = encodeBrowserClipperConfig({
+ endpoint,
+ userId,
+ capability,
+ origin,
+ domain,
+ sensitivity,
+ });
+
+ return `javascript:(async()=>{try{const {endpoint,user_id,capture_capability,expected_origin,domain,sensitivity}=JSON.parse(decodeURIComponent("${encodedConfig}"));if(location.origin!==expected_origin){alert("This Alice clip is bound to "+expected_origin+". Prepare a new clip for this page.");return;}const user_note=prompt("Optional note","");const selection=window.getSelection()?.toString()??"";const body={user_id,url:location.href,title:document.title,selected_text:selection||null,page_text:selection?null:(document.body?.innerText??"").slice(0,20000),user_note:user_note||null,capture_capability,domain,sensitivity};await fetch(endpoint,{method:"POST",mode:"no-cors",body:JSON.stringify(body)});alert("Alice clip request submitted. Verify it in the Alice Inbox.")}catch{alert("Alice clip request failed before submission.")}})();`;
+}
export function scheduleValue(workflow: VNextSchedulerStatus["workflows"][number], key: string, fallback: string) {
const schedule = asRecord(workflow.schedule_json);
diff --git a/apps/web/lib/api.test.ts b/apps/web/lib/api.test.ts
index b1cb36c9..825d70f1 100644
--- a/apps/web/lib/api.test.ts
+++ b/apps/web/lib/api.test.ts
@@ -72,6 +72,7 @@ import {
getContinuityRetrievalEvaluation,
hasLiveApiConfig,
isLocalApiBaseUrl,
+ issueVNextBrowserClipCapability,
requestJson,
sanitizeApiBaseUrl,
sanitizePublicErrorText,
@@ -117,7 +118,7 @@ describe("api helpers", () => {
expect(pageModeLabel("mixed")).toBe("Mixed fallback");
});
- it("keeps live console reads loopback-only", () => {
+ it("allows live console reads only on loopback or the exact browser HTTPS origin", () => {
expect(isLocalApiBaseUrl("http://127.0.0.1:8000")).toBe(true);
expect(isLocalApiBaseUrl("https://[::1]:8443")).toBe(true);
expect(isLocalApiBaseUrl("ftp://localhost:8000")).toBe(false);
@@ -128,15 +129,53 @@ describe("api helpers", () => {
userId: "user-1",
}),
).toBe(true);
+
+ vi.stubGlobal("window", { location: { origin: "https://alice.example.com" } });
expect(
hasLiveApiConfig({
- apiBaseUrl: "https://api.example.com",
+ apiBaseUrl: "https://alice.example.com",
+ userId: "user-1",
+ }),
+ ).toBe(true);
+ expect(
+ hasLiveApiConfig({
+ apiBaseUrl: "https://evil.example",
userId: "user-1",
}),
).toBe(false);
expect(
hasLiveApiConfig({
- apiBaseUrl: "http://api.example.com",
+ apiBaseUrl: "http://alice.example.com",
+ userId: "user-1",
+ }),
+ ).toBe(false);
+ expect(
+ hasLiveApiConfig({
+ apiBaseUrl: "https://alice.example.com/api",
+ userId: "user-1",
+ }),
+ ).toBe(false);
+ expect(
+ hasLiveApiConfig({
+ apiBaseUrl: "https://user:secret@alice.example.com",
+ userId: "user-1",
+ }),
+ ).toBe(false);
+ });
+
+ it("uses the exact PUBLIC_ORIGIN for server-rendered live mode", () => {
+ vi.stubGlobal("window", undefined);
+ vi.stubEnv("PUBLIC_ORIGIN", "https://alice.example.com");
+
+ expect(
+ hasLiveApiConfig({
+ apiBaseUrl: "https://alice.example.com",
+ userId: "user-1",
+ }),
+ ).toBe(true);
+ expect(
+ hasLiveApiConfig({
+ apiBaseUrl: "https://evil.example",
userId: "user-1",
}),
).toBe(false);
@@ -297,8 +336,41 @@ describe("api helpers", () => {
);
});
- it("attaches the in-memory operator key only to loopback vNext routes", async () => {
+ it("issues browser-clip capabilities through the trusted vNext client without URL leakage", async () => {
+ const response = {
+ status: "issued" as const,
+ capability: "alice_clip_one_time_secret",
+ origin: "https://example.com",
+ expires_at: "2026-07-21T12:02:00Z",
+ one_time: true as const,
+ };
+ fetchMock.mockResolvedValue(
+ new Response(JSON.stringify(response), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ }),
+ );
+
+ await expect(
+ issueVNextBrowserClipCapability("http://127.0.0.1:8000", {
+ user_id: "user-1",
+ origin: "https://example.com",
+ }),
+ ).resolves.toEqual(response);
+
+ const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
+ expect(url).toBe("http://127.0.0.1:8000/v0/vnext/connectors/browser-clipper/capabilities");
+ expect(url).not.toContain(response.capability);
+ expect(init.method).toBe("POST");
+ expect(JSON.parse(String(init.body))).toEqual({
+ user_id: "user-1",
+ origin: "https://example.com",
+ });
+ });
+
+ it("attaches the in-memory operator key only to trusted vNext routes", async () => {
const agentApiKey = "alice_sk_operator_session_secret";
+ vi.stubGlobal("window", { location: { origin: "https://alice.example.com" } });
setVNextOperatorAgentApiKey(agentApiKey);
fetchMock.mockImplementation(() =>
Promise.resolve(
@@ -311,7 +383,10 @@ describe("api helpers", () => {
await requestJson("http://127.0.0.1:8000", "/v0/vnext");
await requestJson("https://localhost:8443", "/v0/vnext/workspace");
- await requestJson("https://api.example.com", "/v0/vnext/workspace");
+ await requestJson("https://alice.example.com", "/v0/vnext/workspace");
+ await requestJson("https://evil.example", "/v0/vnext/workspace");
+ await requestJson("http://alice.example.com", "/v0/vnext/workspace");
+ await requestJson("https://alice.example.com/api", "/v0/vnext/workspace");
await requestJson("http://127.0.0.1:8000", "/v0/threads");
await requestJson("http://127.0.0.1:8000", "/v1/providers");
await requestJson("http://127.0.0.1:8000", "/v0/vnextish/workspace");
@@ -323,6 +398,9 @@ describe("api helpers", () => {
expect(authorizationHeaders).toEqual([
`Bearer ${agentApiKey}`,
`Bearer ${agentApiKey}`,
+ `Bearer ${agentApiKey}`,
+ null,
+ null,
null,
null,
null,
@@ -336,6 +414,7 @@ describe("api helpers", () => {
});
it("keeps explicit Authorization headers authoritative over the in-memory operator key", async () => {
+ vi.stubGlobal("window", { location: { origin: "https://alice.example.com" } });
setVNextOperatorAgentApiKey("alice_sk_operator_session_secret");
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ ok: true }), {
@@ -344,7 +423,7 @@ describe("api helpers", () => {
}),
);
- await requestJson("http://127.0.0.1:8000", "/v0/vnext/workspace", {
+ await requestJson("https://alice.example.com", "/v0/vnext/workspace", {
headers: { Authorization: "Bearer explicit-session-token" },
});
diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts
index b4c8fcba..67975032 100644
--- a/apps/web/lib/api.ts
+++ b/apps/web/lib/api.ts
@@ -383,6 +383,14 @@ export type VNextConnectorConfigRecord = {
last_configured_at?: string | null;
};
+export type VNextBrowserClipCapability = {
+ status: "issued";
+ capability: string;
+ origin: string;
+ expires_at: string;
+ one_time: true;
+};
+
export type VNextConnectorStatusPayload = {
config: VNextConnectorConfigRecord;
health: VNextConnectorHealthRecord;
@@ -1955,8 +1963,55 @@ export function isLocalApiBaseUrl(apiBaseUrl: string) {
}
}
+function currentAliceWebOrigin() {
+ const configuredOrigin =
+ typeof window === "undefined" ? process.env.PUBLIC_ORIGIN ?? "" : window.location.origin;
+ const normalized = configuredOrigin.trim();
+ if (!normalized) {
+ return "";
+ }
+
+ try {
+ const parsed = new URL(normalized);
+ if (
+ parsed.protocol !== "https:" ||
+ parsed.username ||
+ parsed.password ||
+ parsed.pathname !== "/" ||
+ parsed.search ||
+ parsed.hash
+ ) {
+ return "";
+ }
+ return parsed.origin;
+ } catch {
+ return "";
+ }
+}
+
+export function isTrustedApiBaseUrl(apiBaseUrl: string) {
+ const normalized = sanitizeApiBaseUrl(apiBaseUrl);
+ if (!normalized) {
+ return false;
+ }
+ if (isLocalApiBaseUrl(normalized)) {
+ return true;
+ }
+
+ try {
+ const parsed = new URL(normalized);
+ return (
+ parsed.protocol === "https:" &&
+ parsed.pathname === "/" &&
+ parsed.origin === currentAliceWebOrigin()
+ );
+ } catch {
+ return false;
+ }
+}
+
export function hasLiveApiConfig(config: Pick
) {
- return Boolean(config.userId.trim() && isLocalApiBaseUrl(config.apiBaseUrl));
+ return Boolean(config.userId.trim() && isTrustedApiBaseUrl(config.apiBaseUrl));
}
export function combinePageModes(...modes: Array): PageDataMode {
@@ -2068,7 +2123,7 @@ function shouldAttachVNextOperatorAgentApiKey(apiBaseUrl: string, path: string)
const logicalPath = `/${path.replace(/^\/+/, "")}`;
return (
- isLocalApiBaseUrl(apiBaseUrl) &&
+ isTrustedApiBaseUrl(apiBaseUrl) &&
(logicalPath === "/v0/vnext" || logicalPath.startsWith("/v0/vnext/"))
);
}
@@ -3281,6 +3336,7 @@ export function captureVNextBrowserClip(
page_text?: string | null;
user_note?: string | null;
capture_token?: string | null;
+ capture_capability?: string | null;
domain?: string;
sensitivity?: string;
},
@@ -3291,6 +3347,20 @@ export function captureVNextBrowserClip(
});
}
+export function issueVNextBrowserClipCapability(
+ apiBaseUrl: string,
+ payload: { user_id: string; origin: string },
+) {
+ return requestJson(
+ apiBaseUrl,
+ "/v0/vnext/connectors/browser-clipper/capabilities",
+ {
+ method: "POST",
+ body: JSON.stringify(payload),
+ },
+ );
+}
+
export function updateVNextConnectorConfig(
apiBaseUrl: string,
connectorName: string,
diff --git a/apps/web/test/browser/navigation.spec.ts b/apps/web/test/browser/navigation.spec.ts
index 314b626f..d80d06c8 100644
--- a/apps/web/test/browser/navigation.spec.ts
+++ b/apps/web/test/browser/navigation.spec.ts
@@ -1,6 +1,8 @@
import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";
+import { buildBrowserClipperBookmarklet } from "../../components/vnext-workspace-model";
+
const routes = [
{ path: "/", heading: "Continuity and memory review console" },
{ path: "/vnext", heading: "True second-brain workspace" },
@@ -63,3 +65,88 @@ for (const path of unavailableByDefault) {
expect(response.status()).toBe(404);
});
}
+
+test("browser clipper uses an opaque CORS-safelisted one-time-capability transport", async ({ page }) => {
+ const captureEndpoint = "http://127.0.0.1:3200/v0/vnext/connectors/browser-clipper/capture";
+ const sourceOrigin = "http://127.0.0.1:3100";
+ const sourceUrl = `${sourceOrigin}/browser-clipper-test-source`;
+ const adversarialConfigValue = `quote"'\\ 100% / %41 \u2028\u2029 café 雪 😀`;
+ const userId = `user-${adversarialConfigValue}`;
+ const capability = `alice_clip_${adversarialConfigValue}`;
+ const fixtureTitle = "Alice browser clipper fixture";
+ const fixturePageText = "Untrusted article\n\nThe page body is untrusted input.\n\nClip once";
+ const requests: Array<{ method: string; body: string | null; headers: Record }> = [];
+ const dialogs: Array<{ type: string; message: string }> = [];
+
+ await page.route(sourceUrl, async (route) => {
+ await route.fulfill({
+ contentType: "text/html",
+ body: `
+
+
+ ${fixtureTitle}
+
+
+ Untrusted article
+ The page body is untrusted input.
+ Clip once
+
+
+
+ `,
+ });
+ });
+ await page.route(captureEndpoint, async (route) => {
+ requests.push({
+ method: route.request().method(),
+ body: route.request().postData(),
+ headers: await route.request().allHeaders(),
+ });
+ await route.fulfill({ status: 503, body: "unavailable" });
+ });
+ page.on("dialog", async (dialog) => {
+ dialogs.push({ type: dialog.type(), message: dialog.message() });
+ if (dialog.type() === "prompt") {
+ await dialog.accept("Source note from the visited page");
+ return;
+ }
+ await dialog.accept();
+ });
+
+ await page.goto(sourceUrl);
+ await expect(page).toHaveTitle(fixtureTitle);
+ await expect(page.locator("script")).toHaveCount(0);
+ const bookmarklet = buildBrowserClipperBookmarklet({
+ endpoint: captureEndpoint,
+ userId,
+ capability,
+ origin: sourceOrigin,
+ domain: "professional",
+ sensitivity: "private",
+ });
+ await page.locator("#alice-clip").evaluate((element, href) => element.setAttribute("href", href), bookmarklet);
+ await page.locator("#alice-clip").click();
+
+ await expect.poll(() => requests.length).toBe(1);
+ await expect.poll(() => dialogs.length).toBe(2);
+ const capture = JSON.parse(requests[0].body ?? "null");
+ expect(capture).toEqual({
+ user_id: userId,
+ url: sourceUrl,
+ title: fixtureTitle,
+ selected_text: null,
+ page_text: fixturePageText,
+ user_note: "Source note from the visited page",
+ capture_capability: capability,
+ domain: "professional",
+ sensitivity: "private",
+ });
+ expect(capture).not.toHaveProperty("capture_token");
+ expect(requests[0].method).toBe("POST");
+ expect(requests[0].headers["content-type"]?.toLowerCase()).toBe("text/plain;charset=utf-8");
+ expect(requests[0].headers.authorization).toBeUndefined();
+ expect(dialogs).toEqual([
+ { type: "prompt", message: "Optional note" },
+ { type: "alert", message: "Alice clip request submitted. Verify it in the Alice Inbox." },
+ ]);
+});
diff --git a/apps/web/test/browser/review-dashboard-demo.spec.ts b/apps/web/test/browser/review-dashboard-demo.spec.ts
new file mode 100644
index 00000000..e055e70b
--- /dev/null
+++ b/apps/web/test/browser/review-dashboard-demo.spec.ts
@@ -0,0 +1,67 @@
+import { expect, test } from "@playwright/test";
+
+test("review dashboard fixture stays truthful and read-only at the redaction boundary", async ({
+ page,
+}) => {
+ await page.goto("/memories");
+
+ await expect(page.getByRole("heading", { level: 1, name: "Memory review workspace" })).toBeVisible();
+ await expect(page.getByRole("link", { name: /user\.preference\.merchant\.supplements/ })).toHaveAttribute(
+ "aria-current",
+ "page",
+ );
+ await expect(page.getByRole("button", { name: /redact/i })).toHaveCount(0);
+ await expect(page.locator('a[href^="/traces?trace="]')).toHaveCount(0);
+});
+
+test("review dashboard columns align mechanically without stretching the shell", async ({ page }) => {
+ await page.setViewportSize({ width: 1440, height: 1000 });
+ await page.goto("/memories");
+
+ const shellTopbar = await page.locator(".shell-topbar").boundingBox();
+ const shellMain = await page.locator(".shell-main").boundingBox();
+ expect(shellTopbar).not.toBeNull();
+ expect(shellMain).not.toBeNull();
+ expect(Math.abs((shellMain?.y ?? 0) - ((shellTopbar?.y ?? 0) + (shellTopbar?.height ?? 0)) - 22)).toBeLessThanOrEqual(1);
+
+ const memoryCards = page.locator(".memory-layout > .section-card");
+ await expect(memoryCards).toHaveCount(2);
+ const memoryList = memoryCards.nth(0);
+ const memoryDetail = memoryCards.nth(1);
+ const [listBox, detailBox] = await Promise.all([memoryList.boundingBox(), memoryDetail.boundingBox()]);
+ expect(listBox).not.toBeNull();
+ expect(detailBox).not.toBeNull();
+ expect(Math.abs((listBox?.y ?? 0) - (detailBox?.y ?? 0))).toBeLessThanOrEqual(1);
+
+ await page.goto("/traces");
+ const traceCards = page.locator(".split-layout > .section-card");
+ await expect(traceCards).toHaveCount(2);
+ const traceList = traceCards.nth(0);
+ const traceDetail = traceCards.nth(1);
+ const [traceListBox, traceDetailBox] = await Promise.all([
+ traceList.boundingBox(),
+ traceDetail.boundingBox(),
+ ]);
+ expect(traceListBox).not.toBeNull();
+ expect(traceDetailBox).not.toBeNull();
+ expect(Math.abs((traceListBox?.y ?? 0) - (traceDetailBox?.y ?? 0))).toBeLessThanOrEqual(1);
+});
+
+test("review dashboard remains bounded at a mobile viewport", async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.goto("/memories");
+
+ const dimensions = await page.evaluate(() => ({
+ clientWidth: document.documentElement.clientWidth,
+ scrollWidth: document.documentElement.scrollWidth,
+ }));
+ expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth);
+
+ const cards = page.locator(".memory-layout > .section-card");
+ await expect(cards).toHaveCount(2);
+ for (let index = 0; index < 2; index += 1) {
+ const box = await cards.nth(index).boundingBox();
+ expect(box).not.toBeNull();
+ expect((box?.x ?? 0) + (box?.width ?? 0)).toBeLessThanOrEqual(390);
+ }
+});
diff --git a/docs/alpha/README.md b/docs/alpha/README.md
index a937a9a0..a17fbb2f 100644
--- a/docs/alpha/README.md
+++ b/docs/alpha/README.md
@@ -19,6 +19,7 @@ Start here:
- [Local runtime](local-runtime.md)
- [Doctor](doctor.md)
- [Demo mode](demo-mode.md)
+- [Review dashboard demo](review-dashboard-demo.md)
- [Headless Ubuntu install](headless-ubuntu-install.md)
- [Agent integration](agent-integration.md)
- [MCP tools](mcp-tools.md)
@@ -31,6 +32,10 @@ Start here:
- [Agent output ingestion examples](agent-output-ingestion.md)
- [Dogfooding guide](dogfooding-guide.md)
- [Troubleshooting](troubleshooting.md)
+- [Backup and restore](backup-and-restore.md)
+- [Disaster recovery](../runbooks/disaster-recovery.md)
+- [Health and monitoring](../runbooks/health-and-monitoring.md)
+- [Upgrade v0.12.0 to current](../runbooks/upgrade-v0.12-to-current.md)
- [Known limitations](known-limitations.md)
- [Security and privacy](security-and-privacy.md)
- [Alpha onboarding](onboarding.md)
diff --git a/docs/alpha/agent-integration.md b/docs/alpha/agent-integration.md
index e178f80c..54a2859d 100644
--- a/docs/alpha/agent-integration.md
+++ b/docs/alpha/agent-integration.md
@@ -183,13 +183,17 @@ Custom agents calling the HTTP API authenticate with per-agent API keys. Create
alicebot agent keys create --agent-id openclaw --profile project_scoped_agent --label "OpenClaw laptop"
```
-The raw key (`alice_sk_...`) is printed exactly once; only its sha256 hash is stored. Export it and pass it on every agent HTTP call — never paste raw keys into scripts or docs:
+The raw key (`alice_sk_...`) is printed exactly once; only its sha256 hash is stored. Put it in an owner-only temporary curl config for HTTP calls. Curl receives only the config path in its process arguments; never paste raw keys into scripts or docs:
```bash
-export ALICE_AGENT_API_KEY=""
-
-curl -X POST http://127.0.0.1:8000/v0/vnext/memories/commit \
- -H "Authorization: Bearer $ALICE_AGENT_API_KEY" \
+ALICE_AGENT_API_KEY=""
+agent_curl_config="$(mktemp "${TMPDIR:-/tmp}/alice-agent-curl.XXXXXX")"
+chmod 600 "$agent_curl_config"
+trap 'rm -f "$agent_curl_config"' EXIT
+printf 'header = "Authorization: Bearer %s"\n' "$ALICE_AGENT_API_KEY" >"$agent_curl_config"
+
+curl --config "$agent_curl_config" \
+ -X POST http://127.0.0.1:8000/v0/vnext/memories/commit \
-H "Content-Type: application/json" \
-d '{"user_id": "00000000-0000-0000-0000-000000000001", "title": "Preferred planning format", "canonical_text": "The user prefers concise daily planning summaries."}'
```
diff --git a/docs/alpha/backup-and-restore.md b/docs/alpha/backup-and-restore.md
index 6f292ac4..1c0645da 100644
--- a/docs/alpha/backup-and-restore.md
+++ b/docs/alpha/backup-and-restore.md
@@ -91,6 +91,10 @@ the importing local user. Configure the intended embedding endpoint and run:
alice-memory reindex-embeddings --db /path/to/restored-memory.db
```
+Portable JSONL does not contain embedding vectors. A successful import and FTS
+recall therefore do not prove vector-search readiness; reindex against the
+intended provider and verify its signed-vector coverage before cutover.
+
For PostgreSQL upgrades or model changes, use
`alicebot vnext memories backfill-embeddings`; it rebuilds missing, unsigned,
and provider/model-incompatible vectors.
@@ -105,10 +109,15 @@ PostgreSQL utilities against the admin connection and protect the resulting
file as sensitive plaintext:
```bash
-pg_dump --format=custom --file=alice.dump "$DATABASE_ADMIN_URL"
+export PGHOST=db.internal PGPORT=5432 PGUSER=alicebot_admin PGDATABASE=alicebot
+export PGPASSWORD='from-your-secret-manager'
+pg_dump --format=custom --file=alice.dump
pg_restore --list alice.dump
```
+Using libpq environment variables keeps the credentialed DSN out of process
+arguments. Protect the environment and unset `PGPASSWORD` after the command.
+
Restore into a new database, apply the same Alice release's migrations, then
run integration and application smoke tests before cutover. Database roles,
extensions, and grants are deployment concerns; record them alongside the
@@ -126,3 +135,22 @@ Before `make migrate` on an existing installation:
4. run the upgrade on that restored copy;
5. verify capture, review/correction, recall, and export before upgrading the
live database.
+
+## Executed Phase 5 evidence
+
+The repository drill performs a quiesced SQLite physical copy/restore, a
+portable export/import/re-export fidelity check, a v0.12.0-to-current upgrade,
+and a disposable PostgreSQL dump/restore and migration upgrade:
+
+```bash
+./.venv/bin/python scripts/run_phase5_ops_evidence.py --backend all \
+ --output artifacts/phase5/ops-evidence.json
+```
+
+See the [disaster-recovery runbook](../runbooks/disaster-recovery.md),
+[health and monitoring](../runbooks/health-and-monitoring.md), and
+[v0.12.0 upgrade procedure](../runbooks/upgrade-v0.12-to-current.md). The
+workflow receipt is sanitized and excludes database URLs, secrets, paths, and
+memory content. It identifies an uncommitted carrier with the source HEAD/tree,
+an explicit clean/dirty state, and a deterministic snapshot digest rather than
+claiming that HEAD contains the working changes.
diff --git a/docs/alpha/demo-mode.md b/docs/alpha/demo-mode.md
index 60fb1fba..4a8c9e28 100644
--- a/docs/alpha/demo-mode.md
+++ b/docs/alpha/demo-mode.md
@@ -41,3 +41,8 @@ Safety expectations:
- synthetic names and identifiers only
After loading, open `/vnext` and inspect Inbox, Memory Review, Generated, Trace, Agent Activity, Doctor, and Connectors.
+
+For the public-API capture, review, accept, trace, and redaction walkthrough, use
+the [review dashboard demo](review-dashboard-demo.md). It distinguishes the
+source-review trace from the independent `/traces` view and keeps redaction on
+the governed API rather than claiming a web control that does not exist.
diff --git a/docs/alpha/first-run.md b/docs/alpha/first-run.md
index 5868dd8a..c107993c 100644
--- a/docs/alpha/first-run.md
+++ b/docs/alpha/first-run.md
@@ -30,7 +30,8 @@ For local live mode, `.env` must include `CORS_ALLOWED_ORIGINS=http://127.0.0.1:
Keyless local compatibility ends when the first active agent key is provisioned. The console key is
entered per mounted browser session and is never configured through `.env`, local storage, or the
-URL. The browser-clipper bookmarklet remains a zero-active-key compatibility path; after keys
-exist, use a trusted authenticated API client with both Bearer authentication and `capture_token`.
+URL. The trusted console can use that session key to issue an origin-bound, short-lived, one-time
+browser-clip capability. Prepare a fresh bookmarklet for every clip; the bookmarklet receives
+neither the reusable agent key nor a configured `capture_token`.
Use `pnpm --dir apps/web dev` only while editing the web UI. For long-running agent or Hermes sessions, prefer `make runtime` as the combined API plus web command, or use the `next start` command above when API is already running.
diff --git a/docs/alpha/headless-ubuntu-install.md b/docs/alpha/headless-ubuntu-install.md
index 55a3546c..f595a32f 100644
--- a/docs/alpha/headless-ubuntu-install.md
+++ b/docs/alpha/headless-ubuntu-install.md
@@ -18,6 +18,11 @@ Historical note: `v0.5.1-vnext-preview` and `v0.6.0-alpha-rc.2` are older milest
## Recommended Secure Access
+The default keyless posture trusts the local machine owner. It is not an
+internet-facing authentication mode: while no active agent key exists, a local
+caller can select the Alice `user_id`, and that identifier is not proof of
+identity. Keep both services on `127.0.0.1` and use an SSH tunnel:
+
Use an SSH tunnel from your laptop:
```bash
@@ -30,7 +35,17 @@ Then open this from your local browser:
http://127.0.0.1:3000/vnext
```
-Do not expose `/vnext`, the API, MCP, or browser clipper endpoint publicly by default. Keep services bound to localhost and add an authenticated reverse proxy only after the alpha path is working over SSH.
+Do not expose `/vnext`, the API, MCP, or browser clipper endpoint publicly by
+default. If remote access is required, first provision and test agent API keys,
+then place Alice behind a TLS-terminating authenticated reverse proxy, restrict
+`CORS_ALLOWED_ORIGINS` to the exact trusted UI origins, and restrict the host
+firewall so clients cannot bypass the proxy. Agent keys do not replace TLS or
+host isolation. A one-time browser-clipper capability authorizes only its bound
+page origin and one capture; it is not a general remote-access credential.
+
+Single-tenant remote hardening beyond these minimum constraints belongs to the
+separate cloud-deployment work. Multi-tenant hosting is not a supported alpha
+deployment.
## Inspect-Before-Run Install
diff --git a/docs/alpha/known-limitations.md b/docs/alpha/known-limitations.md
index fae4810d..e8907de2 100644
--- a/docs/alpha/known-limitations.md
+++ b/docs/alpha/known-limitations.md
@@ -15,7 +15,7 @@ This alpha is intentionally limited.
- OCR execution is not packaged
- PDF OCR is not packaged
- voice transcription execution is not packaged
-- browser clipper is a bookmarklet/MVP path only while zero active agent keys exist; once a key is provisioned, the bookmarklet cannot authenticate safely and capture requires a trusted API client with Bearer authentication plus `capture_token`
+- browser clipper remains a bookmarklet/MVP path: the trusted console must issue a new short-lived, origin-bound, one-time bookmarklet for each clip, and the user must verify opaque submissions in the Inbox
- scheduler is local
- model providers require user configuration
- secrets fallback is alpha-grade unless OS or managed secret provider is configured
diff --git a/docs/alpha/quickstart.md b/docs/alpha/quickstart.md
index 48355d82..8825f436 100644
--- a/docs/alpha/quickstart.md
+++ b/docs/alpha/quickstart.md
@@ -94,9 +94,10 @@ variable or stored in local storage, a URL, logs, or errors. `trusted_local_agen
for the full human/admin review surface.
The browser-clipper bookmarklet deliberately cannot receive or prompt for this key because it runs
-inside the visited page. It works only while zero active agent keys exist. After key provisioning,
-use a trusted API client that sends both `Authorization: Bearer ...` and the configured
-`capture_token` to the clipper endpoint.
+inside the visited page. From the authenticated Alice console, enter the page URL and choose
+**Issue and copy one-time bookmarklet**. Alice binds a short-lived, single-use capability to that
+page origin; the reusable agent key and any configured `capture_token` remain in trusted clients.
+Prepare a fresh bookmarklet for every clip and verify the result in the Inbox.
## Configure Embeddings (Recommended)
diff --git a/docs/alpha/review-dashboard-demo.md b/docs/alpha/review-dashboard-demo.md
new file mode 100644
index 00000000..8c4150d4
--- /dev/null
+++ b/docs/alpha/review-dashboard-demo.md
@@ -0,0 +1,140 @@
+# Review Dashboard Demo
+
+This is the bounded enterprise demo for the shipped review surfaces. It uses only
+existing public HTTP operations and the existing web dashboard; it does not add a
+demo-only route or mutation.
+
+## Prerequisites
+
+- Start Alice with PostgreSQL and the web app by following the [quickstart](quickstart.md).
+- Keep the API on a loopback interface unless agent-key authentication and a TLS reverse proxy are configured.
+- Set `ALICE_USER_ID` to the local Alice user UUID.
+- Use one of exactly two authentication postures for this full sequence:
+ - a fresh local-keyless user with **zero active agent keys**, leaving `ALICE_AGENT_API_KEY` unset; or
+ - an active, **unbound `admin_agent`** key for that same user, exported as `ALICE_AGENT_API_KEY`.
+ Create one without `--project-scope`, for example:
+ `alicebot agent keys create --agent-id review-dashboard-operator --profile admin_agent --label "Review dashboard demo"`.
+ Lower-privilege or project-bound keys cannot perform every review and redaction action below. Once any
+ active key exists, omitting the Bearer header also fails closed.
+- Set `ALICE_API_URL` to the API origin, normally `http://127.0.0.1:8000`.
+
+The examples below use `jq`. Build the optional Authorization header once in an
+owner-only temporary curl config. Curl receives only the config path in its
+process arguments, never the raw key:
+
+```bash
+auth_config="$(mktemp "${TMPDIR:-/tmp}/alice-review-demo.XXXXXX")"
+chmod 600 "$auth_config"
+trap 'rm -f "$auth_config"' EXIT
+: >"$auth_config"
+if [ -n "${ALICE_AGENT_API_KEY:-}" ]; then
+ printf 'header = "Authorization: Bearer %s"\n' "$ALICE_AGENT_API_KEY" >"$auth_config"
+fi
+auth_args=(--config "$auth_config")
+```
+
+Use synthetic text. Do not paste customer or production secrets into a demo.
+
+## 1. Capture a source
+
+```bash
+sentinel="DASHBOARD-DEMO-$(date +%s)"
+source_json="$({
+ printf '{"user_id":"%s","raw_text":"Decision: %s is accepted only after operator review.","title":"Review dashboard demo","domain":"professional","sensitivity":"private"}' \
+ "$ALICE_USER_ID" "$sentinel"
+} | curl --fail-with-body --silent --show-error \
+ "${auth_args[@]}" \
+ -H 'Content-Type: application/json' \
+ --data-binary @- \
+ "$ALICE_API_URL/v0/vnext/sources")"
+source_id="$(jq -er '.source_id' <<<"$source_json")"
+```
+
+Expected: HTTP 201, one source ID, and at least one candidate memory. Open
+`/vnext` to show the review inbox; the source is captured, not accepted.
+
+## 2. Review the source and identify its candidate
+
+```bash
+trace_json="$(curl --fail-with-body --silent --show-error \
+ "${auth_args[@]}" \
+ -H 'Content-Type: application/json' \
+ --data "$(jq -cn --arg user_id "$ALICE_USER_ID" \
+ '{user_id:$user_id,action:"review",review_note:"Reviewed in the enterprise demo."}')" \
+ "$ALICE_API_URL/v0/vnext/sources/$source_id/review")"
+memory_id="$(jq -er '.trace.candidate_memories[0].id' <<<"$trace_json")"
+jq '{trace_kind:.trace.trace_kind, summary:.trace.summary, sampling:.trace.sampling}' <<<"$trace_json"
+```
+
+Expected: `capture_to_brief`, the same source ID, a non-zero candidate-memory
+count, and `trace_complete: true` for this one-row demo. This is the trace embedded
+in the mutating source-review response.
+
+## 3. Accept the candidate
+
+```bash
+accept_json="$(curl --fail-with-body --silent --show-error \
+ "${auth_args[@]}" \
+ -H 'Content-Type: application/json' \
+ --data "$(jq -cn --arg user_id "$ALICE_USER_ID" '{user_id:$user_id,action:"accept"}')" \
+ "$ALICE_API_URL/v0/vnext/memories/$memory_id/review")"
+jq '{id:.memory.id,status:.memory.status}' <<<"$accept_json"
+```
+
+Expected: the same memory ID with `status: active`.
+
+- With an active key, open `/vnext`, enter the same unbound `admin_agent` key in
+ **Unbound admin_agent API key**, and inspect the accepted row in **Memory Review**.
+- Only in the zero-key local/demo posture may `/memories` and `/traces` be used as
+ separate review surfaces. Those pages are server-rendered and cannot forward
+ the `/vnext` browser-memory Bearer, so they must not be presented as live keyed
+ evidence. Do not invent a `/traces` record linkage from this source trace.
+
+## 4. Read the bounded source trace without another mutation
+
+```bash
+trace_json="$(curl --fail-with-body --silent --show-error \
+ "${auth_args[@]}" \
+ "$ALICE_API_URL/v0/vnext/traces/sources/$source_id?user_id=$ALICE_USER_ID")"
+jq '{trace_kind,summary,sampling,events}' <<<"$trace_json"
+```
+
+Expected: the same `capture_to_brief` trace now includes the accepted memory and
+its `review.item_accepted` event. This GET does not update source review metadata
+or append another source-review event. Do not claim that this bounded source trace
+is linked to one of the independently persisted records on `/traces` unless the
+response supplies that UUID linkage.
+
+## 5. Redact through the public API
+
+The web dashboard deliberately has no redaction button. Redaction is a
+human/admin-only lifecycle action and remains on the governed public API:
+
+```bash
+redact_json="$(curl --fail-with-body --silent --show-error \
+ "${auth_args[@]}" \
+ -H 'Content-Type: application/json' \
+ --data "$(jq -cn --arg user_id "$ALICE_USER_ID" --arg memory_id "$memory_id" \
+ '{user_id:$user_id,memory_id:$memory_id,reason:"Scripted enterprise demo cleanup."}')" \
+ "$ALICE_API_URL/v0/vnext/memories/redact")"
+jq '{status,forgotten_first,idempotent_replay,redaction_marker}' <<<"$redact_json"
+```
+
+Expected: `status: redacted` and `forgotten_first: true`. The automated
+role-separated test below verifies the surviving audit skeleton and proves that
+its governed memory, revision, and event content no longer contains the raw
+sentinel. Memory redaction intentionally does not erase the separately governed
+source or source chunks; archive/delete that source under the source retention
+policy if source-evidence removal is also required.
+
+## Automated proof
+
+- `tests/integration/test_review_dashboard_demo.py` runs capture, source review,
+ accept, read-only bounded-trace GET, and redaction in both zero-key and unbound
+ `admin_agent` modes against a role-separated migrated PostgreSQL database. It
+ proves the trace GET does not mutate source metadata/events, the audit skeleton
+ survives, and the sentinel is absent from the governed memory graph.
+- `apps/web/test/browser/review-dashboard-demo.spec.ts` proves fixture navigation
+ is explicit, selected rows use `aria-current`, the dashboard does not advertise
+ a linked trace it cannot prove, no web redaction control exists, and desktop and
+ mobile layouts stay bounded.
diff --git a/docs/alpha/security-and-privacy.md b/docs/alpha/security-and-privacy.md
index df4c7e87..b9a9a2cd 100644
--- a/docs/alpha/security-and-privacy.md
+++ b/docs/alpha/security-and-privacy.md
@@ -2,6 +2,20 @@
Alice public preview is local-first.
+## Deployment Trust Boundary
+
+**Keyless is local-machine-owner mode, not anonymous network mode.** Keep the
+API and web app on loopback and use an SSH tunnel for headless access. In a
+keyless deployment, local callers can select the Alice `user_id`; that value is
+not an authentication credential. Do not expose the API port to a LAN, public
+interface, container network with untrusted peers, or an untrusted browser.
+
+Once any active agent API key exists for a user, protected `/v0/vnext` requests
+for that user reject keyless access. Remote access requires active keys plus a
+TLS-terminating authenticated reverse proxy, a restrictive CORS allowlist, and
+host/firewall controls. Agent keys authenticate Alice calls; they do not encrypt
+traffic or harden the host.
+
Security posture:
- source evidence is review-only
@@ -18,10 +32,14 @@ Security posture:
- artifact get, feedback, quality-rating, review, export, and trace operations authorize the persisted artifact project/domain/sensitivity before returning content or applying a side effect; trace sources are filtered by the same exact-target policy
- the local `/vnext` console accepts an unbound `trusted_local_agent` or `admin_agent` key only through its password field, keeps it only in browser memory for the mounted session, and forwards it only to loopback `/v0/vnext` routes; it never reads the key from environment variables, local storage, URLs, logs, or errors
- `trusted_local_agent` does not grant human/admin review decisions such as artifact acceptance; use a dedicated unbound `admin_agent` key when those actions are needed and revoke it when no longer needed
-- the browser-clipper bookmarklet never embeds or prompts for an agent key because visited-page JavaScript is not a trusted credential context; it is a zero-active-key compatibility path only, and keyed deployments must use a trusted API client with Bearer authentication plus `capture_token`
+- the browser-clipper bookmarklet never embeds or prompts for an agent key or a reusable `capture_token`; a trusted Alice UI issues a short-lived, origin-bound, one-time capture capability, while trusted non-browser API clients may use Bearer authentication plus `capture_token`
- MCP servers bound with `ALICE_AGENT_API_KEY` expose only the core surface; legacy handlers fail closed because they do not all implement the same persisted-target authorization contract
- the managed SQLite directory is owner-only; database sidecars and exports are also owner-only
+The complete shipped-product threat model, evidence ledger, and open proof gaps
+are in [`docs/security/`](../security/README.md). Those Stage A materials prepare
+an independent review; they are not a security certification.
+
Recommended alpha defaults:
```bash
diff --git a/docs/deployment/single-tenant-self-hosted.md b/docs/deployment/single-tenant-self-hosted.md
new file mode 100644
index 00000000..5e3a84a4
--- /dev/null
+++ b/docs/deployment/single-tenant-self-hosted.md
@@ -0,0 +1,596 @@
+# Single-Tenant Self-Hosted Deployment
+
+This is the supported Phase 5 deployment shape for one Alice owner on one
+Linux host. It is a hardened operating contract, not cloud-provider IaC and
+not evidence that Alice was exercised on a public cloud. The checked-in CI
+smoke validates the configuration contract only. A release cannot claim an
+executed cloud deployment until the owner supplies the sanitized
+`owner_real_host_deployment_receipt` described below.
+
+## Claim boundary
+
+This guide covers:
+
+- one VM or container host controlled by one owner;
+- one Alice API process and one prebuilt Alice web process, both bound only to
+ loopback;
+- Caddy as the only public process, terminating public TLS, requiring a valid
+ operator client certificate, and proxying to the two loopback services;
+- PostgreSQL 16 with pgvector 0.8 or newer over certificate-verified TLS;
+- separate `alicebot_admin` migration and `alicebot_app` runtime roles;
+- deployment-time environment/secret injection, scheduled encrypted backups,
+ restore drills, and conservative upgrades.
+
+It does **not** provide or claim multi-tenant isolation, an SLA, high availability,
+zero-downtime deploys, a managed database, managed backup,
+managed alert delivery, automatic failover, or cloud-provider support. The
+operator owns the host, PostgreSQL, DNS, certificate reachability, monitoring,
+backup retention, restore testing, and incident response.
+
+## Security posture
+
+**Keyless equals local-machine-owner trust.** Before the first active agent key
+exists, local processes that can reach the loopback API share the machine
+owner's trust. That compatibility mode is not internet authentication. Never
+publish Alice while it is keyless, and never expose ports 3000 or 8000 through
+a public address, port-forward, load balancer, or container publish rule.
+
+Once any active agent key exists, keyless `/v0/vnext` calls are rejected. The
+browser console requires an unbound `admin_agent` key for the complete review
+surface and holds it only in browser memory for that mounted session. Create
+that key while the firewall is still closed, before opening the firewall to
+Caddy. Keep the raw value in the owner's secret manager and revoke it when its
+operator session is no longer needed.
+
+The legacy compatibility routers remain unmounted with
+`ALICE_LEGACY_SURFACES=0`, and the production legacy gate remains closed with
+`LEGACY_V0_ENABLED_OUTSIDE_DEV=false`. The production middleware distinguishes
+the agent-key-protected `/v0/vnext` family from legacy `/v0` routes: vNext may
+pass through the authenticated TLS proxy, while legacy routes stay disabled.
+This route distinction and the agent-key auth sweep are release-gated runtime
+contracts, not something the proxy should emulate.
+
+Do not add another proxy without revisiting Caddy's trust configuration and
+Alice's exact `TRUSTED_PROXY_IPS` allowlist. Caddy must preserve the real client
+IP. The proxy authentication control is mutual TLS (mTLS), not HTTP Basic,
+because Alice needs the HTTP `Authorization` header for its own Bearer agent
+key.
+
+## Topology and firewall
+
+```text
+browser/agent
+ |
+ | mTLS HTTPS :443 (HTTP :80 only for ACME and redirect)
+ v
+Caddy on the one host
+ |-- 127.0.0.1:3000 Alice web
+ `-- 127.0.0.1:8000 Alice API
+ |
+ `-- TLS verify-full --> PostgreSQL 16 + pgvector
+```
+
+Permit inbound TCP 80 and 443 to Caddy. Deny inbound 3000, 8000, 5432, and the
+Caddy admin port. If PostgreSQL is remote, its network policy should allow
+5432 only from this host's private egress identity. The application host must
+be able to resolve the database hostname exactly as it appears in the server
+certificate.
+
+## 1. Prepare a pinned release
+
+Use a fresh non-root service account and a verified Alice release tag or exact
+commit. Record the tag and SHA in the deployment receipt. Do not deploy from a
+dirty checkout. Install Python 3.12+, Node.js, the repository-declared pnpm
+version, PostgreSQL 16 client tools, and a pinned/approved Caddy package. The
+reference assets use host processes and no container image. If an operator
+translates them to containers, every image must use an immutable
+`@sha256:` reference; a floating tag is not equivalent evidence.
+
+```bash
+git clone https://github.com/samrusani/AliceBot.git /opt/alicebot
+cd /opt/alicebot
+git fetch --tags --force
+ALICE_RELEASE_REF='replace-with-verified-tag-or-sha'
+git checkout --detach "$ALICE_RELEASE_REF"
+git status --short
+git rev-parse HEAD
+
+python3 -m venv .venv
+./.venv/bin/python -m pip install --require-virtualenv .
+corepack enable
+corepack prepare pnpm@10.23.0 --activate
+pnpm --dir apps/web install --frozen-lockfile
+```
+
+The example uses an editable checkout so Alembic resources and scripts stay
+available. Pin and inventory the host packages in the operator's own image or
+configuration-management layer.
+
+## 2. Inject configuration and secrets
+
+Start from
+[`packaging/cloud/single-tenant.env.example`](../../packaging/cloud/single-tenant.env.example).
+It is a template, not a usable environment file. A deployment system or secret
+manager must replace `${ALICEBOT_DB_APP_PASSWORD}` while rendering an
+owner-only API runtime file. Do not ask systemd `EnvironmentFile=` to expand
+it; it does not perform shell interpolation. Install the PostgreSQL CA bundle
+separately at `/run/secrets/alicebot/postgres-ca.pem` and make both artifacts
+readable only by the Alice service account.
+
+Migration and recovery automation must render the separate admin DSN below
+directly into the one-shot process environment. This is a shape contract, not
+a usable credential:
+
+```dotenv
+DATABASE_ADMIN_URL="postgresql://alicebot_admin:${ALICEBOT_DB_ADMIN_PASSWORD}@db.alice.internal:5432/alicebot?sslmode=verify-full&sslrootcert=/run/secrets/alicebot/postgres-ca.pem"
+```
+
+The runtime and admin examples deliberately name the same normalized
+host, port, database, TLS mode, and CA while using distinct roles and passwords.
+DATABASE_ADMIN_URL is required for migrations, but it is not a production API
+startup requirement. DATABASE_ADMIN_URL is absent from the API runtime
+environment and must not be copied into its supervisor unit.
+
+Before substitution, **percent-encode each database password as URL userinfo**
+(RFC 3986), or generate it exclusively from the URL-safe unreserved alphabet
+`A-Z a-z 0-9 - . _ ~`. Never insert raw `@`, `:`, `/`, `?`, `#`, or `%`
+characters into either DSN; they can change how the URL is parsed. Encode only
+the password value, not the complete DSN, and keep the raw and encoded values
+out of command arguments and receipts.
+
+Required invariants:
+
+- `APP_ENV=production`;
+- `APP_HOST=127.0.0.1` and `ALICE_WEB_HOST=127.0.0.1`;
+- one exact origin such as `https://alice.example.com` in `PUBLIC_ORIGIN`,
+ `CORS_ALLOWED_ORIGINS`, and `NEXT_PUBLIC_ALICEBOT_API_BASE_URL`;
+- no wildcard CORS and `CORS_ALLOW_CREDENTIALS=false`;
+- `TRUST_PROXY_HEADERS=true` with the exact trusted peer
+ `TRUSTED_PROXY_IPS=127.0.0.1`;
+- unique runtime and migration database passwords injected into their
+ respective process environments at deploy time, never committed;
+- `sslmode=verify-full` and the same absolute `sslrootcert` path in both the
+ runtime and migration URLs;
+- the same stable user UUID in `ALICEBOT_AUTH_USER_ID` and
+ `NEXT_PUBLIC_ALICEBOT_USER_ID`.
+
+Migration commands consume their one-shot `DATABASE_ADMIN_URL`; application
+queries consume the runtime `DATABASE_URL`. The API must always run through
+`alicebot_app`, and its service environment must never contain the admin DSN.
+Restrict access to both separately rendered environments.
+
+Connector and model credentials are separate secret-manager injections. For
+the surviving Telegram connector, inject `TELEGRAM_BOT_TOKEN` into the service
+environment, then persist only its reference:
+
+```bash
+./.venv/bin/alicebot vnext connectors configure telegram \
+ --enabled --secret-ref env:TELEGRAM_BOT_TOKEN
+```
+
+Do not commit a raw connector token or put provider API keys in
+`WORKSPACE_PROVIDER_CONFIGS_JSON`. The `env:TELEGRAM_BOT_TOKEN` value is an
+identifier; the environment value it resolves is the secret.
+
+`NEXT_PUBLIC_*` values are build-time public configuration. Render a separate
+`apps/web/.env.production.local` containing the public API origin, user UUID,
+and the same server-only `PUBLIC_ORIGIN`, then build. The web supervisor must
+also inject `PUBLIC_ORIGIN` at runtime. Rebuild the web app whenever any of
+those values changes:
+
+```bash
+pnpm --dir apps/web build
+```
+
+In this hardened topology, **/vnext is the only live authenticated browser console**.
+Its client-side workspace accepts the browser-memory operator key,
+and the web API helper permits that Bearer key only for loopback or the exact
+configured same-origin HTTPS base. It rejects another host such as
+`https://evil.example`, plaintext remote origins, credentials, and base paths.
+
+The other default-navigation pages perform async server-side reads over legacy
+`/v0`; the server cannot use the browser-memory Bearer key or the browser's mTLS
+client certificate. They therefore remain demo/fixture views in this topology,
+not live operational surfaces. Making every page live requires a future BFF or
+client-side refactor and is out of scope for this guide.
+
+## 3. Prepare and verify PostgreSQL
+
+Provision PostgreSQL 16 and install pgvector 0.8 or newer. Create distinct
+login roles named exactly `alicebot_admin` and `alicebot_app`; the migrations
+grant to `alicebot_app` by name. The database should be owned by the admin role,
+while the runtime role receives only migration-defined grants.
+
+With `DATABASE_ADMIN_URL` injected into this one-shot verification process,
+verify the server and certificate without putting a credentialed URL in
+process arguments:
+
+```bash
+./.venv/bin/python - <<'PY'
+import os
+import psycopg
+
+with psycopg.connect(os.environ["DATABASE_ADMIN_URL"]) as connection:
+ with connection.cursor() as cursor:
+ cursor.execute("SHOW server_version_num")
+ server_version = int(cursor.fetchone()[0])
+ cursor.execute("SELECT extversion FROM pg_extension WHERE extname = 'vector'")
+ vector_version = cursor.fetchone()
+if server_version // 10000 != 16:
+ raise SystemExit("PostgreSQL major must be 16")
+if vector_version is None:
+ raise SystemExit("pgvector is not installed")
+try:
+ vector_major_minor = tuple(int(part) for part in str(vector_version[0]).split(".")[:2])
+except ValueError as exc:
+ raise SystemExit("pgvector version could not be parsed") from exc
+if vector_major_minor < (0, 8):
+ raise SystemExit("pgvector must be 0.8 or newer")
+print("postgres_major=16 pgvector_minimum=0.8 tls_contract=verify-full")
+PY
+```
+
+This proves the configured CA and hostname accepted the server certificate;
+`sslmode=require` alone is not sufficient. Record only versions and status,
+not DSNs, certificate paths, or command output containing infrastructure
+identifiers.
+
+## 4. Migrate as admin, then run as the application role
+
+Take a backup before every migration. Have the deployment runner inject
+`DATABASE_ADMIN_URL` only into the migration command, then run Alembic through
+the admin role. `scripts/migrate.sh` fails before Alembic if that variable is
+absent; it never falls back to the runtime DSN.
+
+```bash
+./scripts/migrate.sh
+./.venv/bin/alicebot vnext migrations status
+```
+
+Then discard the one-shot migration environment and start the API with the
+runtime template, whose `DATABASE_URL` names `alicebot_app` and which contains
+no `DATABASE_ADMIN_URL`. Start the web server with explicit loopback flags. A
+supervisor should load the rendered runtime environment at process start,
+restart on failure with backoff, set a sensible file-descriptor limit, and
+capture stdout/stderr without environment dumps.
+
+```bash
+env -u DATABASE_ADMIN_URL ./.venv/bin/python -m alicebot_api.local_server
+pnpm --dir apps/web start --hostname 127.0.0.1 --port 3000
+```
+
+After the supervisor starts the API, prove the boundary against the **live
+process**, not merely the source environment file. The following Linux/systemd
+probe reads the process environment privately, never prints it, connects with
+that process's runtime URL, and emits only a safe verdict. Adapt the unit name
+or use the equivalent container-namespace inspection for another supervisor;
+never dump `/proc/.../environ` into logs or a receipt.
+
+```bash
+api_pid="$(systemctl show --property MainPID --value alicebot-api.service)"
+test "$api_pid" -gt 0
+sudo /opt/alicebot/.venv/bin/python - "$api_pid" <<'PY'
+import sys
+
+import psycopg
+
+try:
+ raw_environment = open(f"/proc/{int(sys.argv[1])}/environ", "rb").read()
+ process_environment = dict(
+ entry.split(b"=", 1) for entry in raw_environment.split(b"\0") if b"=" in entry
+ )
+ if b"DATABASE_ADMIN_URL" in process_environment:
+ raise RuntimeError
+ runtime_url = process_environment.get(b"DATABASE_URL")
+ if runtime_url is None:
+ raise RuntimeError
+ with psycopg.connect(runtime_url.decode("utf-8")) as connection:
+ session_role, effective_role = connection.execute(
+ "SELECT session_user, current_user"
+ ).fetchone()
+ if session_role != "alicebot_app" or effective_role != "alicebot_app":
+ raise RuntimeError
+except Exception:
+ print("api_runtime_db_boundary=failed")
+ raise SystemExit(1)
+print("api_runtime_db_boundary=passed role=alicebot_app admin_dsn_present=false")
+PY
+```
+
+The owner receipt records only pass/fail and timestamp for this probe. It must
+attest `runtime DB role=alicebot_app` for both the PostgreSQL session and
+effective roles, plus `admin DSN absent from API service environment`; do not
+record either DSN, the process environment, PID, host, or database error
+details.
+
+Before Caddy or the public firewall is enabled, verify both listeners from the
+host:
+
+```bash
+curl --fail-with-body http://127.0.0.1:8000/healthz
+curl --fail-with-body --head http://127.0.0.1:3000/vnext
+```
+
+`/healthz` checks PostgreSQL only. Its Redis and object-storage fields are
+`not_checked`; a 200 does not prove the web UI, scheduler, connectors, model
+provider, embedding provider, backups, or alert delivery. Treat those as
+separate probes.
+
+## 5. Bootstrap identity and authentication before publication
+
+Keep the public firewall closed. Bootstrap the one local workspace through
+the loopback API, then create a dedicated unbound operator key by omitting
+`--project-scope`:
+
+```bash
+curl --fail-with-body --request POST http://127.0.0.1:8000/v1/workspaces/bootstrap
+./.venv/bin/alicebot agent keys create \
+ --agent-id vnext-operator \
+ --profile admin_agent \
+ --label "Single-tenant review console"
+```
+
+The raw `alice_sk_...` value is shown once. Capture it directly into the
+owner's secret manager, confirm `alicebot agent keys list` shows an active key
+with `project_scope` null, and do not place the raw value in shell history,
+service files, URLs, logs, or a deployment receipt.
+
+Prove that keyed mode closed the compatibility path before opening the
+firewall. A keyless request to the operator workspace must return 401; a
+request with the unbound key must succeed. Feed the Authorization header to
+curl through stdin configuration so the key is not a process argument:
+
+```bash
+test "$(curl --silent --output /dev/null --write-out '%{http_code}' \
+ "http://127.0.0.1:8000/v0/vnext/workspace?user_id=${ALICEBOT_AUTH_USER_ID}")" = 401
+
+printf 'header = "Authorization: Bearer %s"\n' "$ALICE_AGENT_API_KEY" | \
+ curl --fail-with-body --config - \
+ "http://127.0.0.1:8000/v0/vnext/workspace?user_id=${ALICEBOT_AUTH_USER_ID}"
+```
+
+Run `unset ALICE_AGENT_API_KEY` immediately afterward if the deployment shell
+does not need it again.
+
+## 6. Enable the TLS proxy
+
+Copy
+[`packaging/cloud/Caddyfile.example`](../../packaging/cloud/Caddyfile.example),
+replace the example domain and ACME contact, validate it with the exact Caddy
+binary selected by the operator, and install it as an owner-controlled config.
+The file routes API paths without stripping them and sends all other requests
+to the web process. Caddy's admin listener and both upstreams are loopback. It
+requires and verifies a client certificate against the operator-controlled CA
+at `/run/secrets/alicebot/client-ca.pem`, enables strict SNI/Host matching, and
+preserves Caddy's normal real-client `X-Forwarded-For` behavior. Protect the CA
+and client private keys outside the repository. Issue a separate short-lived
+client certificate for each authorized browser or agent; rotation or revocation
+is an operator procedure and must be tested before relying on it.
+
+The public API matcher is intentionally exact: `/healthz`, the API docs, and
+`/v0/vnext` plus its descendants. It does not publish broad `/v0/*` or any
+`/v1` route. **Remote /v1 is unsupported** until those routes have application
+authentication equivalent to the vNext agent-key boundary. Workspace
+bootstrap, provider administration, and runtime `/v1` calls therefore remain
+loopback-only operator actions performed before the public firewall opens.
+
+```bash
+caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
+```
+
+Start Caddy only after the unbound `admin_agent` key exists. The server
+certificate must come from a publicly trusted CA; the client-certificate CA is
+operator-owned and independent. Do not use `tls internal` for the public
+server endpoint.
+
+Point public DNS at the host, permit inbound 80/443, and confirm Caddy obtained
+a publicly trusted server certificate only after the runtime auth gates and
+the local keyless/authenticated probes pass on the exact release candidate.
+
+Run these probes from a second machine, not from the deployment host:
+
+```bash
+# Both negative probes must fail the TLS handshake. The second certificate
+# must be issued by a deliberately untrusted test CA, never the operator CA.
+if curl --silent --show-error --fail --output /dev/null \
+ https://alice.example.com/healthz; then
+ echo "no-client-certificate rejection failed" >&2
+ exit 1
+fi
+if curl --cert "$ALICE_UNTRUSTED_MTLS_CLIENT_CERT" \
+ --key "$ALICE_UNTRUSTED_MTLS_CLIENT_KEY" \
+ --silent --show-error --fail --output /dev/null \
+ https://alice.example.com/healthz; then
+ echo "untrusted-client-certificate rejection failed" >&2
+ exit 1
+fi
+curl --cert "$ALICE_MTLS_CLIENT_CERT" --key "$ALICE_MTLS_CLIENT_KEY" \
+ --fail-with-body https://alice.example.com/healthz
+curl --cert "$ALICE_MTLS_CLIENT_CERT" --key "$ALICE_MTLS_CLIENT_KEY" \
+ --fail-with-body --head https://alice.example.com/vnext
+curl --cert "$ALICE_MTLS_CLIENT_CERT" --key "$ALICE_MTLS_CLIENT_KEY" \
+ --fail-with-body https://alice.example.com/openapi.json
+
+# These paths must resolve to the web 404 boundary (HTML), never Alice's API.
+probe_not_api() {
+ result="$(curl --cert "$ALICE_MTLS_CLIENT_CERT" \
+ --key "$ALICE_MTLS_CLIENT_KEY" --silent --show-error \
+ --output /dev/null --write-out '%{http_code} %{content_type}' "$1")"
+ case "$result" in
+ "404 text/html"*) ;;
+ *) echo "public API route boundary failed" >&2; return 1 ;;
+ esac
+}
+probe_not_api https://alice.example.com/v1/workspaces/bootstrap
+probe_not_api https://alice.example.com/v0/memories
+probe_not_api https://alice.example.com/v0/vnextish
+```
+
+The OpenAPI document's `info.version` is the HTTP version probe; compare it to
+the checked-out release and also record `./.venv/bin/alicebot --version` on the
+host. Confirm the valid-certificate responses carry HSTS and clickjacking
+response headers (`Strict-Transport-Security`, CSP `frame-ancestors 'none'`,
+and `X-Frame-Options: DENY`). Repeat the keyless 401 and authenticated
+workspace probes through the HTTPS origin. Do not save the authenticated
+response or raw header in CI artifacts.
+
+Record the three route checks only as `remote-v1-not-api=passed`,
+`remote-non-vnext-v0-not-api=passed`, and
+`remote-vnext-lookalike-not-api=passed`, each with a timestamp. Do not retain
+response bodies, content, hostnames, or adapted Caddy configuration in the
+receipt.
+
+## 7. Monitoring and backups
+
+Monitor at least:
+
+- Caddy TLS renewal, 5xx rate, request latency, and disk use;
+- API/web process availability and restart count;
+- `/healthz` database reachability, with its DB-only limitation preserved;
+- PostgreSQL connections, storage, replication posture if independently
+ configured, and certificate expiry;
+- Alice scheduler status, heartbeat freshness, failures, and expired claims;
+- connector health and last successful capture;
+- backup completion, age, encrypted off-host copy, and last restore drill.
+
+Alert delivery is operator-owned and must be tested end to end. This project
+does not provide managed alert routing.
+
+Wire PostgreSQL backup to an **external scheduler** such as a systemd timer or
+cloud scheduler; do not rely on the Alice workflow scheduler to protect its own
+database. Follow [Backup and Restore](../alpha/backup-and-restore.md) and the
+[Disaster Recovery runbook](../runbooks/disaster-recovery.md). The scheduled
+job must use secret-manager injection, create a custom-format `pg_dump`, verify
+the archive, encrypt it before upload, keep an off-host encrypted copy, apply a
+documented retention policy, and emit a secret-free success/failure signal.
+
+Schedule the 5.2 PostgreSQL backup/restore drill with a one-shot service like
+the following. Render `/run/secrets/alicebot/backup-restore.env` separately
+with the role-separated `DATABASE_ADMIN_URL` and `DATABASE_URL` shapes shown
+above; both URLs must retain
+`sslrootcert=/run/secrets/alicebot/postgres-ca.pem`. The CA bundle must exist at
+that exact path on a VM, or be mounted there read-only in a container. The
+service passes DSNs only through its environment, never through command-line
+arguments:
+
+```ini
+[Unit]
+Description=Alice PostgreSQL backup and disposable-restore drill
+
+[Service]
+Type=oneshot
+User=alicebot
+EnvironmentFile=/run/secrets/alicebot/backup-restore.env
+BindReadOnlyPaths=/run/secrets/alicebot/postgres-ca.pem
+ExecStart=/opt/alicebot/.venv/bin/python /opt/alicebot/scripts/run_phase5_ops_evidence.py --backend postgres --work-dir /var/lib/alicebot/backup-drill --output /var/lib/alicebot/evidence/postgres-backup-restore.json
+```
+
+```ini
+[Timer]
+OnCalendar=weekly
+Persistent=true
+```
+
+This drill proves dump/restore mechanics and deletes its disposable database;
+it is not the retained production backup. The external backup job must still
+encrypt and upload its verified archive off-host under the operator's retention
+policy. Never add database URL options to the service because doing so exposes
+credentials in process arguments.
+
+At a cadence chosen from the operator's recovery objectives, restore a selected
+backup into a **disposable restore** database, apply the same release's
+migrations, verify row counts and representative authenticated recall, then
+destroy the disposable target. A backup upload without a successful restore
+drill is not recovery evidence. Keep the restore target isolated from public
+traffic and never overwrite the live database to perform a drill.
+
+## 8. Safe upgrade and rollback
+
+For every upgrade:
+
+1. record the current release tag/SHA and migration head;
+2. stop writers or enter a documented maintenance window;
+3. create, encrypt, and upload a backup;
+4. complete a disposable restore and application probe;
+5. stage the new verified release and rebuild the web app with the exact public
+ origin;
+6. inject `DATABASE_ADMIN_URL` only into the one-shot migration process and
+ run migrations;
+7. discard the migration environment, start the API using only the
+ `alicebot_app` runtime URL, and run loopback probes;
+8. start Caddy/public traffic and run external TLS, version, `/vnext` after
+ operator-key entry, keyless-401, authenticated API, scheduler, and connector
+ probes. Do not report fixture-backed navigation pages as live.
+
+Application code can be rolled back only when the deployed schema is declared
+compatible with that older release. There is **no in-place schema downgrade**
+contract. If a database rollback is required, stop writers and restore the
+pre-upgrade backup into a fresh database, verify it, and cut over explicitly.
+Do not run Alembic downgrade against the live database and do not point an old
+binary at a newer schema on hope alone.
+
+## CI contract smoke and the required real-host receipt
+
+Run the repository validator locally with:
+
+```bash
+./.venv/bin/python scripts/run_single_tenant_deployment_smoke.py
+./.venv/bin/python -m pytest tests/unit/test_single_tenant_deployment.py -q
+```
+
+The CI workflow deliberately emits:
+
+```text
+environment=ephemeral_ci
+cloud_provider=none
+public_dns=false
+public_ca=false
+evidence_kind=configuration_contract_only
+source_head_commit=
+source_head_tree=
+carrier_state=clean|dirty
+carrier_snapshot_sha256=
+validated_asset_sha256=
+```
+
+It checks examples, exact origins, loopback binds/upstreams, database role and
+TLS settings, exact proxy trust, fail-closed mTLS, real-client XFF preservation,
+immutable workflow pins, claim boundaries, and receipt sanitization. It does
+not start Caddy, obtain a certificate, contact a cloud provider, or prove a
+real backup schedule. The owner real-host receipt remains blocking.
+
+The source commit/tree identify HEAD. `carrier_state` and the carrier digest
+bind the actual tracked plus nonignored-untracked working bytes, including
+tracked deletions, file modes, and symlink target text without following the
+link. The five per-asset hashes separately bind this guide, the environment
+example, the Caddyfile, the workflow, and the web API source whose exact-origin
+trust contract the smoke validates. Paths and file contents never enter the
+JSON receipt.
+
+Before anyone claims the guide was exercised, the owner must add an out-of-band
+sanitized receipt named `owner_real_host_deployment_receipt` with:
+
+- release tag and source SHA;
+- `environment=real_single_tenant_host` and the actual cloud/provider class;
+- `public_dns=true` and `public_ca=true` after an external TLS probe;
+- pass/fail and timestamps for migration, loopback API/web, external health,
+ version, `/vnext` after operator-key entry, keyless rejection, authenticated
+ workspace, scheduler, backup, and disposable restore checks;
+- explicit pass/fail for no-client-certificate rejection,
+ untrusted-client-certificate rejection, valid-client-certificate acceptance,
+ and HSTS and clickjacking response headers;
+- if certificate revocation or rotation is relied on, explicit pass/fail for
+ revoked-client-certificate rejection and replacement-certificate acceptance;
+- confirmation that only Caddy is publicly reachable and the API/web/database
+ ports are not;
+- pass/fail and timestamp proving the live API service has runtime DB
+ role=`alicebot_app` and its process environment has no `DATABASE_ADMIN_URL`;
+- `remote-v1-not-api`, `remote-non-vnext-v0-not-api`, and
+ `remote-vnext-lookalike-not-api` pass/fail with timestamps;
+- reviewer identity and date.
+
+The receipt must exclude usernames beyond fixed Alice role names, hostnames,
+IP addresses, DSNs, credentials, agent keys or prefixes, certificate paths,
+filesystem paths, memory content, HTTP bodies, and raw logs. Until that receipt
+is reviewed, the truthful status is “configuration contract validated; real
+single-tenant host proof outstanding.”
diff --git a/docs/handoff/2026-07-21-v0.14.0-phase5-enterprise-track/BUILD_REPORT.md b/docs/handoff/2026-07-21-v0.14.0-phase5-enterprise-track/BUILD_REPORT.md
new file mode 100644
index 00000000..1ed6b07b
--- /dev/null
+++ b/docs/handoff/2026-07-21-v0.14.0-phase5-enterprise-track/BUILD_REPORT.md
@@ -0,0 +1,210 @@
+# Phase 5 Enterprise Track Build Report
+
+> Builder evidence only. The authoritative code-carrier verdict belongs in the
+> independent reviewer-authored `REVIEW_REPORT.md`. Phase 5 completion remains
+> **NO-GO pending the 5.4 owner gate and green committed-SHA CI**. Stage A and
+> the retained Stage B history support only the owner-accepted claim of
+> "automated security scanning under OpenAI Trusted Access on the repository,
+> plus internal adversarial review." The carrier grants no broader security
+> assurance.
+
+## Carrier identity
+
+- Source commit: `c9d24243920a694eaf00ad595da392a1478710dd`
+- Source tree: `ecc16a53f580308959e97e8b1f02edd04bbe3bfc`
+- Superseded CI-only carrier: `e8d20189edfca5e9925cb3ed390e0621816899e7`
+ with receipt `4cf7e08b...`; failed and not shippable
+- Replacement topology: one fresh carrier commit directly on the source
+ commit; `e8d2018` must not be in its ancestry
+- Source branch at handoff: `codex/v0140-phase5-enterprise-track`
+- Carrier state: intentionally uncommitted and unstaged
+- Python package version: `0.13.1`
+- Web package version: `0.13.1`
+- Target release after owner/release-engineer gates: `0.14.0`
+
+## Final verification
+
+| Lane | Reproduced result | Evidence boundary |
+|---|---|---|
+| Python unit and coverage | Final depth-1/no-tag carrier: 4,034 passed, 10 skipped; total coverage 80.55%; exact 14-path API/router aggregate floor passed at 45% | Receipt-trailed direct-on-base scratch commit, with the base object unavailable and tracked/staged state clean |
+| PostgreSQL integration | 407 passed, 1 skipped | Full role-separated run against disposable PostgreSQL 16.14 with pgvector 0.8.5; admin was superuser and application role was non-superuser |
+| Default surface | 2 passed with legacy surfaces/tools and agent key unset, plus `--require-executed-tests` | Flag-off default-surface round trip and OpenAI Agents SDK tool both executed against the role-separated database |
+| Ops and shallow-history contracts | Full-history receipt/truth plus ops: 45 passed; depth-1/no-tag receipt/truth plus ops: 42 passed, 3 deliberate history/tag skips | The no-tag lane delegates only authentic history-dependent proof; missing PostgreSQL URLs fail before tag lookup. Integrated mode ignores unrelated transient `.coverage.*` shards but fails on any receipt/report drift |
+| Deployment contracts | 53 passed; deployment smoke status `passed` with exactly `owner_real_host_deployment_receipt` open | Includes missing-admin-DSN precedence without a repository `.venv`, exact example UUID sentinels, and exact Caddy host/upstream directives |
+| Web unit | 53 files, 236 tests passed | Full Vitest lane after the two-layer bookmarklet serializer repair |
+| Web coverage | Core: 219 tests, 90.26% statements/lines; vNext: 17 tests, 81.85% statements/lines | Both configured coverage lanes passed |
+| Web static/build/budgets | Typecheck, full lint, production build, and budgets passed; `/` 106,168 bytes, `/continuity` 113,580 bytes, `/vnext` 137,822 bytes | Current repaired carrier; all routes retain 13,832-17,178 bytes of budget headroom |
+| Browser integration | 24 passed: 21 core, 1 legacy, 1 outage, 1 partial; the hostile exact-payload clipper case also passed 10/10 under repetition | Full Playwright posture matrix with a script-free same-origin fixture and exact one-time-capability transport |
+| CodeQL alert remediation | #515-#520 use a strict two-layer percent-encoded config blob; #521 covers all modeled dangerous schemes; #522 uses exact Caddy directive tokens | No suppression or allowlist; focused Python 52, Vitest 4, hostile browser round trip 10/10, build/type/lint/Ruff passed. Only fresh committed-SHA CodeQL can close the aggregate check |
+| LongMemEval and eval contracts | 135 passed; evidence checker passed all 7 arms; vector contract 2 passed; six-suite model-free eval passed; canonical release gate failed closed without a vector provider | Preserves honest `fts_only` labeling and release-gate behavior |
+| Dependency posture | Advisory harness 5/5; exact Gitleaks 8.30.1 reproduced the old `e8d2018` finding, then reported no leaks across the fresh one-commit `c9d2424..HEAD` replacement | No historical allowlist or scan suppression was added |
+| Static/control checks | Control-doc truth, release check at `0.13.1`, full Ruff, exact CI mypy over 228 files, compileall, YAML parsing, Bash syntax, and `git diff --check` passed | `actionlint` and ShellCheck were unavailable locally; repository and committed-SHA workflow checks remain authoritative |
+| Deployment validator | Local contract passed with exactly one blocker: `owner_real_host_deployment_receipt` | This is deliberately not real-host evidence |
+
+No production source changed after the frozen matrix. The real branch was
+flattened to the source commit with all 124 carrier/report paths unstaged and
+an empty index. A disposable one-commit, direct-on-base carrier reproduced the
+receipt and report trailers, passed the full-history and depth-1/no-tag truth
+checks above, and passed the exact Gitleaks PR-range command. The first complete
+shallow unit run exposed transient pytest-cov shards in the integrated-mode
+worktree probe; the scoped fail-on-old repair was independently reviewed, and
+the complete 4,034-test shallow rerun then passed.
+
+## Owner and environment gates
+
+- `5.1.c OWNER DISPOSITION ACCEPTED`: the accepted claim is "automated security
+ scanning under OpenAI Trusted Access on the repository, plus internal
+ adversarial review." It is not an external audit or certification.
+- `5.4 OWNER GATE OPEN`: no real-host deployment receipt.
+- `COMMITTED-SHA CI GATE OPEN`: the old commit failed. A fresh direct-on-base
+ carrier must pass PR/main-only Gitleaks, CodeQL, tests, ops, and deployment
+ workflows before release engineering may integrate it.
+- Local Caddy and ShellCheck binaries were unavailable. Caddy syntax, public
+ TLS/DNS/CA, firewall, and certificate lifecycle remain in the owner gate;
+ adversarial repository tests validate only the checked-in contract.
+
+## Explicit carrier receipt
+
+Receipt format:
+`alice-v0.14.0-phase5-enterprise-track-explicit-carrier-v1`.
+
+Receipt-listed paths (122, bytewise sorted):
+
+```text
+.github/workflows/deployment-guide-smoke.yml
+.github/workflows/ops-evidence.yml
+.github/workflows/tests.yml
+.gitignore
+Makefile
+README.md
+SECURITY.md
+apps/api/alembic/versions/20260721_0094_browser_clip_capabilities.py
+apps/api/src/alicebot_api/browser_clip_capabilities.py
+apps/api/src/alicebot_api/config.py
+apps/api/src/alicebot_api/main.py
+apps/api/src/alicebot_api/openapi_operation_contracts.py
+apps/api/src/alicebot_api/routers/vnext_memories.py
+apps/api/src/alicebot_api/sqlite_schema.py
+apps/api/src/alicebot_api/sqlite_store.py
+apps/api/src/alicebot_api/vnext_connectors.py
+apps/api/src/alicebot_api/vnext_secrets.py
+apps/api/src/alicebot_api/vnext_store.py
+apps/api/src/alicebot_api/vnext_stores/postgres/browser_clip_capabilities.py
+apps/api/src/alicebot_api/vnext_stores/sqlite/browser_clip_capabilities.py
+apps/web/app/approvals/loading.tsx
+apps/web/app/approvals/page.test.tsx
+apps/web/app/approvals/page.tsx
+apps/web/app/artifacts/loading.tsx
+apps/web/app/artifacts/page.test.tsx
+apps/web/app/artifacts/page.tsx
+apps/web/app/entities/loading.tsx
+apps/web/app/entities/page.test.tsx
+apps/web/app/entities/page.tsx
+apps/web/app/globals.css
+apps/web/app/memories/loading.tsx
+apps/web/app/memories/page.test.tsx
+apps/web/app/memories/page.tsx
+apps/web/app/traces/loading.tsx
+apps/web/app/traces/page.test.tsx
+apps/web/components/approval-detail.tsx
+apps/web/components/approval-list.tsx
+apps/web/components/artifact-chunk-list.tsx
+apps/web/components/artifact-detail.tsx
+apps/web/components/artifact-list.tsx
+apps/web/components/browser-clipper.test.ts
+apps/web/components/entity-detail.tsx
+apps/web/components/entity-edge-list.tsx
+apps/web/components/entity-list.tsx
+apps/web/components/memory-label-list.tsx
+apps/web/components/memory-review-lists.test.tsx
+apps/web/components/memory-revision-list.tsx
+apps/web/components/trace-list.tsx
+apps/web/components/vnext-brain-workspace.tsx
+apps/web/components/vnext-operator-auth.test.tsx
+apps/web/components/vnext-workspace-model.ts
+apps/web/lib/api.test.ts
+apps/web/lib/api.ts
+apps/web/test/browser/navigation.spec.ts
+apps/web/test/browser/review-dashboard-demo.spec.ts
+docs/alpha/README.md
+docs/alpha/agent-integration.md
+docs/alpha/backup-and-restore.md
+docs/alpha/demo-mode.md
+docs/alpha/first-run.md
+docs/alpha/headless-ubuntu-install.md
+docs/alpha/known-limitations.md
+docs/alpha/quickstart.md
+docs/alpha/review-dashboard-demo.md
+docs/alpha/security-and-privacy.md
+docs/deployment/single-tenant-self-hosted.md
+docs/handoff/2026-07-21-v0.14.0-phase5-enterprise-track/ENGINEER_HANDOFF.md
+docs/handoff/2026-07-21-v0.14.0-phase5-enterprise-track/FIX_MATRIX.md
+docs/handoff/2026-07-21-v0.14.0-phase5-enterprise-track/README.md
+docs/runbooks/disaster-recovery.md
+docs/runbooks/health-and-monitoring.md
+docs/runbooks/upgrade-v0.12-to-current.md
+docs/runbooks/vnext-dogfood-daily-checklist.md
+docs/security/README.md
+docs/security/auth-authorization.md
+docs/security/dependency-posture.md
+docs/security/external-review-brief.md
+docs/security/input-validation.md
+docs/security/secrets-redaction.md
+docs/security/stage-a-evidence.md
+docs/security/threat-model.md
+docs/vnext/architecture.md
+docs/vnext/security-privacy.md
+packaging/cloud/Caddyfile.example
+packaging/cloud/single-tenant.env.example
+scripts/_phase5_ops_seed.py
+scripts/migrate.sh
+scripts/run_phase5_ops_evidence.py
+scripts/run_single_tenant_deployment_smoke.py
+tests/integration/test_browser_clip_capabilities.py
+tests/integration/test_default_surface_integration.py
+tests/integration/test_migrations.py
+tests/integration/test_provider_runtime_api.py
+tests/integration/test_review_dashboard_demo.py
+tests/integration/test_stage_a_agent_key_isolation.py
+tests/unit/test_20260721_0094_browser_clip_capabilities.py
+tests/unit/test_browser_clip_capabilities.py
+tests/unit/test_browser_clip_capability_storage.py
+tests/unit/test_config.py
+tests/unit/test_legacy_gated_router_split.py
+tests/unit/test_legacy_surface_test_posture.py
+tests/unit/test_main.py
+tests/unit/test_memories_legacy_router_split.py
+tests/unit/test_phase5_enterprise_handoff_truth.py
+tests/unit/test_phase5_ops_evidence.py
+tests/unit/test_providers_router_split.py
+tests/unit/test_runnable_docs_secret_argv.py
+tests/unit/test_single_tenant_deployment.py
+tests/unit/test_sqlite_store.py
+tests/unit/test_stage_a_vnext_auth_surface.py
+tests/unit/test_store_events_revisions_split.py
+tests/unit/test_store_graph_open_loops_split.py
+tests/unit/test_store_memory_access_split.py
+tests/unit/test_store_memory_lifecycle_split.py
+tests/unit/test_surface_gates.py
+tests/unit/test_vnext_agent_keys.py
+tests/unit/test_vnext_connectors.py
+tests/unit/test_vnext_main.py
+tests/unit/test_vnext_production_proxy_auth.py
+tests/unit/test_vnext_release_polish.py
+tests/unit/test_vnext_secrets.py
+tests/unit/test_workspaces_router_split.py
+```
+
+The serialized receipt was 20,845 bytes in each of two independent live reads.
+Both reads produced the same digest:
+
+carrier receipt sha256: `94c990d7a67ebe1cd21e45a88a9cc850b06b3fefb2c372be9797e78b7a97dfb2`
+
+Receipt-loop exclusions are exactly:
+
+```text
+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
+```
+
+Any edit to a receipt input invalidates the digest and independent verdict.
diff --git a/docs/handoff/2026-07-21-v0.14.0-phase5-enterprise-track/ENGINEER_HANDOFF.md b/docs/handoff/2026-07-21-v0.14.0-phase5-enterprise-track/ENGINEER_HANDOFF.md
new file mode 100644
index 00000000..36663324
--- /dev/null
+++ b/docs/handoff/2026-07-21-v0.14.0-phase5-enterprise-track/ENGINEER_HANDOFF.md
@@ -0,0 +1,147 @@
+# Phase 5 Enterprise Track Engineer Handoff
+
+## Start here
+
+- **Code carrier:** **NO-GO** until the repaired carrier has a fresh
+ `BUILD_REPORT.md` receipt and reviewer-authored `REVIEW_REPORT.md` verdict.
+- **Phase 5 completion:** **NO-GO pending the 5.4 owner gate and green
+ committed-SHA CI**.
+- **5.1.c OWNER DISPOSITION ACCEPTED:** the accepted claim is "automated
+ security scanning under OpenAI Trusted Access on the repository, plus
+ internal adversarial review." No external auditor is required, no third-party
+ assessment occurred, and this carrier must never be described as
+ independently audited or security-certified.
+- **5.4 OWNER GATE OPEN:** `owner_real_host_deployment_receipt`.
+- **COMMITTED-SHA CI GATE OPEN:** commit `e8d2018` failed; PR/main workflows
+ must certify the repaired, intentionally uncommitted carrier after commit.
+
+Base commit: `c9d24243920a694eaf00ad595da392a1478710dd`.
+Base tree: `ecc16a53f580308959e97e8b1f02edd04bbe3bfc`.
+Both version sources remain `0.13.1`; do not treat this handoff as the version
+cut or publication receipt.
+
+## Superseded carrier and required seven-defect proof
+
+Receipt `4cf7e08b...` and commit `e8d2018` are superseded and **not shippable**.
+The replacement repair scope covers the six reported CI defects:
+shallow-history handoff truth, tagless shallow-lane delegation to the
+authoritative full-history ops drill, stable negative-path validation order,
+migration precondition/error precedence before the existing fixed-location
+`.venv` interpreter check, the example-file Gitleaks false positive, and the
+browser one-time-capability timing race. Aggregate CodeQL alerts #515-#522 are a
+seventh carrier defect; repair them without suppressions. Keep the repair
+uncommitted and unstaged while its
+focused, shallow-clone, full-history ops, and local scan proofs run; then mint a
+fresh receipt and send the exact delta through independent control-tower
+re-review. Only the fresh committed-SHA Gitleaks and CodeQL jobs may close the
+two scan findings.
+
+Gitleaks scans the entire `c9d2424..HEAD` PR range. Do not append the repair to
+`e8d2018`: that history would retain the old high-entropy placeholder. Release
+engineering must create the replacement as one fresh commit directly on base
+`c9d2424`, with the fresh trailers below and without `e8d2018` in its ancestry.
+Do not use a historical allowlist or CodeQL suppression as a substitute for the
+repaired committed-SHA checks.
+
+## Review order
+
+1. Read the threat model, Stage A ledger, and retained Stage B disposition under
+ `docs/security/`; confirm they claim only the owner-accepted Trusted Access
+ scanning plus internal adversarial review, not an external or independent
+ audit.
+2. Review browser-clip capability issuance, atomic consumption, migration 0094,
+ simple-request transport, and recursive secret redaction across PostgreSQL
+ and SQLite.
+3. Review the production vNext auth boundary and the every-route authorization
+ sweep; confirm legacy production gates were not opened.
+4. Reproduce `scripts/run_phase5_ops_evidence.py --backend all` with PostgreSQL
+ 16 server and client tools, then confirm no disposable database remains.
+5. Review `/vnext` same-origin key forwarding, unavailable states, and the
+ keyed/keyless review-dashboard integration and browser tests.
+6. Run the deployment validator and inspect the exact Caddy matcher, mTLS,
+ headers, runtime-only DSN, one-shot migration DSN, and owner receipt fields.
+7. Reconstruct the explicit receipt in `BUILD_REPORT.md`, then read the
+ reviewer-authored `REVIEW_REPORT.md`.
+
+## Required local gates
+
+Use the exact environment expected by each lane. The PostgreSQL commands require
+the role-separated application/admin URLs used by integration tests.
+
+```bash
+./.venv/bin/pytest tests/unit -q --cov=alicebot_api --cov-report=term --cov-report=json:/tmp/alicebot-python-coverage.json --cov-fail-under=50
+make check-python-coverage PYTHON_COVERAGE_JSON=/tmp/alicebot-python-coverage.json PYTHON_API_COVERAGE_MIN=45
+ALICE_LEGACY_SURFACES=1 ./.venv/bin/pytest tests/integration -q --require-executed-tests
+unset ALICE_LEGACY_SURFACES ALICE_MCP_LEGACY_TOOLS ALICE_AGENT_API_KEY
+./.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
+./.venv/bin/pytest eval/longmemeval -q
+./.venv/bin/python scripts/check_longmemeval_evidence.py
+pnpm --dir apps/web test
+pnpm --dir apps/web test:coverage:core
+pnpm --dir apps/web test:coverage:vnext
+pnpm --dir apps/web typecheck
+pnpm --dir apps/web lint
+pnpm --dir apps/web build
+pnpm --dir apps/web test:budget
+pnpm --dir apps/web test:browser
+make release-static
+git diff --check
+```
+
+Also run both Phase 5 evidence entry points:
+
+```bash
+./.venv/bin/python scripts/run_phase5_ops_evidence.py --backend all --output /tmp/phase5-ops.json
+./.venv/bin/python scripts/run_single_tenant_deployment_smoke.py
+```
+
+The deployment smoke must continue to report exactly one blocker:
+`owner_real_host_deployment_receipt`. An empty blocker list before real-host
+evidence is a failure, not success.
+
+## Receipt and commit protocol
+
+Receipt format:
+`alice-v0.14.0-phase5-enterprise-track-explicit-carrier-v1`.
+
+The receipt hashes the explicit sorted path list in `BUILD_REPORT.md`, including
+mode, kind, and content/link-target hash, relative to the recorded base commit
+and tree. It excludes exactly this handoff's `BUILD_REPORT.md` and reviewer-owned
+`REVIEW_REPORT.md` to avoid a receipt loop. Do not stage by dirty-tree wildcard.
+
+After independently reproducing the receipt, start from base `c9d2424` and
+commit the exact carrier and both reports as one fresh replacement commit, not
+as a child of `e8d2018`. Use these trailers with values computed from the
+committed bytes:
+
+```text
+Alice-Carrier-Receipt-SHA256:
+Alice-Build-Report-SHA256:
+Alice-Review-Report-SHA256:
+```
+
+The truth guard locates the integrated carrier by ancestry and these content
+receipts. It does not predict the future commit, merge, release, or tag SHA.
+Any receipt-listed edit requires a new bind and independent review.
+
+## Release-engineer sequence
+
+1. Confirm `git status`, no staged paths, base ancestry, version `0.13.1`, and
+ the protected/immutable path checks in the truth guard.
+2. Reproduce the local matrix and carrier receipt.
+3. From a fresh branch or worktree at `c9d2424`, create one replacement commit
+ with the three receipt trailers. Verify `e8d2018` is not in its ancestry.
+4. Open the PR so PR-only workflows run; require the Phase 5 ops and deployment
+ contract jobs plus the normal full matrix on the committed SHA.
+5. Verify that release notes preserve the accepted 5.1.c wording and do not
+ claim an external review, independent audit, or security certification.
+6. Exercise the deployment guide on a real host and review the safe owner
+ receipt. Do not infer it from the local configuration smoke.
+7. Only after required owner gates and CI are green, cut both governed versions
+ to `0.14.0`, build reproducible artifacts, run semantic/release gates, tag,
+ publish, and perform external readback.
+
+No step above authorizes a tag or publication from this uncommitted handoff.
diff --git a/docs/handoff/2026-07-21-v0.14.0-phase5-enterprise-track/FIX_MATRIX.md b/docs/handoff/2026-07-21-v0.14.0-phase5-enterprise-track/FIX_MATRIX.md
new file mode 100644
index 00000000..4d9df99f
--- /dev/null
+++ b/docs/handoff/2026-07-21-v0.14.0-phase5-enterprise-track/FIX_MATRIX.md
@@ -0,0 +1,53 @@
+# Phase 5 Enterprise Track Fix Matrix
+
+Phase 5 completion is **NO-GO pending the 5.4 owner gate and green
+committed-SHA CI**. Stage A is the repository evidence described below; Stage B
+history records the review boundary. **5.1.c OWNER DISPOSITION ACCEPTED** uses
+the claim bar "automated security scanning under OpenAI Trusted Access on the
+repository, plus internal adversarial review." No external auditor is required,
+no third-party assessment occurred, and this carrier must never be described as
+independently audited or security-certified. **5.4 OWNER GATE OPEN** still
+requires the owner-run real-host deployment receipt.
+
+| Area | Delivered change | Fail-on-old or execution proof | Status |
+|---|---|---|---|
+| Committed-SHA CI repair | Supersedes receipt `4cf7e08b...` / commit `e8d2018`; targets the six reported CI defects covering shallow-history truth, tagless shallow-lane delegation to the authoritative full-history ops drill, negative-path ordering, migration precondition/error precedence before the existing fixed-location `.venv` interpreter check, the example-file Gitleaks false positive, and browser capability timing; also targets aggregate CodeQL alerts #515-#522 as the seventh carrier defect, without suppressions | Reproduce the affected shallow jobs and authoritative full-history ops job; require Gitleaks and CodeQL on a fresh committed SHA; mint a fresh receipt and obtain independent control-tower re-review before integration | Old carrier not shippable; seven-defect repair uncommitted and not yet CI-certified |
+| Merged base work | Preserved PR #310 reviewer/provenance binding, PR #311 scheduler DSN-in-environment behavior, and PR #312 dependency fix without rebuilding them | Base commit/tree and exact dirty-scope guard | Preserved |
+| Browser clipper | Replaced visited-page reusable credentials with a 120-second, origin-bound, one-time capability; stores only SHA-256 digests; validates and consumes atomically on PostgreSQL and SQLite | Capability shape, origin canonicalization, expiry, replay, tamper, concurrency, migration, route, OpenAPI, redaction, and browser tests | Closed |
+| Keyless boundary | Documented keyless as local-machine-owner trust and kept remote vNext behind agent keys plus an authenticated TLS proxy | Every-vNext-route auth sweep, keyless-after-key rejection, scope/escalation tests, production proxy-auth tests | Closed for Stage A |
+| Security preparation | Added shipped-product threat model, auth/input/secrets/dependency evidence, retained Stage B review brief, and explicit proof gaps | Route/MCP/OpenAPI/error manifests, RLS/key isolation, secret/error tests, dependency audits | Stage A closed; 5.1.c owner disposition accepted at the stated claim bar |
+| Ops evidence | Added physical SQLite backup/WAL restore, portable round trip, authentic v0.12 upgrade, PostgreSQL dump/destroy/restore, migrations 0093/0094, recall/signature, monitoring, and DR automation | Genuine `--backend all` run on PostgreSQL 16.13 with matching clients, no proof gaps and zero disposable DBs left; cleanup fails closed | Closed locally; committed-SHA CI open |
+| Ops receipt truth | Bound receipts to source commit/tree plus a dirty-carrier snapshot, handled deletions/symlinks/races, retained multiple failure codes, propagated `PGSSLROOTCERT`, and rejects PostgreSQL client/server major drift | Adversarial temporary-repository and evidence-helper tests, exact mypy/Ruff, real PG16 run | Closed |
+| Review dashboard | Added honest loading/error/unavailable states, bounded layouts, default navigation gating, same-origin HTTPS live auth, and the scripted review demo | 236 Vitest tests, two coverage lanes, 24 Playwright tests, keyed/keyless role-separated PostgreSQL demo | Closed |
+| Demo truth | Uses an unbound `admin_agent` or zero-key local user, reads source trace by GET without mutation, and keeps raw keys out of curl argv | DB pre/post equality, doc/browser guards, runnable-doc secret-argv sentinel including attached and continued header forms | Closed |
+| Deployment contract | Added loopback API/web, PostgreSQL verify-full, role-separated secrets, exact HTTPS origin, mTLS Caddy, HSTS/anti-framing headers, exact vNext-only public matcher, monitoring/backup/upgrade instructions | Adversarial validator, production-startup and migration fail-on-old tests, five input hashes, safe local receipt | Closed as configuration contract |
+| Runtime admin credential | Removed the production API startup requirement, excludes `DATABASE_ADMIN_URL` from runtime environment, and requires one-shot injection for migration/recovery | Production startup without admin DSN; migration fails clearly without it; owner receipt requires absent env plus `session_user` and `current_user` equal `alicebot_app` | Closed |
+| Real-host evidence | Owner receipt requires mTLS positive/negative/rotation probes, security headers, exact negative API perimeter probes, runtime role/env proof, backup/restore, auth, and monitoring timestamps | Must be executed on the deployed host; repository smoke reports `owner_real_host_deployment_receipt` | Owner gate open |
+
+## Explicitly open, accepted, or out of scope
+
+- **5.1.c OWNER DISPOSITION ACCEPTED:** Stage A evidence, automated security
+ scanning under OpenAI Trusted Access on the repository, plus internal
+ adversarial review meet the owner's chosen bar. This is not an independent
+ audit, certification, or claim that every proof gap is closed.
+- **5.4 OWNER GATE OPEN:** the owner-run real-host deployment receipt remains
+ required; local configuration evidence cannot close it.
+- **COMMITTED-SHA CI GATE OPEN:** the old receipt `4cf7e08b...` and commit
+ `e8d2018` failed and are superseded. The seven-defect repair, including
+ CodeQL #515-#522 without suppressions, needs a fresh receipt, independent
+ re-review, and a green committed-SHA run. Because Gitleaks scans
+ `c9d2424..HEAD`, the replacement must be one fresh commit directly on
+ `c9d2424`, not a child of `e8d2018`; no historical scan allowlist may conceal
+ the old placeholder.
+- No real cloud VM, public DNS, public CA, firewall, certificate revocation,
+ alert delivery, or scheduled backup was provisioned from this carrier.
+- The process-wide settings cache and redaction-path pre-read remain documented
+ internal hardening items.
+- Importer symlink/TOCTOU hardening and a Python advisory-lock workflow remain
+ future work.
+- Legacy `/v0/memories/{id}` cannot serialize a vNext archived/redacted row;
+ the Phase 5 demo uses the governed vNext path and does not conceal this
+ pre-existing compatibility limitation.
+- In the hardened remote topology, `/vnext` is the live authenticated browser
+ console. Other server-rendered navigation pages remain demo/fixture until a
+ future BFF or client-side authenticated refactor.
diff --git a/docs/handoff/2026-07-21-v0.14.0-phase5-enterprise-track/README.md b/docs/handoff/2026-07-21-v0.14.0-phase5-enterprise-track/README.md
new file mode 100644
index 00000000..e9f884ee
--- /dev/null
+++ b/docs/handoff/2026-07-21-v0.14.0-phase5-enterprise-track/README.md
@@ -0,0 +1,76 @@
+# Phase 5 Enterprise Track Handoff
+
+## Verdict boundary
+
+- **Code carrier: NO-GO pending a fresh receipt and independent control-tower
+ re-review.** The previously reviewed carrier failed committed-SHA CI.
+- **Phase 5 completion:** **NO-GO pending the 5.4 owner gate and green
+ committed-SHA CI**.
+- **5.1.c OWNER DISPOSITION ACCEPTED:** the owner accepts the claim bar
+ "automated security scanning under OpenAI Trusted Access on the repository,
+ plus internal adversarial review." Stage A and Stage B history support only
+ that claim. No external auditor is required, no third-party assessment
+ occurred, and this carrier must never be described as independently audited
+ or security-certified.
+- **5.4 OWNER GATE OPEN:** `owner_real_host_deployment_receipt` has not been supplied. Configuration validation is not evidence of a real cloud host, public DNS, public CA, firewall, mTLS perimeter, scheduled backup, or alert delivery.
+- **COMMITTED-SHA CI GATE OPEN:** commit `e8d2018` failed CI. The repair carrier
+ is intentionally uncommitted; pull-request and main-only workflow evidence
+ must run again after release engineering commits the repaired bytes.
+
+## Superseded carrier and seven-defect repair
+
+Receipt `4cf7e08b...` and commit `e8d2018` are superseded and are **not
+shippable**. That carrier exposed six CI defects: shallow-history handoff truth,
+tagless shallow-lane delegation to the authoritative full-history ops drill,
+unstable negative-path validation order, migration precondition/error
+precedence before the existing fixed-location `.venv` interpreter check, a
+Gitleaks example false positive, and a browser one-time-capability timing race.
+Aggregate CodeQL alerts #515-#522 are a seventh carrier defect and must be
+repaired without suppressions. Keep this repair uncommitted while its local and
+shallow-clone proofs run, a fresh receipt is minted, and the delta receives
+independent control-tower review. Do not claim all seven closed until the fresh
+committed-SHA CI run is green.
+
+Gitleaks evaluates the full `c9d2424..HEAD` PR range, so appending a repair as a
+child of `e8d2018` would retain the historical finding. Release engineering
+must rebuild or flatten the exact replacement as one fresh commit directly on
+base `c9d2424`, with fresh receipt trailers; `e8d2018` must not be in the new
+carrier's ancestry. Keep the replacement uncommitted and unstaged until release
+engineering verifies its bytes. No CodeQL suppression or scan allowlist may be
+used to manufacture a green result; committed-SHA CI is the acceptance proof.
+
+The carrier is based on `main` commit
+`c9d24243920a694eaf00ad595da392a1478710dd`, tree
+`ecc16a53f580308959e97e8b1f02edd04bbe3bfc`. Both governed version sources
+remain `0.13.1`. Release engineering, not this carrier, owns the `0.14.0`
+version cut, commit, pull request, merge, tag, publication, and external
+readback.
+
+## What this carrier delivers
+
+- A short-lived, origin-bound, one-time browser-clip capability whose raw
+ value is never stored and cannot be replayed or redeemed cross-origin.
+- Stage A threat-model, authorization, input-validation, secret-handling, and
+ dependency-posture evidence, plus the retained Stage B review history that
+ supports the owner's narrowly worded 5.1.c disposition.
+- Executed SQLite and PostgreSQL 16 backup/destroy/restore, portable
+ export/import, v0.12-to-current upgrade, health, monitoring, and disaster
+ recovery evidence with sanitized receipts.
+- Truthful review-dashboard states, an authenticated `/vnext` operator path,
+ and a tested capture-to-review-to-accept-to-trace-to-redact demo.
+- A hardened single-tenant reference deployment contract with an exact
+ `/v0/vnext` public API boundary, mTLS, response headers, runtime/admin
+ database-role separation, and explicit nonclaims.
+
+The three already-merged security fixes in PRs #310, #311, and #312 were used
+as the base and were not rebuilt.
+
+## Package contents
+
+- `FIX_MATRIX.md` maps the brief and review findings to implementation and proof.
+- `BUILD_REPORT.md` records the final local matrix and explicit carrier receipt.
+- `ENGINEER_HANDOFF.md` gives the release engineer the safe integration order.
+- `REVIEW_REPORT.md` is owned only by the independent control-tower reviewer.
+
+Any change to a receipt-listed path invalidates the frozen receipt and requires
+targeted reruns, a new receipt, and independent re-review.
diff --git a/docs/handoff/2026-07-21-v0.14.0-phase5-enterprise-track/REVIEW_REPORT.md b/docs/handoff/2026-07-21-v0.14.0-phase5-enterprise-track/REVIEW_REPORT.md
new file mode 100644
index 00000000..1e6c73c0
--- /dev/null
+++ b/docs/handoff/2026-07-21-v0.14.0-phase5-enterprise-track/REVIEW_REPORT.md
@@ -0,0 +1,157 @@
+# Phase 5 Enterprise Track Independent Review Report
+
+## Conditional verdict
+
+- **Code carrier (frozen): conditional GO.** The reviewed code and documentation
+ delta has **no remaining P0-P3** finding. The exact bytes are flattened
+ directly onto `c9d24243920a694eaf00ad595da392a1478710dd`, and the receipt
+ has been reproduced in both full-history and depth-1/no-tag scratch commits.
+ Release shippability remains conditional only on green committed-SHA CI and
+ the owner gate stated below.
+- **Phase 5 completion: NO-GO pending the 5.4 owner gate and green committed-SHA
+ CI.** This report is not permission to tag or publish.
+- **5.1.c OWNER DISPOSITION ACCEPTED.** The owner-approved claim is exactly
+ "automated security scanning under OpenAI Trusted Access on the repository,
+ plus internal adversarial review." Stage A and the retained Stage B history
+ support only that bounded claim; they are not an external audit,
+ certification, or independent security assurance.
+- **5.4 OWNER GATE OPEN.** The real-host deployment receipt has not been
+ supplied. Repository checks cannot prove live DNS, public-CA TLS, firewall
+ policy, mTLS operation and rotation, production runtime roles, scheduled
+ backups, or alert delivery.
+- **COMMITTED-SHA CI GATE OPEN.** The replacement is intentionally uncommitted
+ and unstaged. Gitleaks, the GitHub Advanced Security aggregate CodeQL gate,
+ and the complete PR/main workflow matrix must pass on the fresh direct-on-base
+ carrier commit.
+
+## Carrier and receipt review
+
+The failed carrier `e8d20189edfca5e9925cb3ed390e0621816899e7` and its
+`4cf7e08b...` receipt are superseded and not shippable. I reviewed the
+replacement delta against source commit
+`c9d24243920a694eaf00ad595da392a1478710dd` and source tree
+`ecc16a53f580308959e97e8b1f02edd04bbe3bfc`.
+
+- The explicit receipt manifest contains 122 unique, bytewise-sorted paths.
+- Two independent live reads each serialized 20,845 bytes and reproduced
+ `94c990d7a67ebe1cd21e45a88a9cc850b06b3fefb2c372be9797e78b7a97dfb2`.
+- Receipt-loop exclusions are limited to `BUILD_REPORT.md` and this
+ reviewer-authored `REVIEW_REPORT.md`.
+- Python and web versions remain `0.13.1`; the release engineer owns the later
+ governed `0.14.0` cut.
+- The protected SQLite `vnext_stores/sqlite/memory_access.py`, `docs/release`,
+ prior handoffs, immutable release records, and the index are unchanged.
+- The required replacement topology is one fresh carrier commit whose sole
+ parent is `c9d2424`. The failed `e8d2018` carrier must not remain in ancestry,
+ because a child commit cannot remove its leaked placeholder from the PR-range
+ Gitleaks scan.
+
+The receipt digest is stable for the frozen working-tree bytes and was proved
+from both full-history and depth-1/no-tag receipt-trailed scratch commits after
+flattening. This receipt is still not permission to tag or publish.
+
+## CI defects reviewed
+
+The replacement addresses all seven defects exposed by the first committed
+carrier:
+
+1. The handoff history guard skips missing-base lookups only in a genuinely
+ shallow repository; a missing base in a full clone now fails closed.
+2. The SQLite v0.12 baseline drill uses the authentic release tag when history
+ exists and explicitly delegates only that history-dependent proof in a
+ depth-1/no-tag checkout.
+3. PostgreSQL evidence validates both required database URLs before release-tag
+ discovery, for `postgres` and `all`, so configuration errors retain
+ precedence even in a shallow clone.
+4. `scripts/migrate.sh` validates `DATABASE_ADMIN_URL` before resolving the
+ repository virtual environment, with an isolated fail-on-old test.
+5. The browser-clipper transport test now uses a routed, script-free,
+ same-origin fixture and asserts the exact captured payload, removing Next.js
+ hydration as an unrelated source of flakiness.
+6. The example browser-capability UUID is a deterministic non-nil sentinel that
+ remains identical across web and API configuration without matching the
+ Gitleaks generic-api-key detector. Exact Gitleaks 8.30.1 scanning over
+ `c9d2424..HEAD` found no leak in the receipt-trailed scratch carrier; the
+ authoritative GitHub workflow must repeat that result on the final commit.
+7. The eight new GitHub Advanced Security alerts are addressed without
+ suppressions or allowlists: the bookmarklet transports a strict two-layer
+ percent-encoded configuration blob, the dangerous-scheme test covers all
+ modeled schemes, and the deployment smoke test parses exact Caddy directive
+ tokens rather than trusting URL substrings.
+
+The first final shallow full-suite attempt then exposed one additional
+test-order dependency: unrelated transient untracked artifacts could make a
+clean receipt-trailed shallow commit look like a dirty live carrier. The guard
+now filters only for receipt inputs and the two report exclusions when HEAD is
+integrated. It still rejects any live receipt/report drift, while base-mode
+freezing continues to require the exact carrier path set and an empty index. A
+fail-on-old regression covers both the tolerated unrelated artifacts and the
+rejected receipt-scoped paths.
+
+For the CodeQL changes, I compared the implementation with the upstream query
+and library models for `js/incomplete-url-scheme-check`,
+`js/bad-code-sanitization`, and
+`py/incomplete-url-substring-sanitization`. The rewritten sources and sinks are
+absent or separated by an explicit sanitizer barrier, while hostile semantic
+round-trip tests cover quotes, backslashes, literal percent escapes,
+``, Unicode line separators, and non-ASCII text. This source review is
+not a substitute for the fresh committed-SHA aggregate CodeQL result.
+
+## Reproduced evidence
+
+The frozen replacement passed the local matrix recorded in `BUILD_REPORT.md`:
+
+- Python unit and coverage: 4,028 passed and 1 unrelated test skipped, with the
+ still-unbound handoff-truth file excluded; 80.55% total coverage and the 45%
+ API/router aggregate floor passed.
+- History/ops contracts: 30 passed with full history; focused truth and topology
+ checks passed 7; a real depth-1/no-tag checkout passed 29 ops tests with one
+ expected baseline-upgrade delegation and 5 truth tests with 2 history-only
+ skips.
+- Final receipt-trailed history guards: full-history truth plus ops passed all
+ 45 tests; shallow truth plus ops passed 42 with 3 deliberate history-only
+ skips.
+- Final receipt-trailed shallow carrier: rev-count 1, no tags, and no base
+ object. The full unit/coverage lane passed 4,034 tests with 10 skips in 154.56
+ seconds at 80.55% total coverage; the exact 14-path router aggregate met the
+ 45% floor. Tracked and staged state remained clean; only standard ignored
+ coverage and pytest artifacts were present.
+- Deployment contracts: 53 passed, with only
+ `owner_real_host_deployment_receipt` open.
+- PostgreSQL integration: 407 passed and 1 skipped in 483.05 seconds against a
+ disposable PostgreSQL 16.14 plus pgvector 0.8.5 instance, with an admin
+ superuser and a separate non-superuser application role. The exact flag-off
+ default surface executed 2 non-skipped tests and both passed.
+- Web: 236 unit tests; 90.26% core and 81.85% vNext statement/line coverage;
+ typecheck, lint, production build, and budgets passed.
+- Browser postures: 24 passed; the hostile exact-payload clipper case also
+ passed 10 consecutive repetitions.
+- Evaluation lanes: 135 LongMemEval tests, all 7 evidence arms, 2 vector
+ contracts, and all six model-free suites passed; the canonical release gate
+ correctly failed closed without a vector provider.
+- Control/static checks: release truth at `0.13.1`, Ruff, mypy over 228 files,
+ compileall, Bash syntax, YAML parsing, and `git diff --check` passed.
+
+Fresh GitHub Actions results remain intentionally outside this conditional
+verdict until recorded from the authoritative committed-SHA runs.
+
+## Remaining proof boundaries
+
+Local Caddy and ShellCheck executables were unavailable. Tests cover the
+checked-in Caddy contract, but actual Caddy parsing and all live-host properties
+remain within the 5.4 owner receipt. The owner-accepted 5.1.c disposition also
+does not turn Stage A, Stage B, OpenAI Trusted Access scanning, or internal
+adversarial review into a claim that an external security assessment occurred.
+
+Release engineering must now:
+
+1. commit the exact flattened carrier directly on `c9d2424`, excluding the
+ superseded carrier from ancestry;
+2. include the required carrier and report receipt trailers;
+3. run the complete PR/main
+ matrix, including Gitleaks and the aggregate CodeQL check; and
+4. keep Phase 5 completion at **NO-GO pending the 5.4 owner gate and green
+ committed-SHA CI**.
+
+Accordingly: **Code carrier conditional GO; Phase 5 completion NO-GO pending
+the 5.4 owner gate and green committed-SHA CI.**
diff --git a/docs/runbooks/disaster-recovery.md b/docs/runbooks/disaster-recovery.md
new file mode 100644
index 00000000..42e37184
--- /dev/null
+++ b/docs/runbooks/disaster-recovery.md
@@ -0,0 +1,162 @@
+# Disaster Recovery
+
+This runbook restores one self-hosted, single-tenant Alice installation. It
+does not define an SLA or a managed backup service. Choose and document your
+own recovery-point objective (backup frequency) and recovery-time objective
+(time to provision, restore, verify, and cut over).
+
+The destructive commands below always target a new or disposable database
+first. Never rehearse against the only copy of production data.
+
+## Before an incident
+
+1. Record the Alice version and source commit deployed with each backup.
+2. Encrypt backups off-machine and restrict access as you would for the live
+ database; memory content is sensitive plaintext.
+3. Retain the database roles, extensions, TLS settings, and environment
+ configuration separately from the data dump. Portable SQLite JSONL omits
+ users, agent keys, embeddings, and deleted rows.
+4. Schedule backups and run a restore drill regularly. A successful backup
+ command without a successful restore drill is not recovery evidence.
+
+The repository drill exercises both backends, the v0.12.0 upgrade path, and
+monitoring contracts. It creates and destroys only private SQLite fixtures and
+randomly named `alice_phase5_ops_*` PostgreSQL databases:
+
+```bash
+./.venv/bin/python scripts/run_phase5_ops_evidence.py --backend all \
+ --work-dir "$(mktemp -d)" \
+ --output artifacts/phase5/ops-evidence.json
+```
+
+It requires `DATABASE_ADMIN_URL`, `DATABASE_URL`, PostgreSQL 16 `pg_dump` and
+`pg_restore`, a PostgreSQL 16 server, and the `alicebot_app` role. The drill
+validates all three major versions before creating a disposable database and
+fails closed on missing, malformed, or mismatched version evidence. Do not use
+a newer client major: its archive prologue may contain settings PostgreSQL 16
+cannot restore. Exit `0` and report status `passed` are required. The
+JSON report is sanitized; the temporary databases, dumps, and memory text are
+removed on exit. Failure to drop the randomly named PostgreSQL database is a
+failed drill with `postgres_cleanup_failed`, never a passed receipt; an
+operator must remove the named-by-prefix fixture before retrying. CI runs the same command in
+`.github/workflows/ops-evidence.yml` and retains only the sanitized report.
+
+Receipt identity does not pretend that Git `HEAD` contains an uncommitted
+carrier. It records `source_head_commit`, that commit's `source_head_tree`, a
+`carrier_state` of `clean` or `dirty`, and `carrier_snapshot_sha256` over the
+actual carrier. The snapshot includes tracked changes and deletions plus all
+non-ignored untracked files. It hashes file contents, types, modes, and symlink
+targets without following a symlink outside the repository. Git-ignored drill
+outputs such as `artifacts/` are excluded through the repository's standard
+ignore rules.
+
+## SQLite physical recovery
+
+Use a physical copy when you need every local byte, including signed embedding
+vectors and the vector-cache invalidation stamp.
+
+1. Stop every Alice MCP/API process that can write the database.
+2. Checkpoint the WAL and require the first returned value to be `0` (not
+ busy):
+
+ ```bash
+ sqlite3 ~/.alice/memory.db 'PRAGMA wal_checkpoint(TRUNCATE);'
+ sqlite3 ~/.alice/memory.db 'PRAGMA integrity_check;'
+ ```
+
+3. Copy the main database only after the successful checkpoint. Preserve
+ owner-only permissions and hash the copy:
+
+ ```bash
+ install -m 0600 ~/.alice/memory.db ~/alice-backups/memory.db
+ shasum -a 256 ~/alice-backups/memory.db
+ ```
+
+4. Restore into a new owner-only directory, never over the only live copy:
+
+ ```bash
+ install -d -m 0700 ~/alice-restore-test
+ install -m 0600 ~/alice-backups/memory.db ~/alice-restore-test/memory.db
+ sqlite3 ~/alice-restore-test/memory.db 'PRAGMA integrity_check;'
+ alice-memory mcp --db ~/alice-restore-test/memory.db
+ ```
+
+5. Run a known recall query and inspect a memory that has an embedding. Only
+ then stop the live process, preserve the failed database family (`.db`,
+ `-wal`, `-shm`, `-journal`), and atomically replace it with the verified
+ restore.
+
+If the checkpoint reports busy, do not copy just the main file. Find the
+remaining writer or use the `alice-memory export` online-snapshot path in
+[Backup and restore](../alpha/backup-and-restore.md).
+
+## SQLite portable recovery
+
+Portable JSONL is for user-owned memory-graph portability, not a physical
+clone:
+
+```bash
+alice-memory export --db ~/.alice/memory.db \
+ --out ~/alice-backups/alice.jsonl
+alice-memory import --db ~/alice-restore-test/memory.db \
+ --in ~/alice-backups/alice.jsonl
+alice-memory reindex-embeddings --db ~/alice-restore-test/memory.db
+```
+
+Require the export/import/re-export canonical SHA-256 footer and record counts
+to match. FTS recall works immediately after import. Vector recall does not:
+portable JSONL omits embedding vectors, so configure the intended embedding
+provider and reindex before cutover.
+
+## PostgreSQL recovery
+
+Run PostgreSQL 16 client tools against the PostgreSQL 16 server with libpq
+environment variables so credentials do not appear in process arguments:
+
+```bash
+export PGHOST=db.internal PGPORT=5432 PGUSER=alicebot_admin PGDATABASE=alicebot
+export PGSSLMODE=verify-full
+export PGSSLROOTCERT=/run/secrets/alicebot/postgres-ca.pem
+export PGPASSWORD='from-your-secret-manager'
+pg_dump --format=custom --file=alice.dump
+pg_restore --list alice.dump
+sha256sum alice.dump
+```
+
+Provision a new database with PostgreSQL 16 and pgvector 0.8 or newer. Ensure
+the `alicebot_app` role exists, then restore without changing ownership:
+
+```bash
+createdb alice_restore_test
+pg_restore --exit-on-error --no-owner --dbname=alice_restore_test alice.dump
+```
+
+Against the restored database, verify:
+
+- `SELECT version_num FROM alembic_version` equals the release's dynamic
+ Alembic head;
+- `SELECT count(*)` matches for users, memories, events, artifacts, and
+ artifact ratings;
+- a known FTS recall query returns its memory;
+- the memory still has `embedding_vector` and a content-matching embedding
+ signature;
+- application-role access works with RLS enabled and forced.
+
+If the restore is from an older release, follow
+[Upgrade v0.12.0 to current](upgrade-v0.12-to-current.md) in the restored
+database before verification and cutover.
+
+## Cutover and rollback
+
+1. Quiesce writers and take one final backup.
+2. Repeat the integrity, row-count, recall, embedding-signature, migration-head,
+ and application-role checks on the candidate.
+3. Point Alice at the verified target and check `/healthz`, `/v0/vnext/doctor`,
+ and scheduler status.
+4. Keep the pre-cutover store read-only until the retention window expires.
+5. On any failed proof, point Alice back to that untouched store. Do not merge
+ or replay writes until the cause is understood.
+
+Record the backup hash, source-head commit/tree, carrier state/snapshot hash,
+database engine version, migration head, check results, start/end time, and
+operator. Never record credentials or memory content in the recovery receipt.
diff --git a/docs/runbooks/health-and-monitoring.md b/docs/runbooks/health-and-monitoring.md
new file mode 100644
index 00000000..74a55366
--- /dev/null
+++ b/docs/runbooks/health-and-monitoring.md
@@ -0,0 +1,101 @@
+# Health And Monitoring
+
+Alice exposes several useful signals, but no single endpoint proves the whole
+system healthy. Monitor each signal for what it actually checks.
+
+## `/healthz`
+
+`GET /healthz` performs a bounded PostgreSQL connectivity check. It returns
+HTTP 200 with top-level status `ok` when that check succeeds and HTTP 503 with
+status `degraded` when it fails.
+
+The response also names Redis and object storage as `not_checked`. Those are
+truth labels, not successful checks. Do not turn `/healthz` into an all-services
+availability claim, and do not alert on the redacted Redis URL as though it
+were a probe result.
+
+Recommended alerts:
+
+- immediate: `/healthz` returns 503 or times out for two consecutive probes;
+- capacity: database connections, disk space, WAL growth, and backup age cross
+ operator-defined thresholds;
+- release drift: `alembic_version.version_num` differs from the dynamic head in
+ the deployed Alice artifact.
+
+The SQLite MCP on-ramp does not use this PostgreSQL endpoint. Monitor its
+process, database-file availability, free disk space, backup age, and periodic
+`PRAGMA integrity_check` instead.
+
+## Doctor and connector signals
+
+`GET /v0/vnext/doctor?ci=true` and `POST /v0/vnext/doctor/run` provide the
+application readiness checks. `GET /v0/vnext/connectors/health` reports
+connector telemetry. These vNext routes obey the normal Alice user/agent-key
+boundary; monitoring clients must authenticate once keys exist.
+
+Treat a doctor `fail` or nonzero blocking-failure count as an operator action.
+A `warn` is not automatically an outage: inspect the named check and its
+recommended fix. Connector counters and timestamps prove only Alice's view of
+that connector; they are not third-party provider SLAs.
+
+## Scheduler status and a stuck scheduler
+
+The scheduler daemon writes its status file (normally under Alice's runtime
+directory), while the vNext scheduler status reports durable workflows and
+runs. Watch both.
+
+A scheduler is **stuck** when it reports that it should be running but either:
+
+- `ownership_verified` is false, so the PID/status/owner records do not bind to
+ the same live process; or
+- `last_heartbeat_at` is missing or older than the greater of 60 seconds and
+ three times `interval_seconds`.
+
+Also alert as degraded when `last_error_code` is nonempty or expired claims are
+observed (`expired_claim_count` or a nonzero reaped-claim result). Those may be
+recoverable rather than stuck, but they require an operator to inspect the
+recent durable run and event rows. A `started` run whose claim lease expires is
+not success; Alice fences and reaps expired claims.
+
+Useful commands:
+
+```bash
+alicebot vnext scheduler daemon status
+alicebot vnext scheduler status
+alicebot vnext doctor --ci
+```
+
+The exact CLI spelling is visible in `alicebot vnext scheduler --help` for the
+installed release. Do not delete PID, owner, or status files to manufacture a
+green status; stop the daemon through its command, preserve the files for
+diagnosis, then restart.
+
+## Executed monitoring contract
+
+The Phase 5 evidence script executes both health payload branches and synthetic
+scheduler cases covering healthy, disabled, degraded, and stuck states:
+
+```bash
+./.venv/bin/python scripts/run_phase5_ops_evidence.py --backend sqlite
+```
+
+Its sanitized `health_and_monitoring` receipt proves the response labels and
+stuck-classification fields. It does not prove an external alerting system,
+network path, provider availability, or a production scheduler process; those
+remain deployment responsibilities.
+
+## Minimum operator dashboard
+
+Track at least:
+
+- API process and `/healthz` status/latency;
+- dynamic Alembic head versus deployed database revision;
+- scheduler `running`, `ownership_verified`, heartbeat age, last error, due
+ work, expired/reaped claims, and recent failed runs;
+- disk/database size, free space, connection saturation, and PostgreSQL WAL;
+- last successful backup time, backup hash, off-machine copy age, and last
+ successful restore-drill time;
+- connector last-success/last-error counters when connectors are enabled.
+
+Exclude database URLs, API/provider keys, raw memory text, prompts, and trace
+payloads from metrics labels and logs.
diff --git a/docs/runbooks/upgrade-v0.12-to-current.md b/docs/runbooks/upgrade-v0.12-to-current.md
new file mode 100644
index 00000000..3df1a341
--- /dev/null
+++ b/docs/runbooks/upgrade-v0.12-to-current.md
@@ -0,0 +1,117 @@
+# Upgrade v0.12.0 To Current
+
+This is the supported evidence path from published Alice v0.12.0 to the
+current source candidate. Rehearse it on a restored copy before touching the
+live store.
+
+The immutable baseline is tag `v0.12.0`, commit
+`692c28ae60072b1eac4a437676b3ecf68e8bc026`. The repository drill verifies that
+exact mapping and uses `git archive`; it never switches or mutates the current
+checkout.
+
+## Contract
+
+- Back up and restore-test the old store first.
+- Migrations are forward-only during the cutover; rollback means returning to
+ the untouched pre-upgrade store and old Alice artifact.
+- Run migrations with the admin URL. Run Alice with the role-separated
+ application URL.
+- Determine the current Alembic head from the deployed artifact rather than
+ copying a stale revision from this page.
+- SQLite bootstrap is additive and idempotent. It must preserve data, FTS
+ recall, and signed vectors while adding exactly one nonempty
+ `embedding_stamp` row.
+
+## Automated rehearsal
+
+SQLite-only rehearsal needs no PostgreSQL tools:
+
+```bash
+./.venv/bin/python scripts/run_phase5_ops_evidence.py \
+ --backend sqlite \
+ --output artifacts/phase5/sqlite-upgrade-evidence.json
+```
+
+The full role-separated rehearsal additionally needs `pg_dump`, `pg_restore`,
+`DATABASE_ADMIN_URL`, `DATABASE_URL`, and a reachable disposable PostgreSQL 16
+server with pgvector 0.8 or newer:
+
+```bash
+./.venv/bin/python scripts/run_phase5_ops_evidence.py --backend all \
+ --output artifacts/phase5/ops-evidence.json
+```
+
+The script extracts v0.12.0, runs the v0.12.0 code and migration head, seeds a
+known memory plus a signed vector, upgrades in place with the current code, and
+verifies integrity, counts, FTS recall, signature compatibility, and new
+schema. Raw stores are private temporary data and are removed; the JSON report
+contains no paths, DSNs, credentials, or memory content.
+
+For an uncommitted release carrier, use the receipt's
+`carrier_snapshot_sha256` as its content identity. `source_head_commit` and
+`source_head_tree` identify the immutable base only; `carrier_state: dirty`
+makes explicit that the carrier includes changes not present in that commit.
+The digest covers tracked modifications/deletions and non-ignored untracked
+files while safely hashing symlink targets instead of following them.
+
+## PostgreSQL migration proof
+
+Published v0.12.0 ends at `20260716_0092`. On this Phase 5 carrier, the
+following migrations are specifically exercised:
+
+- `20260721_0093`: duplicate non-null `(artifact_id, reviewer_id)` ratings are
+ reduced to the newest `created_at DESC, id DESC` survivor, then
+ `artifact_quality_ratings_artifact_reviewer_key` enforces uniqueness. The
+ drill seeds old/new duplicates, asserts the newer row survives, and proves a
+ duplicate insert fails.
+- `20260721_0094`: `browser_clip_capabilities` exists after migration. The
+ drill also requires the database `alembic_version` to equal the migration
+ graph's dynamically discovered single head, so a later additive head cannot
+ silently be skipped.
+
+For a manual rehearsal:
+
+```bash
+export DATABASE_ADMIN_URL='from-your-secret-manager'
+python -m alembic -c apps/api/alembic.ini current
+python -m alembic -c apps/api/alembic.ini heads
+python -m alembic -c apps/api/alembic.ini upgrade head
+```
+
+Do not paste a credentialed URL into a transcript or command line. The examples
+show an environment placeholder; inject the real value through the deployment
+secret mechanism.
+
+## SQLite proof
+
+Stop all writers, make a physical backup, then start the new `alice-memory`
+runtime against a restored copy. Bootstrap installs current tables and indexes.
+Verify:
+
+```sql
+PRAGMA integrity_check;
+PRAGMA foreign_key_check;
+SELECT id, length(token) FROM embedding_stamp;
+SELECT count(*) FROM memories;
+```
+
+The stamp query must return exactly one row with `id = 1` and a nonempty token.
+Opening/bootstraping the same unchanged store again must preserve that token.
+Run a representative recall and confirm a previously signed vector remains
+present and content-compatible.
+
+Portable JSONL is a separate contract: it omits embedding vectors, so a JSONL
+restore must run `alice-memory reindex-embeddings` after the intended provider
+is configured. Do not use portable round-trip results as proof that physical
+vector state survived.
+
+## Cutover and rollback
+
+1. Record old/new source commits and dynamic migration heads.
+2. Quiesce writers and take a final backup with a SHA-256 digest.
+3. Upgrade the already verified restore candidate.
+4. Require integrity, counts, recall, embedding-signature, application-role,
+ RLS, `/healthz`, doctor, and scheduler checks to pass.
+5. Cut over once. Keep the old store read-only.
+6. If any check fails, restore the old artifact and point it at the untouched
+ old store. Do not downgrade the partially migrated database in place.
diff --git a/docs/runbooks/vnext-dogfood-daily-checklist.md b/docs/runbooks/vnext-dogfood-daily-checklist.md
index 8f859946..d2031aad 100644
--- a/docs/runbooks/vnext-dogfood-daily-checklist.md
+++ b/docs/runbooks/vnext-dogfood-daily-checklist.md
@@ -69,7 +69,7 @@ alicebot vnext smoke agentic-scheduler
- Missing `connector_settings` or `connector_state`: run `./scripts/migrate.sh`, then `alicebot vnext doctor --fix-safe`.
- Local folder recapture noise: check ignored generated folders, extensions, and allowed roots.
-- Browser clipper unauthorized: pass the configured local capture token or clear the browser clipper `secret_ref`.
+- Browser clipper unauthorized: prepare a fresh one-time bookmarklet in the authenticated Alice console and confirm that its bound origin matches the page; trusted API clients may instead send Bearer authentication plus the configured local `capture_token`.
- Scheduler stale or failing: inspect `alicebot vnext scheduler failures`, then keep generated artifacts review-only until the root cause is fixed.
## Non-Negotiables
diff --git a/docs/security/README.md b/docs/security/README.md
new file mode 100644
index 00000000..42645b4d
--- /dev/null
+++ b/docs/security/README.md
@@ -0,0 +1,75 @@
+# Security Review Evidence
+
+This directory is Alice's durable security-evidence package. It preserves the
+Stage A preparation, the Stage B review history, the owner's Phase 5.1.c
+disposition, and the remaining proof gaps without turning team-authored
+evidence into an external audit or certification.
+
+## Status
+
+- Stage A threat model and evidence package: **prepared; the prior local
+ receipts are superseded after committed-SHA CI failed, so the repaired
+ carrier needs fresh receipts**.
+- Phase 5.1.c owner disposition: **accepted** at the claim bar "automated
+ security scanning under OpenAI Trusted Access on the repository, plus
+ internal adversarial review."
+- Stage B history: **retained as provenance, not evidence of an external
+ assessment**. No external auditor is required under the owner's disposition,
+ and no external audit occurred.
+- Security certification or assurance claim: **none**.
+- Version change or release approval: **not granted by these documents**.
+
+The Stage A baseline is `main` at
+`c9d24243920a694eaf00ad595da392a1478710dd`. The browser-clipper remediation
+adds one HTTP operation to that baseline. The superseded carrier reproduced 183
+default HTTP operations, including 71 `/v0/vnext` operations, 232 HTTP
+operations with legacy surfaces enabled, and 11 core MCP tools. Route, OpenAPI,
+legacy-gating, and flag-off MCP closure tests reproduced those values on that
+carrier; the repaired carrier must reproduce them again. The base counts were
+182, 70, 231, and 11.
+
+## Evidence Language
+
+- **Implemented control** means the named code path exists.
+- **Test evidence** means a named repository test exercises the claim; it still
+ has to pass on the exact review commit.
+- **Carrier acceptance** means a final-carrier CI or local result is required.
+- **Proof gap** means the team does not claim closure.
+- **Owner action** is outside the builder team's authority.
+
+## Package Index
+
+- [Threat model](threat-model.md) — assets, actors, trust boundaries, data flows,
+ abuse cases, controls, and residual risks.
+- [Stage A evidence ledger](stage-a-evidence.md) — scope, source provenance,
+ acceptance rules, and known gaps.
+- [Authentication and authorization](auth-authorization.md) — HTTP, MCP, agent
+ key, project-scope, RLS, SQLite, and legacy-gating boundaries.
+- [Input validation](input-validation.md) — request models, SQL/JSON/FTS
+ construction, import paths, and the deferred directory-import gap.
+- [Secrets and redaction](secrets-redaction.md) — key storage, provider secrets,
+ public errors, logging evidence, and RAM-hygiene limits.
+- [Dependency posture](dependency-posture.md) — pinning, advisory tooling,
+ automated scans, and the Python audit gap.
+- [Review disposition and retained brief](external-review-brief.md) — the
+ original owner-owned Stage B scope, the accepted claim boundary, optional
+ future review guidance, and current status.
+
+## Historical Scan: Input, Not Sign-off
+
+A repository-wide deep scan targeted
+`2c372417e1d07d072265d2efdfbdace04f8bfcbb`, not the Phase 5 base or final
+carrier. Its report contains 41 canonical findings represented by 156 report
+instances: 24 Medium and 132 Low, no High or Critical, all with Medium
+confidence. Coverage was partial and the report explicitly retained runtime and
+deployment proof gaps.
+
+The scan artifacts remain with the release engineer. They are historical review
+input only: the counts must not be presented as a current-head finding count,
+clean bill of health, external assessment, or proof that untested paths are
+safe. Three confirmed issues from that cycle were merged in PRs #310, #311, and
+#312; the browser-clipper credential-context remediation belongs to the Phase 5
+carrier and must pass its final acceptance tests. The owner's Stage B
+disposition accepts Trusted Access automated scanning plus internal adversarial
+review; it does not turn the historical scan into an exact-candidate external
+assessment or an independently audited claim.
diff --git a/docs/security/auth-authorization.md b/docs/security/auth-authorization.md
new file mode 100644
index 00000000..c75fba49
--- /dev/null
+++ b/docs/security/auth-authorization.md
@@ -0,0 +1,84 @@
+# Authentication And Authorization Evidence
+
+## Shipped Policy
+
+Alice has two supported HTTP postures:
+
+1. **Keyless local-owner mode:** while a user has zero active agent API keys,
+ local vNext compatibility accepts owner-trusted calls. The supplied `user_id`
+ is not proof of identity, so the API must remain on loopback and inaccessible
+ to untrusted local processes.
+2. **Keyed mode:** once any active key exists for that user, protected vNext
+ requests require a valid `Authorization: Bearer alice_sk_...` key. The key
+ record supplies the user, agent identity, maximum permission profile, and
+ optional project binding; payload claims cannot change the actor or widen
+ privilege/scope.
+
+The one-time browser-clipper capability is scoped to its capture endpoint,
+user, origin, expiry, and first redemption. It is not a keyless bypass for any
+other operation.
+
+## HTTP Authorization Layers
+
+- `alicebot_api.main` applies the centralized vNext authentication boundary
+ before handler dispatch and maintains an exact route classification.
+- Route-local policy covers target-aware operations; central operator routes
+ require an unbound `trusted_local_agent` or `admin_agent` key in keyed mode.
+- Human/admin review decisions retain their stricter action policy; a trusted
+ local profile is not automatically an admin review identity.
+- Persisted memory, artifact, source, and project scope is authoritative for
+ target reads and mutations. Caller metadata cannot relabel a persisted target
+ into scope.
+- Authentication failures use 401; authenticated but unauthorized actions use
+ 403 and stable public response families.
+
+At the Phase 5 base the route inventory was 70 vNext operations. The clipper
+issuer makes the frozen carrier inventory 71: 33 route-local-policy and 38
+central-operator operations. The focused exact-set and all-route ASGI tests
+reproduced this split.
+
+## Store Boundaries
+
+- PostgreSQL runtime connections use `alicebot_app`; user transactions set the
+ application principal consumed by forced RLS policies. Cross-user access is
+ expected to return no row or reject the mutation.
+- `DATABASE_ADMIN_URL` is for migrations and explicit administration, not
+ request handling.
+- SQLite is a single-user local on-ramp. It mirrors policy behavior in service
+ code and protects the database/sidecars with owner-only permissions, but it
+ is not a multi-user RLS boundary.
+
+## MCP And Legacy Surfaces
+
+- The default registry exposes 11 core MCP tools.
+- A key-bound server uses `ALICE_AGENT_API_KEY`; payload identity cannot replace
+ that key's actor or widen its profile/project scope.
+- Legacy MCP handlers do not all have the same persisted-target authorization
+ contract, so a key-bound MCP server suppresses them.
+- `ALICE_LEGACY_SURFACES` is off by default and gates 49 legacy HTTP operations.
+ It is read at import time; restart after changing it.
+
+## Repository Evidence
+
+| Claim | Evidence |
+| --- | --- |
+| Raw key is verified against a hash, bound to one user, and revocation is honored | `apps/api/src/alicebot_api/vnext_agent_keys.py`; `tests/unit/test_vnext_agent_keys.py` |
+| Profile and project escalation are rejected and audited | `tests/unit/test_vnext_agent_keys.py`; `tests/unit/test_vnext_main.py` |
+| Keyless calls are accepted only before key provisioning, then rejected | `tests/unit/test_vnext_agent_keys.py`; `tests/unit/test_vnext_main.py` |
+| Central routes fail closed unless classified and require unbound trusted/admin actors | `tests/unit/test_vnext_main.py` |
+| Default and legacy route sets are exact | `tests/integration/test_default_surface_integration.py`; `tests/unit/test_legacy_gated_router_split.py` |
+| MCP key binding, core registry, and legacy suppression remain exact | `tests/unit/test_mcp.py`; `tests/integration/test_mcp_server.py` |
+| Cross-user database visibility is constrained | role-separated PostgreSQL integration suites and migration RLS assertions |
+
+## Carrier Acceptance And Gaps
+
+The Phase 5 carrier adds `tests/unit/test_stage_a_vnext_auth_surface.py`, a
+generated all-route matrix proving that, once a key exists, each registered
+vNext operation reaches the central boundary and rejects a missing credential
+before handler behavior. It passed on the frozen carrier alongside tests proving
+that the capability issuer is central-operator-only and capability redemption
+cannot authorize a different route or user.
+
+Run the focused commands in [the Stage A ledger](stage-a-evidence.md), then the
+full role-separated integration matrix. A skipped database or MCP posture is a
+proof gap, not a passing result.
diff --git a/docs/security/dependency-posture.md b/docs/security/dependency-posture.md
new file mode 100644
index 00000000..d8d4eacd
--- /dev/null
+++ b/docs/security/dependency-posture.md
@@ -0,0 +1,79 @@
+# Dependency And Supply-Chain Posture
+
+## Pinning Policy
+
+- The web application declares exact direct dependency versions in
+ `apps/web/package.json` and commits `apps/web/pnpm-lock.yaml`. CI installs with
+ the repository's pinned pnpm version.
+- Python runtime and development dependencies use bounded compatible ranges in
+ `pyproject.toml`; build-system dependencies are exact pins. The published
+ package intentionally permits compatible resolver updates and the repository
+ does not currently commit a full Python application lock.
+- GitHub Actions are pinned to commit SHAs. Tool versions installed inside jobs,
+ such as Gitleaks, are explicitly selected and their downloaded archive is
+ checksum-verified.
+- Dependabot checks GitHub Actions, pip, and the web npm ecosystem weekly.
+
+## Web Advisory Gate
+
+The pinned pnpm 10 audit client depends on retired registry behavior, so Alice
+uses `apps/web/scripts/npm-advisory-audit.mjs` against npm's bulk advisory
+endpoint. The wrapper:
+
+1. obtains the installed dependency tree from pnpm;
+2. refuses an empty tree;
+3. sends package/version sets to the bulk endpoint;
+4. validates that the response is a plain package-to-advisory-array object with
+ valid severity and semver ranges;
+5. range-matches every installed version; and
+6. fails closed on network, HTTP, JSON, schema, or configured-threshold failure.
+
+CI runs both production-only and full-tree audits at `high`, so High and
+Critical matches block. Lower-severity matches remain visible but do not fail
+that job. The validator's malformed/empty-response behavior is covered by
+`apps/web/scripts/npm-advisory-audit.test.mjs`.
+
+```bash
+cd apps/web
+pnpm test:advisory-audit
+node scripts/npm-advisory-audit.mjs --prod --audit-level=high
+node scripts/npm-advisory-audit.mjs --audit-level=high
+```
+
+These commands require the npm advisory service. An unavailable or malformed
+service must fail, not produce a green empty result.
+
+## Repository Scanning
+
+`.github/workflows/security-scans.yml` runs on pull requests, pushes to `main`,
+a weekly schedule, and manual dispatch:
+
+- Gitleaks scans the relevant commit range with redacted output.
+- CodeQL analyzes Python and JavaScript.
+
+Those scanners supplement review and tests; neither proves runtime reachability
+or the absence of a vulnerability.
+
+## Open Gap: Python Advisory Enforcement
+
+Dependabot monitors declared pip requirements, but Alice currently has no
+fail-closed CI command that audits the resolved Python environment against a
+vulnerability database. Compatible-range resolution also means two installers
+can select different transitive versions over time.
+
+Until that gap is deliberately closed, release evidence must record the exact
+Python environment used for tests (including `python --version` and `pip
+freeze`), review Dependabot alerts, and avoid claiming equivalent Python and web
+advisory enforcement. Adding a Python lock/audit policy is a later scoped change,
+not something these documents certify into existence.
+
+## Update Discipline
+
+- Dependency/security updates use a focused carrier with the full unit,
+ integration, web, package, and release matrix.
+- Major runtime/toolchain upgrades are independently qualified; audit-tool
+ breakage is not bypassed with `continue-on-error`.
+- Lockfile-only changes must be reviewed against their manifest intent and
+ production graph.
+- A clean advisory result is time-bound to the database response and exact
+ installed tree recorded by that run.
diff --git a/docs/security/external-review-brief.md b/docs/security/external-review-brief.md
new file mode 100644
index 00000000..24253efc
--- /dev/null
+++ b/docs/security/external-review-brief.md
@@ -0,0 +1,106 @@
+# Phase 5.1.c Review Disposition and Retained Brief
+
+## Ownership And Status
+
+Phase 5.1.c is owner-owned. Its original Stage B plan contemplated an external
+reviewer, and the scope below is retained as historical provenance. The owner's
+authoritative disposition now accepts Phase 5.1.c at the claim bar "automated
+security scanning under OpenAI Trusted Access on the repository, plus internal
+adversarial review."
+
+Current status: **owner disposition accepted at that claim bar**. No external
+auditor is required, no external audit occurred, and no exact-review-commit
+receipt from an external reviewer exists. Stage A and Stage B evidence must not
+be described as an independent audit, security certification, clean bill of
+health, or proof that every documented gap is closed. The 5.4 real-host receipt
+and green committed-SHA CI remain separate release gates.
+
+## Retained Stage B Review Target
+
+If the owner voluntarily commissions a future external review, the reviewer
+should receive:
+
+- the exact immutable release-candidate commit and package/container hashes;
+- the supported local-first and keyed single-tenant deployment profiles;
+- [the threat model](threat-model.md) and [Stage A ledger](stage-a-evidence.md);
+- the historical scan artifacts for commit
+ `2c372417e1d07d072265d2efdfbdace04f8bfcbb`, explicitly labeled partial and
+ historical;
+- instructions to run PostgreSQL and SQLite postures, default and gated HTTP
+ surfaces, the 11-tool core MCP server, browser clipper, scheduler, importers,
+ providers, exports, and redaction; and
+- the private reporting path described in the repository root `SECURITY.md`.
+
+This retained target is not a current release requirement. If used later, do
+not ask the reviewer to infer the target from `main`, a branch name, or an
+unpublished version string. Their report must name the commit and deployment
+configuration they actually assessed.
+
+## Retained Adversarial Questions
+
+1. Does the keyless-local assumption remain contained to loopback/owner trust,
+ and does keyed mode close every vNext HTTP and core MCP path?
+2. Can a key or payload escalate actor, permission profile, project scope,
+ sensitivity, review authority, or PostgreSQL user context?
+3. Can a visited page reuse, transfer, race, or leak a browser-clipper
+ capability, or cause a capture after capability rejection?
+4. Can hostile SQL/JSON/FTS input change query structure or bypass user/project/
+ lifecycle filters?
+5. Can hostile imported files escape roots, substitute content after evidence
+ capture, exhaust a worker, or create provenance that does not match parsed
+ bytes?
+6. Can agent keys, provider/connector credentials, database URLs, or sensitive
+ user content reach public errors, logs, events, traces, process arguments,
+ archives, or package artifacts?
+7. Are provider URL/redirect/DNS and response-size controls adequate for the
+ documented deployment, and what residual availability risks are acceptable?
+8. Do PostgreSQL RLS, SQLite filesystem isolation, true redaction, backup/export,
+ and legacy gating match the documentation under adversarial use?
+
+Any future reviewer should explicitly revisit the deferred directory-import
+symlink/TOCTOU issue, Python dependency-audit gap, settings cache footgun,
+redaction RAM lifetime, and the historical scan's unresolved availability and
+provider-network hypotheses.
+
+## Optional Future External-Review Deliverables
+
+These deliverables apply only if the owner later chooses to commission an
+external review; they are not required to close the current Phase 5.1.c owner
+disposition.
+
+- Executive verdict scoped to the exact commit and deployment assumptions.
+- Findings with severity, confidence, source-to-sink proof, reproduction, impact,
+ and a falsifiable remediation test.
+- Reviewed-surface and unreviewed-surface inventories.
+- Clear disposition of Stage A known gaps and historical scan hypotheses.
+- Separate code-security and deployment/operational recommendations.
+- A retest receipt for each fixed High/Critical and any other release blocker.
+
+Any future High or Critical finding would open a fix-and-retest window.
+Medium/Low items would require an explicit owner disposition; silence would not
+be acceptance.
+
+## Optional Future Stand-up Path
+
+A future reviewer should be able to reach a working local environment in under
+one hour by following:
+
+1. [`docs/alpha/headless-ubuntu-install.md`](../alpha/headless-ubuntu-install.md)
+ for role-separated PostgreSQL, or the
+ [alpha quickstart](../alpha/quickstart.md) for the SQLite on-ramp;
+2. `make setup`, migrations where applicable, and `alicebot vnext doctor
+ --fix-safe --ci`;
+3. the focused commands in [the Stage A ledger](stage-a-evidence.md); and
+4. the repository's full release matrix for the exact carrier.
+
+If those instructions fail on a clean supported host, record the failure as an
+environment/reproducibility gap before beginning substantive review.
+
+## Receipt Boundary
+
+The current owner disposition does not require an external-review receipt. If a
+future external review is commissioned, its non-secret receipt should contain
+the reviewer or organization identity, exact commit, dates, surfaces, methods,
+report location, severity rollup, open blockers, remediation commits, and
+retest status. Do not commit exploit details or live secrets to the public
+repository; use a private GitHub security advisory for active vulnerabilities.
diff --git a/docs/security/input-validation.md b/docs/security/input-validation.md
new file mode 100644
index 00000000..ad3d0b47
--- /dev/null
+++ b/docs/security/input-validation.md
@@ -0,0 +1,76 @@
+# Input Validation And Injection Evidence
+
+## HTTP And Structured Data
+
+vNext request bodies inherit the shared Pydantic model configured with
+`extra="forbid"`; typed fields, literals, UUIDs, length bounds, and route-specific
+validators reject malformed values before persistence. New raw transports must
+size-bound bytes before decoding, reject non-object JSON and unknown fields, and
+then validate through the same model contract.
+
+Metadata is treated as JSON data. Store calls serialize values through database
+drivers rather than interpolating them into SQL expressions. Any operation that
+selects a JSON field or ordering mode must choose from a code-owned allowlist.
+
+## SQL Construction
+
+The Postgres and SQLite stores bind caller values as parameters. The limited SQL
+fragments assembled dynamically are code-owned column lists, fixed predicates,
+or allowlisted sort/query modes; callers do not supply identifiers or SQL
+syntax. SQL-shape tests pin security-sensitive predicates and generated text so
+a refactor cannot silently drop user, project, lifecycle, or signature filters.
+
+The Stage A source sweep found no confirmed SQL or JSON-path injection in the
+reviewed current store paths. That is a bounded review result, not a claim that
+future dynamic SQL is safe by construction.
+
+## Full-Text Search
+
+- PostgreSQL passes the strict query to `websearch_to_tsquery` as a bound value.
+ Its match-any fallback constructs an OR expression from normalized literal
+ lexemes and still binds the resulting query value.
+- SQLite FTS5 query builders quote normalized tokens rather than accepting raw
+ FTS syntax. Unit tests exercise quotes, operators, punctuation, and FTS5
+ metacharacters for memory and source-chunk search.
+
+SQLite adversarial FTS coverage is currently broader. Stage B should include
+hostile PostgreSQL strings across strict, match-any, source, and scoped paths,
+including empty/stop-word-only inputs and Unicode boundary cases.
+
+## File And Import Paths
+
+The SQLite portable export/import path has extensive alias, inode, symlink,
+sidecar-name, immutable-snapshot, integrity-digest, unknown-schema, and atomic
+publication tests. Those controls do **not** close a separate gap in the
+content-directory importers:
+
+- Markdown recursively discovers `*.md` files;
+- ChatGPT recursively discovers `*.json` files;
+- OpenClaw reads named JSON files or direct-directory JSON fallbacks.
+
+Those importers can currently include a symlinked member outside the selected
+root, and the archive step and parser can read the source at different times.
+The recorded archive checksum can therefore describe different bytes than the
+parsed memory, or a local attacker can substitute content between reads. This
+finding is deferred from the Phase 5.1 carrier.
+
+Until remediation, import only from a private staging directory owned by the
+Alice operator, reject/remove symlinks before import, and ensure no other
+process can mutate the directory during the run. A later fix should open files
+once without following symlinks, enforce root containment on the opened object,
+and feed the same immutable bytes to both archiving and parsing.
+
+## Focused Evidence
+
+```bash
+./.venv/bin/pytest -q \
+ tests/unit/test_importers.py \
+ tests/unit/test_sqlite_onramp.py \
+ tests/unit/test_vnext_store.py \
+ tests/unit/test_sqlite_store.py \
+ tests/integration/test_source_content_retrieval_postgres.py \
+ tests/integration/test_vnext_fts_fallback_postgres.py
+```
+
+Run role-separated PostgreSQL tests against the supported database/pgvector
+version. Do not treat a skipped integration suite as proof.
diff --git a/docs/security/secrets-redaction.md b/docs/security/secrets-redaction.md
new file mode 100644
index 00000000..12128158
--- /dev/null
+++ b/docs/security/secrets-redaction.md
@@ -0,0 +1,104 @@
+# Secrets, Keys, Logging, And Redaction Evidence
+
+## Agent API Keys
+
+`alicebot agent keys create` mints an `alice_sk_...` value and returns it once.
+The stores persist its SHA-256 verifier and a short prefix for identification,
+not the raw key. Verification uses constant-time digest comparison; revocation
+and last-used state live on the key record. Treat the creation response and
+terminal history as sensitive, and rotate a key if its one-time value was not
+captured safely.
+
+The code path and unit tests prove hash-only store input. The Phase 5 carrier
+adds HTTP and role-separated database sentinels for invalid/foreign keys, public
+responses, logs, events, and cross-user visibility; existing escalation tests
+also inspect audit payloads. Final acceptance requires those tests to pass on
+the exact carrier. Stage B should still probe successful, escalation, and
+internal-error paths end to end rather than infer universal non-disclosure from
+the bounded sentinels.
+
+## Browser Clipper Capability
+
+The bookmarklet must never receive a reusable agent key or connector
+`capture_token`. The trusted Alice UI issues a random, short-lived capability
+bound to one user and normalized web origin. Persistence contains only the
+capability hash, expiry, and consumption state. Redemption requires a matching
+non-opaque `Origin`. The first authorized capture attempt consumes the row in
+the capture transaction even when later content normalization or import fails;
+retrying therefore requires a freshly issued capability.
+
+The final carrier acceptance suite must prove valid first use; replay/reload,
+other-origin, expired, and tampered rejection; exactly one winner under
+concurrent redemption; and absence of raw capabilities from source metadata,
+events, traces, error bodies, logs, documentation examples, and OpenAPI examples.
+
+## Provider And Connector Secrets
+
+- Current provider registration stages credentials in the local provider secret
+ manager and persists a reference in the provider row. Hosted-era provider
+ rows are not adopted into the deterministic local workspace; re-register them
+ after upgrade.
+- Connector configuration persists `secret_ref` identifiers. The composite
+ provider can resolve environment-backed refs or an owner-only encrypted local
+ file. Secret-shaped fields are recursively replaced with `***` before
+ connector status/event output.
+- Provider runtime errors are normalized before public response/telemetry. Base
+ URL validation rejects embedded credentials and disallowed local/private
+ network targets.
+- Legacy plaintext provider-field resolution remains for migration
+ compatibility. Do not create new plaintext rows; re-register or rotate legacy
+ provider configurations onto the current secret-reference path.
+
+The Phase 5 carrier extends the live provider integration test so the exact
+configured credential is observed at the stub transport and proven absent from
+successful public payloads, durable telemetry, and captured logs. Final
+acceptance requires that test and the provider failure-family sanitization tests
+to pass on the exact carrier; Stage B should still test unexpected exception
+paths.
+
+## Public Error Boundary
+
+`public_exception_response` maps exceptions to a fixed public vocabulary and
+keeps exception type/text out of the serialized body. An AST gate scans
+`main.py` and every router, detects direct and delayed `str(exc)` response
+patterns, and pins a per-module call manifest. The carrier is expected to retain
+298 direct calls; this count must be reproduced rather than edited by rote.
+
+Private logs can still contain exception detail for operator diagnosis. Protect
+log files as sensitive data, restrict access and retention, and never rely on
+the public response scrubber to make unsafe exception messages suitable for
+logs.
+
+## Redaction Limit
+
+True redaction scrubs governed content from durable memories and coupled
+artifacts, revisions, events, ratings, and provenance while retaining the
+minimal audit skeleton. PostgreSQL and SQLite integration/unit suites verify the
+returned and persisted result.
+
+Some authorization and bundle-building paths read the pre-redaction row into
+Python memory before the transactional scrub. This is result-safe but extends
+plaintext lifetime in RAM until objects are released and the process memory is
+reused. Alice does not claim memory-zeroization guarantees; use host isolation,
+swap/dump policy, and process lifecycle controls for highly sensitive data.
+
+## Focused Evidence
+
+```bash
+./.venv/bin/pytest -q \
+ tests/unit/test_vnext_agent_keys.py \
+ tests/unit/test_stage_a_vnext_auth_surface.py \
+ tests/unit/test_browser_clip_capabilities.py \
+ tests/unit/test_browser_clip_capability_storage.py \
+ tests/unit/test_vnext_secrets.py \
+ tests/unit/test_provider_secrets.py \
+ tests/unit/test_provider_security.py \
+ tests/unit/test_public_errors.py \
+ tests/integration/test_api_logging_smoke.py \
+ tests/integration/test_stage_a_agent_key_isolation.py \
+ tests/integration/test_browser_clip_capabilities.py \
+ tests/integration/test_provider_runtime_api.py
+```
+
+The role-separated true-redaction integration suite and the final-carrier
+browser-capability tests are also required. Skips are missing evidence.
diff --git a/docs/security/stage-a-evidence.md b/docs/security/stage-a-evidence.md
new file mode 100644
index 00000000..f36a5602
--- /dev/null
+++ b/docs/security/stage-a-evidence.md
@@ -0,0 +1,124 @@
+# Stage A Evidence Ledger
+
+## Epistemic Status
+
+This is team-authored preparation, not an external or independent audit. It was
+started from `main` at
+`c9d24243920a694eaf00ad595da392a1478710dd`. The focused Stage A matrix below
+was reproduced on the now-superseded pre-commit carrier. Commit `e8d2018`
+subsequently failed CI, so receipt `4cf7e08b...` is not shippable and the
+repaired carrier needs fresh evidence. A result from the base, the superseded
+working tree, or the historical scan does not transfer automatically to a
+later tree.
+
+The owner has accepted Phase 5.1.c at the claim bar "automated security
+scanning under OpenAI Trusted Access on the repository, plus internal
+adversarial review." This closes the owner disposition at that stated bar; it
+does not assert that an external review occurred, that the carrier was
+independently audited, or that the open proof gaps below disappeared. Stage B
+history is retained to make that distinction auditable.
+
+## Surface Closure
+
+| Surface | Base | Superseded carrier result | Acceptance evidence |
+| --- | ---: | ---: | --- |
+| Default HTTP operations | 182 | 183 | `tests/integration/test_default_surface_integration.py` and OpenAPI closure tests |
+| `/v0/vnext` operations | 70 | 71 | route-policy inventory in `tests/unit/test_vnext_main.py` |
+| HTTP operations with legacy surfaces | 231 | 232 | `tests/unit/test_legacy_gated_router_split.py` |
+| Core MCP tools | 11 | 11 | MCP registry/sentinel tests |
+| Legacy HTTP delta | 49 | 49 | default/gated exact-set comparison |
+| Direct `public_exception_response` calls | 298 | 298 | per-module manifest in `tests/unit/test_public_errors.py` |
+
+The deltas reflect one new browser-clipper capability-issuance route. Focused
+closure tests reproduced the exact sets and counts on the superseded carrier.
+The repaired carrier and eventual release commit must reproduce them again; no
+count may be updated merely to make a gate pass.
+
+## Evidence Areas
+
+| Area | Implemented/test evidence | Open acceptance or proof gap |
+| --- | --- | --- |
+| Authentication and authorization | Central vNext auth gate, key-bound actor/profile/scope, RLS, central-route classification, default/legacy gating, core MCP sentinels, and an all-71-route ASGI keyless-rejection matrix. | Re-run the exact route inventory and live per-user RLS/key-isolation tests on the release commit. |
+| Browser clipper | Carrier replaces reusable page-context token with a short-lived, origin-bound, one-time capability, bounded simple-request transport, sanitized validation, value-based taint redaction, and hash-only persistence. | Focused tests reproduced first use, replay/reload, cross-origin, expiry, tamper, atomic concurrent double redemption, and absence from persisted/error surfaces; repeat them on the release commit and retain browser-policy limitations. |
+| Input/injection | Fail-closed vNext models; parameterized store SQL; SQL-shape tests; hostile FTS tests; SQLite portable-import snapshot/alias tests. | Directory importers have an outside-root symlink and archive/parse TOCTOU gap; PostgreSQL hostile FTS coverage should be broadened. |
+| Secrets/errors | Agent-key hash verifier, provider secret references, recursive key/value redaction, sanitized provider errors, stable public-error vocabulary and AST manifest. Raw-agent-key log and provider-key non-echo sentinels passed on the superseded carrier. | Re-run on the repaired carrier and release commit; redaction pre-read still leaves transient plaintext in RAM. |
+| Dependencies | Exact web package versions and lockfile; production/full npm bulk audits; Dependabot; CodeQL; Gitleaks; SHA-pinned Actions. | No fail-closed Python advisory audit or fully locked Python application graph. |
+| Configuration | Production config checks, explicit CORS, loopback defaults, security headers. | `get_settings()` is first-caller-wins per process; document for embedders. |
+
+## Reproduction Commands
+
+Run with the repository's supported environment and exact dependency state.
+PostgreSQL integration commands require the documented role-separated test
+database. Treat skipped security tests as missing evidence, not a pass.
+
+```bash
+./.venv/bin/pytest -q \
+ tests/unit/test_vnext_agent_keys.py \
+ tests/unit/test_stage_a_vnext_auth_surface.py \
+ tests/unit/test_vnext_main.py \
+ tests/unit/test_mcp.py \
+ tests/unit/test_public_errors.py \
+ tests/unit/test_browser_clip_capabilities.py \
+ tests/unit/test_browser_clip_capability_storage.py \
+ tests/unit/test_20260721_0094_browser_clip_capabilities.py \
+ tests/unit/test_provider_security.py \
+ tests/unit/test_provider_secrets.py \
+ tests/unit/test_vnext_secrets.py \
+ tests/unit/test_importers.py \
+ tests/unit/test_sqlite_onramp.py
+
+./.venv/bin/pytest -q \
+ tests/integration/test_default_surface_integration.py \
+ tests/integration/test_stage_a_agent_key_isolation.py \
+ tests/integration/test_browser_clip_capabilities.py \
+ tests/integration/test_http_security_posture.py \
+ tests/integration/test_source_content_retrieval_postgres.py \
+ tests/integration/test_vnext_fts_fallback_postgres.py
+
+pnpm --dir apps/web test
+pnpm --dir apps/web test:browser
+
+(cd apps/web && pnpm test:advisory-audit)
+(cd apps/web && node scripts/npm-advisory-audit.mjs --prod --audit-level=high)
+(cd apps/web && node scripts/npm-advisory-audit.mjs --audit-level=high)
+```
+
+The full release matrix, migration tests, coverage floors, OpenAPI registry
+closure, and both integration postures remain required in addition to this
+focused set.
+
+## Historical Scan Provenance
+
+The prior deep scan targeted commit
+`2c372417e1d07d072265d2efdfbdace04f8bfcbb`. Its 41 canonical findings were
+expanded into 156 report instances (24 Medium and 132 Low; no High/Critical),
+all Medium-confidence with partial coverage and proof gaps. It is input to this
+ledger, not evidence about the final carrier and not an external or independent
+review.
+
+## Deferred Finding: Directory Import Source Integrity
+
+The Markdown, ChatGPT, and OpenClaw directory importers enumerate path objects,
+archive content, and later reopen the selected source for parsing. They do not
+currently reject every symlinked member or guarantee that the parsed bytes are
+the bytes whose archive checksum was recorded. A source controlled by another
+local actor can therefore escape the selected root or be substituted between
+reads.
+
+This is deliberately documented, not fixed, in the 5.1 carrier. Until a later
+remediation lands, operators must stage imports in a private, owner-controlled,
+non-symlinked directory and stop writers before import. Any future adversarial
+review should validate reachability and severity if this issue is revisited,
+including Markdown recursive discovery, ChatGPT recursive JSON discovery, and
+OpenClaw named/fallback JSON discovery. The owner-accepted Phase 5.1.c
+disposition does not silently close this documented gap.
+
+## Stage A Exit Rule
+
+Stage A is ready to hand to the owner only when the final-carrier commands and
+full release matrix pass without security-relevant skips, target operation
+counts are reproduced, the browser-clipper negative tests pass for both stores,
+and every remaining gap is explicitly retained. The owner has recorded the
+Stage B disposition at the Trusted Access automated-scan plus internal
+adversarial-review bar. Phase 5 still requires green committed-SHA CI and the
+5.4 real-host receipt; these documents grant neither.
diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md
new file mode 100644
index 00000000..219fc86f
--- /dev/null
+++ b/docs/security/threat-model.md
@@ -0,0 +1,204 @@
+# Shipped-Product Threat Model
+
+## Overview
+
+Alice is an agent continuity and governed-memory layer. Its primary runtime
+surfaces are the HTTP API and `/vnext` operator UI, a core MCP server over
+stdio, PostgreSQL or a local SQLite on-ramp, import/capture connectors, model
+providers, a scheduler, and local export/backup/log paths.
+
+This model covers the default local-first, single-user, self-hosted product:
+the HTTP API and `/vnext` operator UI, 11 core MCP tools over stdio, per-agent API
+keys, PostgreSQL with per-user RLS, the SQLite on-ramp, connectors/importers,
+model providers, the scheduler, exports/backups, and local logs.
+
+The frozen Phase 5 Stage A carrier exposes 183 default HTTP operations, of which
+71 are `/v0/vnext`, and 232 operations when `ALICE_LEGACY_SURFACES=1`; focused
+closure tests reproduced those exact sets. Legacy surfaces are off
+by default and the flag is read at import time, so changing it requires a
+process restart.
+
+## Threat Model, Trust Boundaries, And Assumptions
+
+### Deployment Assumptions
+
+Explicitly out of scope are multi-tenant hosting, a managed control plane, SLA
+claims, protection from a compromised host/root account, and the security of
+third-party model providers beyond Alice's validation and disclosure controls.
+
+Keyless operation assumes the API is loopback-only and every process and OS
+user that can reach it is trusted as the owner. A finding that requires public
+exposure of a deliberately keyless port therefore violates the supported
+deployment assumption; a code path that bypasses an active-key boundary does
+not.
+
+### Assets And Security Objectives
+
+| Asset | Objective |
+| --- | --- |
+| Memories, source evidence, artifacts, traces, and exports | Preserve confidentiality and user/project scoping; keep provenance truthful. |
+| Review decisions and lifecycle state | Prevent privilege escalation, cross-project action, forged reviewer identity, and mutation after terminal decisions. |
+| Agent keys, provider credentials, connector secrets, database URLs, and one-time capabilities | Minimize exposure, store verifiers/references rather than reusable plaintext where supported, and never place broad credentials in hostile page context. |
+| Event and revision history | Preserve audit skeletons, authenticated actor attribution, and append-only or controlled-redaction invariants. |
+| Retrieval and generated output | Treat imported/provider text as untrusted data; do not let content become policy or broaden authorization. |
+| Runtime availability | Bound inputs and external work sufficiently for the supported single-user deployment; make remaining resource-exhaustion risk visible. |
+
+### Actors
+
+- **Local owner/operator:** controls the host, config, database, UI, and key
+ lifecycle. Fully trusted in keyless local mode.
+- **Keyed agent:** trusted only for the identity, permission profile, user, and
+ optional project scope bound to its key.
+- **Local unkeyed process:** equivalent to the owner only while the deployment
+ deliberately remains keyless and loopback-only.
+- **Visited web page:** hostile. Its DOM, JavaScript, extensions, service workers,
+ and origin can inspect bookmarklet state and influence captured content.
+- **Imported content or source directory:** hostile data. Files may be malformed,
+ oversized, symlinked, or changed concurrently.
+- **Provider/connector service:** external and potentially faulty, malicious, or
+ compromised; it receives configured prompts/data and may return hostile
+ content or errors.
+- **Remote network client:** untrusted unless authenticated through the supported
+ key and deployment boundary.
+- **Other local OS user or container peer:** out of the keyless trust set; host
+ permissions and network binding must exclude it.
+
+### Input Control
+
+- **Attacker-controlled in supported deployments:** authenticated HTTP and MCP
+ payloads from a compromised/low-privilege agent; visited-page DOM, URL,
+ selection, scripts, and origin; imported files and metadata; provider and
+ connector responses; FTS/search text; and any remote request admitted by the
+ configured reverse proxy.
+- **Operator-controlled:** environment variables, database URLs, provider
+ endpoints, CORS origins, feature flags, import roots, backup/export paths,
+ secrets, key issuance/revocation, and host/proxy/firewall configuration.
+ These remain dangerous inputs but normally require an owner mistake or
+ compromised owner account.
+- **Developer-controlled:** migrations, route/policy registries, OpenAPI
+ contracts, fixed SQL fragments, dependency manifests/locks, CI workflows,
+ and release gates. Compromise here is a build or supply-chain threat.
+
+### Trust Boundaries
+
+| Boundary | Crossing | Required control |
+| --- | --- | --- |
+| Host/network | Client to HTTP API or web UI | Loopback in keyless mode; otherwise TLS reverse proxy, active agent keys, exact CORS origins, and firewall isolation. |
+| Browser/operator UI | Trusted Alice page to API | Operator key retained only in mounted-session memory; no key in URL, storage, logs, or page content. |
+| Visited-page context | Bookmarklet to clipper capture route | Short-lived, origin-bound, one-time capability; no agent key or reusable `capture_token`; atomic redemption. |
+| HTTP identity | Request body/query to authenticated actor | Key record, not payload, controls identity/profile; active-key deployments reject missing/invalid Bearer credentials. |
+| Authorization | Actor to target memory/artifact/project | Persisted target scope and sensitivity drive policy; project-bound keys cannot widen; central routes require unbound trusted/admin keys. |
+| Application/database | Runtime store operation to PostgreSQL | Application role, transaction-scoped `app.current_user_id`, forced RLS, parameterized SQL; admin URL reserved for migration/recovery. |
+| Local process/file | SQLite, secrets, logs, exports, imports | Owner-only paths, alias/symlink checks where implemented, explicit import provenance; SQLite is not a tenant boundary. |
+| MCP client/process | JSON-RPC stdio to core tools | Local process trust when keyless; `ALICE_AGENT_API_KEY` binds a key and suppresses legacy handlers lacking equivalent persisted-target authorization. |
+| Alice/provider | Outbound model or connector request | Validated provider configuration, credential references, sanitized public errors, restrictive network deployment policy. |
+| Content/policy | Source or model text to memory/review action | Content remains data; policy evaluation and review gates are code-controlled. |
+
+### Principal Data Flows
+
+1. A local or keyed caller submits a vNext request with `user_id` and optional
+ agent claims. The centralized HTTP boundary resolves the key and replaces or
+ rejects claims before the route executes.
+2. Policy-aware routes authorize requested scope; target-changing routes re-read
+ persisted target scope where required. The store runs under the user's RLS
+ principal in PostgreSQL or the owner's local SQLite file.
+3. Captured/imported content is normalized into sources, memories, evidence,
+ events, and revisions. Review and explicit commit paths control promotion to
+ trusted memory.
+4. Retrieval applies scope filters before returning context. Provider calls may
+ receive selected content; provider responses and errors return through
+ bounded contracts and public-error sanitization.
+5. The trusted Alice UI requests a browser-clip capability for a normalized
+ origin. The visited page submits one capture with that narrow capability;
+ the first authorized attempt consumes it. A failed normalization or import
+ does not make the capability reusable, so retry requires fresh issuance.
+6. Exports, backups, source archives, and logs cross from application state to
+ the local filesystem and inherit the sensitivity of the source material.
+
+## Attack Surface, Mitigations, And Attacker Stories
+
+The most important attacker stories are a lower-privilege keyed agent trying to
+become an admin or cross project/user scope; a hostile page trying to turn a
+clip operation into reusable Alice authority; an imported file changing query,
+filesystem, or provenance behavior; an external provider exfiltrating secrets
+through errors/redirects or exhausting a worker; and a dependency/build change
+compromising published artifacts.
+
+Browser-only CSRF and XSS stories depend on whether an operator exposes Alice to
+an untrusted browser origin. Exact CORS origins and loopback defaults reduce the
+default likelihood, but the visited-page bookmarklet is intentionally treated
+as hostile regardless. Classic multi-tenant session attacks are not a supported
+product story because Alice has no managed multi-tenant session boundary; an
+active-key or RLS bypass remains in scope.
+
+### Priority Abuse Cases And Controls
+
+| Abuse case | Control/evidence | Residual concern |
+| --- | --- | --- |
+| Missing key treated as remote anonymous access | Documented local-only boundary; active-key rule rejects keyless requests. | Host/proxy misconfiguration can invalidate the assumption. |
+| Payload claims a stronger profile or another project | Key-bound actor/profile/scope, escalation rejection events, policy tests. | Final carrier needs all-route ASGI closure evidence. |
+| Cross-user PostgreSQL read/write | Application-role RLS and user-scoped connections. | Admin credentials or a compromised host bypass the product boundary. |
+| Broad credential exposed to a visited page | One-time origin-bound clipper capability replaces reusable bookmarklet token. | The page can make its one authorized submission; the UI must show the bound origin. |
+| SQL, JSON-path, or FTS injection | Parameter binding, fixed/allowlisted SQL fragments, fail-closed request models, adversarial FTS tests. | Every new dynamic fragment needs review; PostgreSQL hostile-query coverage is narrower than SQLite coverage. |
+| File traversal, symlink escape, or source substitution | SQLite portable-import alias/snapshot controls and import provenance. | Markdown, ChatGPT, and OpenClaw directory importers still have the documented symlink/TOCTOU gap. |
+| Secret or exception disclosure | Hash/reference storage, recursive secret-field redaction, provider error sanitization, stable public error vocabulary. | Final carrier must close raw-key logging and exact provider-key non-echo proof. |
+| Dependency compromise or known advisory | Exact web versions/lockfile, fail-closed npm bulk audit, Dependabot, SHA-pinned Actions, CodeQL, Gitleaks. | No fail-closed Python advisory audit is currently in CI. |
+| Resource exhaustion from hostile files/provider responses | Existing size/shape checks and local deployment limits. | Historical partial scan retained multiple low-confidence availability hypotheses for Stage B. |
+
+### Known Internal Limitations
+
+- `get_settings()` uses a process-wide `lru_cache(maxsize=1)`. That is acceptable
+ for Alice's one-configuration-per-process runtime, but embedders that mutate
+ environment/config after the first call can observe first-caller-wins state.
+- Redaction flows may read content into process memory before validating and
+ applying the durable scrub. Returned/persisted results are redacted, but the
+ transient plaintext lifetime is a RAM-hygiene limitation.
+- The Markdown, ChatGPT, and OpenClaw directory importers can follow an
+ outside-root symlink and can reread a file after archiving it. Until remediated,
+ import only from an owner-controlled, immutable staging directory containing
+ no symlinks.
+- Python dependency advisories are monitored by Dependabot but are not checked
+ by a fail-closed install-tree audit in CI.
+- Stage A tests are team-authored. They reduce review cost; they do not replace
+ adversarial testing by the owner-appointed Stage B reviewer.
+
+## Severity Calibration
+
+Severity is calibrated to the documented local-first and keyed single-tenant
+profiles, not to a hypothetical public multi-tenant service.
+
+- **Critical:** realistic compromise of the supported release or host boundary
+ with catastrophic blast radius, such as unauthenticated remote code execution
+ in a supported keyed deployment, compromise of the release pipeline that
+ ships attacker code, or extraction/destruction of all user data and broad
+ credentials without meaningful preconditions.
+- **High:** bypass of an active key or PostgreSQL tenant boundary that exposes or
+ irreversibly mutates broad sensitive state; privilege escalation from a
+ project/read-only key to admin redaction/review power; or broad provider/agent
+ credential disclosure to a realistic remote attacker. Public reachability
+ and breadth can raise a Medium to High.
+- **Medium:** scoped but material confidentiality/integrity loss, reusable
+ browser authority exposed to an ordinary visited page, provenance mismatch or
+ outside-root read from an attacker-influenced import directory, or reliable
+ service exhaustion through a normal supported input. Strong local-owner
+ preconditions or a narrow record set can lower severity.
+- **Low:** bounded availability or metadata exposure requiring owner-controlled
+ input, unsupported public exposure of a deliberately keyless service,
+ hardening gaps on an already compromised local account, or defense-in-depth
+ weaknesses without a demonstrated supported attack path.
+
+An issue is not dismissed solely because Alice is local-first: malicious web
+pages, imported files, providers, and lower-privilege agent keys are real
+untrusted actors. Conversely, a story that assumes the trusted owner is already
+root or deliberately publishes a keyless loopback service must state that
+precondition and should not be rated like an active-key remote bypass.
+
+## Review And Change Rule
+
+Update this model whenever an external surface, credential type, deployment
+assumption, database boundary, importer, or provider transport changes. The
+reviewer must bind conclusions to an exact commit and deployment profile. See
+the [external-review brief](external-review-brief.md).
+
+Historical scan provenance: target_sha256_ef4c4bf0346dfa7c51c7095b6fcf0a4bb671ad7cb04ba861e4e9e8194fdc283a
+Stage A base revision: c9d24243920a694eaf00ad595da392a1478710dd
diff --git a/docs/vnext/architecture.md b/docs/vnext/architecture.md
index 3d043630..8029314c 100644
--- a/docs/vnext/architecture.md
+++ b/docs/vnext/architecture.md
@@ -139,7 +139,7 @@ The connector layer has two local-first tiers.
Live local capture supports:
- local folder and Obsidian-style Markdown/text scan or polling watch
-- browser clipper capture through `POST /v0/vnext/connectors/browser-clipper/capture` and bookmarklet guidance
+- browser clipper capability issuance through `POST /v0/vnext/connectors/browser-clipper/capabilities`, followed by one-time origin-bound capture through `POST /v0/vnext/connectors/browser-clipper/capture`
- generic agent output ingestion through CLI/API/MCP
Deterministic payload ingestion remains available for:
diff --git a/docs/vnext/security-privacy.md b/docs/vnext/security-privacy.md
index ff127241..843e4f83 100644
--- a/docs/vnext/security-privacy.md
+++ b/docs/vnext/security-privacy.md
@@ -50,7 +50,7 @@ Secret rules:
- API, CLI, UI, event logs, source metadata, artifact metadata, and health responses must expose only the reference or configured/not-configured status.
- Redaction applies before raw connector payloads are persisted.
-- Browser clipper capture tokens are accepted only when configured and are redacted from source/event evidence.
+- Trusted API clients may use a configured browser-clipper capture token, which is redacted from source/event evidence. The bookmarklet never receives it: the trusted Alice console issues a short-lived, origin-bound, one-time capability whose digest is stored until atomic redemption.
- The future OS keychain or hosted secret-provider implementation should satisfy the same interface without changing connector behavior.
Allowed public demo material:
diff --git a/packaging/cloud/Caddyfile.example b/packaging/cloud/Caddyfile.example
new file mode 100644
index 00000000..73d4e38d
--- /dev/null
+++ b/packaging/cloud/Caddyfile.example
@@ -0,0 +1,43 @@
+{
+ # Replace before deployment. Caddy obtains and renews a public certificate.
+ email alice-operator@example.com
+ admin 127.0.0.1:2019
+ servers {
+ strict_sni_host on
+ }
+}
+
+alice.example.com {
+ encode zstd gzip
+ tls {
+ # Install the public certificate of the operator CA that issues one
+ # client certificate per authorized browser or agent at this path.
+ client_auth {
+ mode require_and_verify
+ trust_pool file /run/secrets/alicebot/client-ca.pem
+ }
+ }
+
+ log {
+ output stdout
+ format json
+ }
+
+ header {
+ -Server
+ Content-Security-Policy "frame-ancestors 'none'"
+ Referrer-Policy "no-referrer"
+ Strict-Transport-Security "max-age=31536000; includeSubDomains"
+ X-Content-Type-Options "nosniff"
+ X-Frame-Options "DENY"
+ }
+
+ @alice_api path /healthz /openapi.json /docs /docs/* /redoc /redoc/* /v0/vnext /v0/vnext/*
+ handle @alice_api {
+ reverse_proxy 127.0.0.1:8000
+ }
+
+ handle {
+ reverse_proxy 127.0.0.1:3000
+ }
+}
diff --git a/packaging/cloud/single-tenant.env.example b/packaging/cloud/single-tenant.env.example
new file mode 100644
index 00000000..f38de275
--- /dev/null
+++ b/packaging/cloud/single-tenant.env.example
@@ -0,0 +1,51 @@
+# Single-tenant production contract. This file contains placeholders, not
+# deployable credentials. Render it at deploy time from a secret manager into
+# an owner-only environment file; never commit the rendered file.
+
+APP_ENV=production
+APP_HOST=127.0.0.1
+APP_PORT=8000
+APP_RELOAD=false
+APP_LOG_MODE=stdout
+APP_ACCESS_LOG=true
+HEALTHCHECK_TIMEOUT_SECONDS=2
+
+# The API receives only the alicebot_app DSN. Inject DATABASE_ADMIN_URL
+# separately into migration and recovery processes; never add it to this
+# runtime environment. Substitute a URL-userinfo-percent-encoded value (or a
+# URL-safe password), never raw password characters that alter URL parsing.
+DATABASE_URL="postgresql://alicebot_app:${ALICEBOT_DB_APP_PASSWORD}@db.alice.internal:5432/alicebot?sslmode=verify-full&sslrootcert=/run/secrets/alicebot/postgres-ca.pem"
+
+# One server-side user owns this installation. Use a newly generated UUID.
+ALICEBOT_AUTH_USER_ID=00000000-0000-0000-0000-000000000001
+NEXT_PUBLIC_ALICEBOT_USER_ID=00000000-0000-0000-0000-000000000001
+
+# The API and web build share one exact public HTTPS origin. NEXT_PUBLIC_*
+# values are embedded at build time, so rebuild the web app after changing it.
+PUBLIC_ORIGIN=https://alice.example.com
+CORS_ALLOWED_ORIGINS=https://alice.example.com
+CORS_ALLOWED_METHODS=GET,POST,PUT,PATCH,DELETE,OPTIONS
+CORS_ALLOWED_HEADERS=Authorization,Content-Type,X-AliceBot-User-Id
+CORS_ALLOW_CREDENTIALS=false
+CORS_PREFLIGHT_MAX_AGE_SECONDS=600
+NEXT_PUBLIC_ALICEBOT_API_BASE_URL=https://alice.example.com
+
+# Caddy is the only direct client of the loopback API. Alice accepts forwarded
+# headers only from that exact peer; Caddy preserves the real client address.
+TRUST_PROXY_HEADERS=true
+TRUSTED_PROXY_IPS=127.0.0.1
+LEGACY_V0_ENABLED_OUTSIDE_DEV=false
+ALICE_LEGACY_SURFACES=0
+ALICE_MCP_LEGACY_TOOLS=0
+
+SECURITY_HEADERS_ENABLED=true
+SECURITY_HEADERS_HSTS_MAX_AGE_SECONDS=31536000
+SECURITY_HEADERS_HSTS_INCLUDE_SUBDOMAINS=true
+
+# Start the prebuilt Next.js app with these explicit loopback values.
+ALICE_WEB_HOST=127.0.0.1
+ALICE_WEB_PORT=3000
+
+# Optional connector/model credentials are injected as separate environment
+# variables by the deploy platform. Persist only env:NAME connector references
+# in Alice; do not add raw values or WORKSPACE_PROVIDER_CONFIGS_JSON here.
diff --git a/scripts/_phase5_ops_seed.py b/scripts/_phase5_ops_seed.py
new file mode 100755
index 00000000..22f85883
--- /dev/null
+++ b/scripts/_phase5_ops_seed.py
@@ -0,0 +1,222 @@
+#!/usr/bin/env python3
+"""Seed the small deterministic store used by the Phase 5 operations drill.
+
+This helper is intentionally compatible with the v0.12.0 source tree. The
+orchestrator runs it with ``PYTHONPATH`` pointed at either an extracted
+v0.12.0 archive or the current checkout, so the data is written by the runtime
+being exercised instead of by an ad-hoc SQL fixture.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+from pathlib import Path
+from typing import Any
+from uuid import UUID
+
+
+USER_ID = UUID("00000000-0000-0000-0000-000000005001")
+MEMORY_ID = "00000000-0000-0000-0000-000000005101"
+ARTIFACT_ID = "00000000-0000-0000-0000-000000005201"
+OLDER_RATING_ID = "00000000-0000-0000-0000-000000005301"
+NEWER_RATING_ID = "00000000-0000-0000-0000-000000005302"
+REVIEWER_ID = "phase5-ops-reviewer"
+SEED_QUERY = "cobalt recovery beacon"
+
+
+def _memory_payload(label: str) -> dict[str, object]:
+ text = f"The cobalt recovery beacon identifies the {label} operations evidence store."
+ return {
+ "id": MEMORY_ID,
+ "memory_key": f"phase5.ops.{label}",
+ "value": {"text": text},
+ "status": "active",
+ "memory_type": "decision",
+ "confirmation_status": "confirmed",
+ "trust_class": "human_curated",
+ "title": "Cobalt recovery beacon",
+ "canonical_text": text,
+ "summary": "Deterministic recovery evidence anchor.",
+ "domain": "project",
+ "sensitivity": "internal",
+ "metadata_json": {"evidence_fixture": "phase5_ops_v1"},
+ }
+
+
+def _signed_vector(store: Any, memory: dict[str, object]) -> None:
+ from alicebot_api.vnext_embeddings import ( # imported from selected source tree
+ EMBEDDING_SIGNATURE_VERSION,
+ EMBEDDING_VECTOR_DIMENSIONS,
+ memory_embedding_content_sha256,
+ )
+
+ vector = [1.0, *([0.0] * (EMBEDDING_VECTOR_DIMENSIONS - 1))]
+ updated = store.update_memory_embedding(
+ memory_id=str(memory["id"]),
+ vector=vector,
+ provider="phase5-ops",
+ model="deterministic-v1",
+ endpoint="phase5-ops-local",
+ content_sha256=memory_embedding_content_sha256(memory),
+ signature_version=EMBEDDING_SIGNATURE_VERSION,
+ )
+ if updated is None:
+ raise RuntimeError("signed embedding fixture was not persisted")
+
+
+def _seed_sqlite(db_path: Path, *, label: str) -> dict[str, object]:
+ from alicebot_api.onramp import bootstrap_database
+ from alicebot_api.sqlite_store import SQLiteVNextStore, sqlite_user_connection
+
+ bootstrap_database(db_path, user_id=USER_ID, user_email="phase5-ops@example.invalid")
+ with sqlite_user_connection(db_path, USER_ID) as conn:
+ store = SQLiteVNextStore(conn, USER_ID)
+ memory = store.create_memory(_memory_payload(label), actor_type="system")
+ _signed_vector(store, memory)
+ counts = {
+ "users": int(conn.execute("SELECT count(*) AS count FROM users").fetchone()["count"]),
+ "memories": int(
+ conn.execute("SELECT count(*) AS count FROM memories").fetchone()["count"]
+ ),
+ "event_log": int(
+ conn.execute("SELECT count(*) AS count FROM event_log").fetchone()["count"]
+ ),
+ }
+ return {"backend": "sqlite", "counts": counts, "seeded": True}
+
+
+def _seed_postgres(
+ *,
+ admin_database_url: str,
+ app_database_url: str,
+ label: str,
+ migrate_to_head: bool,
+ seed_migration_0093_fixture: bool,
+) -> dict[str, object]:
+ import psycopg
+
+ if migrate_to_head:
+ from alembic import command
+ from alicebot_api.migrations import make_alembic_config
+
+ command.upgrade(make_alembic_config(admin_database_url), "head")
+
+ with psycopg.connect(admin_database_url) as conn:
+ conn.execute(
+ """
+ INSERT INTO users (id, email, display_name)
+ VALUES (%s, %s, %s)
+ ON CONFLICT (id) DO NOTHING
+ """,
+ (USER_ID, "phase5-ops@example.invalid", "Phase 5 Ops"),
+ )
+
+ from alicebot_api.db import direct_user_connection
+ from alicebot_api.vnext_store import PostgresVNextStore
+
+ with direct_user_connection(app_database_url, USER_ID) as conn:
+ store = PostgresVNextStore(conn)
+ memory = store.create_memory(_memory_payload(label), actor_type="system")
+ _signed_vector(store, memory)
+ if seed_migration_0093_fixture:
+ artifact = store.create_artifact(
+ {
+ "id": ARTIFACT_ID,
+ "artifact_type": "weekly_synthesis",
+ "title": "Phase 5 migration survivor fixture",
+ "content_markdown": "Deterministic migration fixture.",
+ "status": "draft",
+ "domain": "project",
+ "sensitivity": "internal",
+ "generated_by": "system",
+ "metadata_json": {"evidence_fixture": "phase5_ops_v1"},
+ }
+ )
+ for rating_id, usefulness, created_at in (
+ (OLDER_RATING_ID, 2, "2020-01-01T00:00:00Z"),
+ (NEWER_RATING_ID, 5, "2021-01-01T00:00:00Z"),
+ ):
+ conn.execute(
+ """
+ INSERT INTO artifact_quality_ratings (
+ id,
+ user_id,
+ artifact_id,
+ reviewer_id,
+ usefulness,
+ accuracy,
+ verbosity,
+ created_at,
+ metadata_json
+ ) VALUES (
+ %s::uuid,
+ app.current_user_id(),
+ %s::uuid,
+ %s,
+ %s,
+ %s,
+ 'right_sized',
+ %s::timestamptz,
+ '{"evidence_fixture":"phase5_ops_v1"}'::jsonb
+ )
+ """,
+ (
+ rating_id,
+ str(artifact["id"]),
+ REVIEWER_ID,
+ usefulness,
+ usefulness,
+ created_at,
+ ),
+ )
+
+ with psycopg.connect(admin_database_url) as conn:
+ users_row = conn.execute("SELECT count(*) FROM users").fetchone()
+ memories_row = conn.execute("SELECT count(*) FROM memories").fetchone()
+ events_row = conn.execute("SELECT count(*) FROM event_log").fetchone()
+ if users_row is None or memories_row is None or events_row is None:
+ raise RuntimeError("PostgreSQL seed count query returned no row")
+ counts = {
+ "users": int(users_row[0]),
+ "memories": int(memories_row[0]),
+ "event_log": int(events_row[0]),
+ }
+ return {"backend": "postgres", "counts": counts, "seeded": True}
+
+
+def _build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(description="Seed deterministic Phase 5 operations evidence.")
+ parser.add_argument("--backend", choices=("sqlite", "postgres"), required=True)
+ parser.add_argument("--label", default="current")
+ parser.add_argument("--db")
+ parser.add_argument("--database-admin-url", default=os.getenv("DATABASE_ADMIN_URL"))
+ parser.add_argument("--database-url", default=os.getenv("DATABASE_URL"))
+ parser.add_argument("--migrate-to-head", action="store_true")
+ parser.add_argument("--seed-migration-0093-fixture", action="store_true")
+ return parser
+
+
+def main(argv: list[str] | None = None) -> int:
+ args = _build_parser().parse_args(argv)
+ if args.backend == "sqlite":
+ if not args.db:
+ raise SystemExit("--db is required for SQLite evidence")
+ result = _seed_sqlite(Path(args.db), label=args.label)
+ else:
+ if not args.database_admin_url or not args.database_url:
+ raise SystemExit("--database-admin-url and --database-url are required for PostgreSQL evidence")
+ result = _seed_postgres(
+ admin_database_url=args.database_admin_url,
+ app_database_url=args.database_url,
+ label=args.label,
+ migrate_to_head=args.migrate_to_head,
+ seed_migration_0093_fixture=args.seed_migration_0093_fixture,
+ )
+ print(json.dumps(result, sort_keys=True, separators=(",", ":")))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/migrate.sh b/scripts/migrate.sh
index eaf7febb..eef864a5 100755
--- a/scripts/migrate.sh
+++ b/scripts/migrate.sh
@@ -9,11 +9,6 @@ fail() {
exit 1
}
-PYTHON_BIN="${REPO_ROOT}/.venv/bin/python"
-if [ ! -x "${PYTHON_BIN}" ]; then
- fail "Missing ${PYTHON_BIN}. Run 'make setup' before migrating Alice."
-fi
-
if [ -f "${REPO_ROOT}/.env" ]; then
"${REPO_ROOT}/scripts/validate_env.sh" "${REPO_ROOT}/.env"
# Fill only variables not already set so explicitly exported values
@@ -39,6 +34,15 @@ if [ -f "${REPO_ROOT}/.env" ]; then
set +a
fi
+if [ -z "${DATABASE_ADMIN_URL:-}" ]; then
+ fail "DATABASE_ADMIN_URL is required for migrations; inject the admin DSN into this migration process only."
+fi
+
+PYTHON_BIN="${REPO_ROOT}/.venv/bin/python"
+if [ ! -x "${PYTHON_BIN}" ]; then
+ fail "Missing ${PYTHON_BIN}. Run 'make setup' before migrating Alice."
+fi
+
cd "${REPO_ROOT}"
"${PYTHON_BIN}" -m alembic -c "${REPO_ROOT}/apps/api/alembic.ini" upgrade "${1:-head}"
diff --git a/scripts/run_phase5_ops_evidence.py b/scripts/run_phase5_ops_evidence.py
new file mode 100755
index 00000000..0b8f10c8
--- /dev/null
+++ b/scripts/run_phase5_ops_evidence.py
@@ -0,0 +1,1224 @@
+#!/usr/bin/env python3
+"""Execute the Phase 5 backup, restore, upgrade, and monitoring drills.
+
+The report is deliberately content-free: it records checks, counts, hashes,
+and revisions, but never database URLs, credentials, filesystem paths, memory
+text, or subprocess output. Raw drill databases and dumps live only in a
+private temporary directory and are removed when the command exits.
+"""
+
+from __future__ import annotations
+
+import argparse
+from datetime import UTC, datetime, timedelta
+import errno
+import hashlib
+import json
+import os
+from pathlib import Path
+import re
+import shutil
+import sqlite3
+import stat
+import subprocess
+import sys
+import tarfile
+import tempfile
+from typing import Any, Mapping
+from urllib.parse import parse_qs, unquote, urlsplit, urlunsplit
+from uuid import UUID, uuid4
+
+
+ROOT_DIR = Path(__file__).resolve().parents[1]
+CURRENT_SOURCE_DIR = ROOT_DIR / "apps" / "api" / "src"
+SEED_SCRIPT = ROOT_DIR / "scripts" / "_phase5_ops_seed.py"
+ALEMBIC_INI = ROOT_DIR / "apps" / "api" / "alembic.ini"
+BASELINE_TAG = "v0.12.0"
+BASELINE_COMMIT = "692c28ae60072b1eac4a437676b3ecf68e8bc026"
+BASELINE_POSTGRES_HEAD = "20260716_0092"
+MIGRATION_0093 = "20260721_0093"
+MIGRATION_0094 = "20260721_0094"
+POSTGRES_MAJOR = 16
+USER_ID = UUID("00000000-0000-0000-0000-000000005001")
+MEMORY_ID = "00000000-0000-0000-0000-000000005101"
+ARTIFACT_ID = "00000000-0000-0000-0000-000000005201"
+NEWER_RATING_ID = "00000000-0000-0000-0000-000000005302"
+REVIEWER_ID = "phase5-ops-reviewer"
+SEED_QUERY = "cobalt recovery beacon"
+REPORT_VERSION = "phase5_ops_evidence.v1"
+_SAFE_CODE = re.compile(r"^[a-z0-9_.:-]+$")
+_CREDENTIAL_URL = re.compile(r"(?:postgres(?:ql)?|redis)://[^\s/@:]+:[^\s/@]+@", re.IGNORECASE)
+
+
+class EvidenceError(RuntimeError):
+ """A stable failure whose code is safe to include in the public report."""
+
+ def __init__(self, code: str, *additional_codes: str):
+ codes = tuple(dict.fromkeys((code, *additional_codes)))
+ if any(_SAFE_CODE.fullmatch(item) is None for item in codes):
+ raise ValueError("evidence failure codes must be stable identifiers")
+ super().__init__(",".join(codes))
+ self.code = codes[0]
+ self.codes = codes
+
+
+def _sha256_file(path: Path) -> str:
+ digest = hashlib.sha256()
+ with path.open("rb") as stream:
+ for chunk in iter(lambda: stream.read(1024 * 1024), b""):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+def _run(
+ command: list[str],
+ *,
+ code: str,
+ env: Mapping[str, str] | None = None,
+ cwd: Path = ROOT_DIR,
+ stdout_file: Path | None = None,
+ stdin_file: Path | None = None,
+ timeout: int = 300,
+) -> subprocess.CompletedProcess[str]:
+ input_stream = None
+ try:
+ input_stream = stdin_file.open("rb") if stdin_file is not None else None
+ if stdout_file is not None:
+ with stdout_file.open("wb") as output:
+ completed = subprocess.run(
+ command,
+ cwd=cwd,
+ env=dict(env) if env is not None else None,
+ stdin=input_stream if input_stream is not None else subprocess.DEVNULL,
+ stdout=output,
+ stderr=subprocess.PIPE,
+ check=False,
+ timeout=timeout,
+ )
+ if completed.returncode != 0:
+ raise EvidenceError(code)
+ return subprocess.CompletedProcess(command, completed.returncode, "", "")
+ completed_text = subprocess.run(
+ command,
+ cwd=cwd,
+ env=dict(env) if env is not None else None,
+ stdin=input_stream if input_stream is not None else subprocess.DEVNULL,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ check=False,
+ text=True,
+ timeout=timeout,
+ )
+ except (OSError, subprocess.TimeoutExpired) as exc:
+ raise EvidenceError(code) from exc
+ finally:
+ if input_stream is not None:
+ input_stream.close()
+ if completed_text.returncode != 0:
+ raise EvidenceError(code)
+ return completed_text
+
+
+def _require_tool(name: str) -> str:
+ resolved = shutil.which(name)
+ if resolved is None:
+ raise EvidenceError(f"missing_prerequisite:{name.replace('-', '_')}")
+ return resolved
+
+
+def _git_bytes(repo_root: Path, arguments: list[str], *, code: str) -> bytes:
+ try:
+ completed = subprocess.run(
+ [_require_tool("git"), *arguments],
+ cwd=repo_root,
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ check=False,
+ timeout=60,
+ )
+ except (OSError, subprocess.TimeoutExpired) as exc:
+ raise EvidenceError(code) from exc
+ if completed.returncode != 0:
+ raise EvidenceError(code)
+ return completed.stdout
+
+
+def _snapshot_paths(repo_root: Path) -> tuple[list[bytes], bytes]:
+ index = _git_bytes(
+ repo_root,
+ ["ls-files", "--stage", "-z"],
+ code="carrier_index_unavailable",
+ )
+ if any(entry.startswith(b"160000 ") for entry in index.split(b"\0") if entry):
+ raise EvidenceError("carrier_snapshot_gitlink_unsupported")
+ current = _git_bytes(
+ repo_root,
+ ["ls-files", "-z", "--cached", "--others", "--exclude-standard", "--"],
+ code="carrier_paths_unavailable",
+ )
+ head = _git_bytes(
+ repo_root,
+ ["ls-tree", "-r", "--name-only", "-z", "HEAD"],
+ code="carrier_head_paths_unavailable",
+ )
+ paths = sorted({path for path in (*current.split(b"\0"), *head.split(b"\0")) if path})
+ return paths, index
+
+
+def _snapshot_entry(root_fd: int, relative_path: bytes) -> tuple[bytes, int, bytes]:
+ parts = relative_path.split(b"/")
+ if not parts or any(part in {b"", b".", b".."} for part in parts):
+ raise EvidenceError("carrier_path_invalid")
+ nofollow = getattr(os, "O_NOFOLLOW", None)
+ directory = getattr(os, "O_DIRECTORY", None)
+ if nofollow is None or directory is None:
+ raise EvidenceError("carrier_snapshot_nofollow_unavailable")
+ parent_fd = os.dup(root_fd)
+ try:
+ try:
+ for component in parts[:-1]:
+ child_fd = os.open(
+ component,
+ os.O_RDONLY | directory | nofollow,
+ dir_fd=parent_fd,
+ )
+ os.close(parent_fd)
+ parent_fd = child_fd
+ file_name = parts[-1]
+ info = os.stat(file_name, dir_fd=parent_fd, follow_symlinks=False)
+ except OSError as exc:
+ if exc.errno in {errno.ENOENT, errno.ENOTDIR, errno.ELOOP}:
+ return b"missing", 0, b""
+ raise EvidenceError("carrier_entry_stat_failed") from exc
+
+ mode = info.st_mode & 0o177777
+ if stat.S_ISLNK(info.st_mode):
+ try:
+ target = os.readlink(file_name, dir_fd=parent_fd)
+ except OSError as exc:
+ raise EvidenceError("carrier_symlink_read_failed") from exc
+ target_bytes = target if isinstance(target, bytes) else os.fsencode(target)
+ return b"symlink", mode, target_bytes
+ if stat.S_ISREG(info.st_mode):
+ try:
+ file_fd = os.open(file_name, os.O_RDONLY | nofollow, dir_fd=parent_fd)
+ except OSError as exc:
+ raise EvidenceError("carrier_file_open_failed") from exc
+ try:
+ before = os.fstat(file_fd)
+ if not stat.S_ISREG(before.st_mode):
+ raise EvidenceError("carrier_entry_changed_during_snapshot")
+ content = hashlib.sha256()
+ while True:
+ chunk = os.read(file_fd, 1024 * 1024)
+ if not chunk:
+ break
+ content.update(chunk)
+ after = os.fstat(file_fd)
+ finally:
+ os.close(file_fd)
+ stable_fields = ("st_dev", "st_ino", "st_mode", "st_size", "st_mtime_ns", "st_ctime_ns")
+ if any(getattr(before, field) != getattr(after, field) for field in stable_fields):
+ raise EvidenceError("carrier_entry_changed_during_snapshot")
+ return b"regular", mode, content.digest()
+ if stat.S_ISDIR(info.st_mode):
+ return b"directory", mode, b""
+ if stat.S_ISFIFO(info.st_mode):
+ return b"fifo", mode, b""
+ if stat.S_ISSOCK(info.st_mode):
+ return b"socket", mode, b""
+ if stat.S_ISCHR(info.st_mode):
+ return b"character_device", mode, b""
+ if stat.S_ISBLK(info.st_mode):
+ return b"block_device", mode, b""
+ return b"unknown", mode, b""
+ finally:
+ os.close(parent_fd)
+
+
+def _digest_field(digest: Any, value: bytes) -> None:
+ digest.update(len(value).to_bytes(8, "big"))
+ digest.update(value)
+
+
+def _carrier_snapshot_once(repo_root: Path) -> str:
+ paths, index = _snapshot_paths(repo_root)
+ digest = hashlib.sha256(b"alice-carrier-snapshot-v1\0")
+ _digest_field(digest, index)
+ root_fd = os.open(repo_root, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
+ try:
+ for relative_path in paths:
+ entry_type, mode, payload = _snapshot_entry(root_fd, relative_path)
+ _digest_field(digest, relative_path)
+ _digest_field(digest, entry_type)
+ _digest_field(digest, f"{mode:o}".encode("ascii"))
+ _digest_field(digest, payload)
+ finally:
+ os.close(root_fd)
+ return digest.hexdigest()
+
+
+def repository_carrier_identity(repo_root: Path = ROOT_DIR) -> dict[str, str]:
+ repo_root = repo_root.resolve(strict=True)
+ source_head_commit = _run(
+ [_require_tool("git"), "rev-parse", "HEAD"],
+ code="source_head_commit_unavailable",
+ cwd=repo_root,
+ ).stdout.strip()
+ source_head_tree = _run(
+ [_require_tool("git"), "rev-parse", "HEAD^{tree}"],
+ code="source_head_tree_unavailable",
+ cwd=repo_root,
+ ).stdout.strip()
+ before_status = _git_bytes(
+ repo_root,
+ ["status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignored=no"],
+ code="carrier_status_unavailable",
+ )
+ first_snapshot = _carrier_snapshot_once(repo_root)
+ second_snapshot = _carrier_snapshot_once(repo_root)
+ after_status = _git_bytes(
+ repo_root,
+ ["status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignored=no"],
+ code="carrier_status_unavailable",
+ )
+ after_head = _run(
+ [_require_tool("git"), "rev-parse", "HEAD"],
+ code="source_head_commit_unavailable",
+ cwd=repo_root,
+ ).stdout.strip()
+ after_tree = _run(
+ [_require_tool("git"), "rev-parse", "HEAD^{tree}"],
+ code="source_head_tree_unavailable",
+ cwd=repo_root,
+ ).stdout.strip()
+ if (
+ first_snapshot != second_snapshot
+ or before_status != after_status
+ or source_head_commit != after_head
+ or source_head_tree != after_tree
+ ):
+ raise EvidenceError("carrier_changed_during_snapshot")
+ return {
+ "source_head_commit": source_head_commit,
+ "source_head_tree": source_head_tree,
+ "carrier_state": "dirty" if before_status else "clean",
+ "carrier_snapshot_sha256": first_snapshot,
+ }
+
+
+def _source_env(source_dir: Path, extra: Mapping[str, str] | None = None) -> dict[str, str]:
+ env = os.environ.copy()
+ env["PYTHONPATH"] = str(source_dir)
+ env.pop("ALICE_EMBEDDINGS_API_KEY", None)
+ if extra:
+ env.update(extra)
+ return env
+
+
+def _seed_sqlite(db_path: Path, *, source_dir: Path, label: str) -> None:
+ env = _source_env(source_dir)
+ env.pop("DATABASE_URL", None)
+ env.pop("DATABASE_ADMIN_URL", None)
+ _run(
+ [
+ sys.executable,
+ str(SEED_SCRIPT),
+ "--backend",
+ "sqlite",
+ "--db",
+ str(db_path),
+ "--label",
+ label,
+ ],
+ code="sqlite_seed_failed",
+ env=env,
+ )
+
+
+def _parse_portable_footer(path: Path) -> dict[str, object]:
+ footer: dict[str, object] | None = None
+ with path.open("r", encoding="utf-8") as stream:
+ for line in stream:
+ try:
+ record = json.loads(line)
+ except json.JSONDecodeError as exc:
+ raise EvidenceError("portable_json_invalid") from exc
+ if isinstance(record, dict) and record.get("record_type") == "export_footer":
+ data = record.get("record")
+ if isinstance(data, dict):
+ footer = data
+ if footer is None:
+ raise EvidenceError("portable_footer_missing")
+ digest = footer.get("sha256")
+ counts = footer.get("record_counts")
+ if not isinstance(digest, str) or re.fullmatch(r"[0-9a-f]{64}", digest) is None:
+ raise EvidenceError("portable_footer_digest_invalid")
+ if not isinstance(counts, dict):
+ raise EvidenceError("portable_footer_counts_invalid")
+ return footer
+
+
+def _sqlite_counts(conn: sqlite3.Connection) -> dict[str, int]:
+ return {
+ table: int(conn.execute(f"SELECT count(*) FROM {table}").fetchone()[0])
+ for table in ("users", "memories", "event_log")
+ }
+
+
+def _verify_sqlite_store(
+ db_path: Path,
+ *,
+ expect_embedding: bool,
+ expect_stamp: bool = True,
+) -> dict[str, object]:
+ from alicebot_api.sqlite_store import SQLiteVNextStore, sqlite_user_connection
+ from alicebot_api.vnext_embeddings import memory_embedding_signature_is_current
+
+ raw = sqlite3.connect(db_path)
+ try:
+ integrity = [str(row[0]) for row in raw.execute("PRAGMA integrity_check").fetchall()]
+ foreign_keys = raw.execute("PRAGMA foreign_key_check").fetchall()
+ if integrity != ["ok"] or foreign_keys:
+ raise EvidenceError("sqlite_integrity_failed")
+ embedding_present = bool(
+ raw.execute(
+ "SELECT embedding IS NOT NULL FROM memories WHERE id = ?",
+ (MEMORY_ID,),
+ ).fetchone()[0]
+ )
+ if embedding_present is not expect_embedding:
+ raise EvidenceError("sqlite_embedding_presence_mismatch")
+ if expect_stamp:
+ stamp_rows = raw.execute("SELECT id, token FROM embedding_stamp ORDER BY id").fetchall()
+ if len(stamp_rows) != 1 or stamp_rows[0][0] != 1 or not str(stamp_rows[0][1]).strip():
+ raise EvidenceError("sqlite_embedding_stamp_invalid")
+ counts = _sqlite_counts(raw)
+ finally:
+ raw.close()
+
+ with sqlite_user_connection(db_path, USER_ID) as conn:
+ store = SQLiteVNextStore(conn, USER_ID)
+ memory = store.get_memory(MEMORY_ID)
+ recalled = store.search_memories_fts(
+ query=SEED_QUERY,
+ sensitivity_allowed=["internal"],
+ limit=5,
+ )
+ if memory is None or not memory_embedding_signature_is_current(memory):
+ raise EvidenceError("sqlite_embedding_signature_invalid")
+ if MEMORY_ID not in {str(item.get("id")) for item in recalled}:
+ raise EvidenceError("sqlite_recall_failed")
+ return {
+ "counts": counts,
+ "embedding_present": embedding_present,
+ "embedding_signature_current": True,
+ "integrity": "ok",
+ "recall": "matched",
+ }
+
+
+def _checkpoint_sqlite(db_path: Path) -> None:
+ conn = sqlite3.connect(db_path, timeout=5.0)
+ try:
+ row = conn.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone()
+ if row is None or int(row[0]) != 0:
+ raise EvidenceError("sqlite_checkpoint_busy")
+ if [str(item[0]) for item in conn.execute("PRAGMA integrity_check").fetchall()] != ["ok"]:
+ raise EvidenceError("sqlite_integrity_failed")
+ finally:
+ conn.close()
+
+
+def _destroy_sqlite_family(db_path: Path) -> None:
+ for candidate in (
+ db_path,
+ Path(f"{db_path}-wal"),
+ Path(f"{db_path}-shm"),
+ Path(f"{db_path}-journal"),
+ ):
+ candidate.unlink(missing_ok=True)
+
+
+def _sqlite_physical_drill(work_dir: Path) -> tuple[dict[str, object], Path]:
+ source = work_dir / "sqlite-current.db"
+ backup = work_dir / "sqlite-physical.backup"
+ _seed_sqlite(source, source_dir=CURRENT_SOURCE_DIR, label="current")
+ before = _verify_sqlite_store(source, expect_embedding=True)
+ _checkpoint_sqlite(source)
+ shutil.copy2(source, backup)
+ os.chmod(backup, 0o600)
+ if stat.S_IMODE(backup.stat().st_mode) != 0o600:
+ raise EvidenceError("sqlite_backup_permissions_invalid")
+ backup_sha256 = _sha256_file(backup)
+ _destroy_sqlite_family(source)
+ if source.exists():
+ raise EvidenceError("sqlite_destroy_failed")
+ shutil.copy2(backup, source)
+ if stat.S_IMODE(source.stat().st_mode) != 0o600:
+ raise EvidenceError("sqlite_restore_permissions_invalid")
+ after = _verify_sqlite_store(source, expect_embedding=True)
+ if before["counts"] != after["counts"]:
+ raise EvidenceError("sqlite_physical_count_mismatch")
+ return (
+ {
+ "status": "passed",
+ "backup_sha256": backup_sha256,
+ "counts": after["counts"],
+ "checkpoint": "truncate_complete",
+ "destroy_restore": "proved",
+ "integrity": after["integrity"],
+ "recall": after["recall"],
+ "embedding_signature": "current",
+ },
+ source,
+ )
+
+
+def _sqlite_portable_drill(work_dir: Path, source: Path) -> dict[str, object]:
+ portable = work_dir / "portable.jsonl"
+ imported = work_dir / "sqlite-portable-import.db"
+ reexport = work_dir / "portable-reexport.jsonl"
+ env = _source_env(CURRENT_SOURCE_DIR)
+ env.pop("DATABASE_URL", None)
+ env.pop("DATABASE_ADMIN_URL", None)
+ base = [sys.executable, "-m", "alicebot_api.onramp"]
+ _run(
+ [*base, "export", "--db", str(source), "--user-id", str(USER_ID), "--out", str(portable)],
+ code="portable_export_failed",
+ env=env,
+ )
+ first = _parse_portable_footer(portable)
+ _run(
+ [*base, "import", "--db", str(imported), "--user-id", str(USER_ID), "--in", str(portable)],
+ code="portable_import_failed",
+ env=env,
+ )
+ restored = _verify_sqlite_store(imported, expect_embedding=False)
+ _run(
+ [*base, "export", "--db", str(imported), "--user-id", str(USER_ID), "--out", str(reexport)],
+ code="portable_reexport_failed",
+ env=env,
+ )
+ second = _parse_portable_footer(reexport)
+ if first.get("sha256") != second.get("sha256"):
+ raise EvidenceError("portable_digest_mismatch")
+ if first.get("record_counts") != second.get("record_counts"):
+ raise EvidenceError("portable_count_mismatch")
+ return {
+ "status": "passed",
+ "content_sha256": first["sha256"],
+ "record_counts": first["record_counts"],
+ "fidelity": "canonical_digest_and_counts_match",
+ "fts_recall": restored["recall"],
+ "embeddings": "omitted_by_contract",
+ "reindex_command": "alice-memory reindex-embeddings",
+ }
+
+
+def _extract_baseline(work_dir: Path) -> Path:
+ git = _require_tool("git")
+ resolved = _run(
+ [git, "rev-parse", f"{BASELINE_TAG}^{{commit}}"],
+ code="baseline_tag_unavailable",
+ ).stdout.strip()
+ if resolved != BASELINE_COMMIT:
+ raise EvidenceError("baseline_tag_revision_mismatch")
+ archive = work_dir / "v0.12.0.tar"
+ _run(
+ [git, "archive", "--format=tar", BASELINE_TAG],
+ code="baseline_archive_failed",
+ stdout_file=archive,
+ )
+ extracted = work_dir / "v0.12.0"
+ extracted.mkdir(mode=0o700)
+ try:
+ with tarfile.open(archive, mode="r:") as bundle:
+ bundle.extractall(extracted, filter="data")
+ except (OSError, tarfile.TarError) as exc:
+ raise EvidenceError("baseline_archive_extract_failed") from exc
+ if not (extracted / "apps" / "api" / "src" / "alicebot_api" / "onramp.py").is_file():
+ raise EvidenceError("baseline_archive_incomplete")
+ return extracted
+
+
+def _sqlite_upgrade_drill(work_dir: Path, baseline: Path) -> dict[str, object]:
+ from alicebot_api.onramp import bootstrap_database
+
+ db_path = work_dir / "sqlite-v0.12-upgrade.db"
+ old_source = baseline / "apps" / "api" / "src"
+ _seed_sqlite(db_path, source_dir=old_source, label="v0.12.0")
+ raw = sqlite3.connect(db_path)
+ try:
+ old_tables = {
+ str(row[0])
+ for row in raw.execute("SELECT name FROM sqlite_master WHERE type = 'table'").fetchall()
+ }
+ if "embedding_stamp" in old_tables:
+ raise EvidenceError("baseline_sqlite_stamp_unexpected")
+ if raw.execute("SELECT count(*) FROM memories WHERE id = ?", (MEMORY_ID,)).fetchone()[0] != 1:
+ raise EvidenceError("baseline_sqlite_seed_missing")
+ finally:
+ raw.close()
+
+ bootstrap_database(db_path, user_id=USER_ID, user_email="phase5-ops@example.invalid")
+ first = _verify_sqlite_store(db_path, expect_embedding=True)
+ conn = sqlite3.connect(db_path)
+ try:
+ first_token = str(conn.execute("SELECT token FROM embedding_stamp WHERE id = 1").fetchone()[0])
+ finally:
+ conn.close()
+ bootstrap_database(db_path, user_id=USER_ID, user_email="phase5-ops@example.invalid")
+ conn = sqlite3.connect(db_path)
+ try:
+ second_token = str(conn.execute("SELECT token FROM embedding_stamp WHERE id = 1").fetchone()[0])
+ finally:
+ conn.close()
+ if first_token != second_token:
+ raise EvidenceError("sqlite_embedding_stamp_not_idempotent")
+ return {
+ "status": "passed",
+ "baseline_tag": BASELINE_TAG,
+ "baseline_commit": BASELINE_COMMIT,
+ "source_method": "git_archive_no_checkout",
+ "data_preserved": first["counts"],
+ "recall": first["recall"],
+ "embedding_signature": "current",
+ "embedding_stamp": "one_nonempty_stable_row",
+ }
+
+
+def _database_url_for(root_url: str, database_name: str) -> str:
+ parsed = urlsplit(root_url)
+ if parsed.scheme not in {"postgres", "postgresql"} or not parsed.hostname:
+ raise EvidenceError("postgres_url_invalid")
+ return urlunsplit((parsed.scheme, parsed.netloc, f"/{database_name}", parsed.query, ""))
+
+
+def _app_role_from_url(app_url: str) -> str:
+ role = unquote(urlsplit(app_url).username or "")
+ if role != "alicebot_app":
+ raise EvidenceError("postgres_app_role_must_be_alicebot_app")
+ return role
+
+
+def _libpq_env(database_url: str) -> dict[str, str]:
+ parsed = urlsplit(database_url)
+ if parsed.scheme not in {"postgres", "postgresql"} or not parsed.hostname:
+ raise EvidenceError("postgres_url_invalid")
+ env = os.environ.copy()
+ env.update(
+ {
+ "PGHOST": parsed.hostname,
+ "PGPORT": str(parsed.port or 5432),
+ "PGUSER": unquote(parsed.username or ""),
+ "PGDATABASE": unquote(parsed.path.removeprefix("/")),
+ }
+ )
+ password = unquote(parsed.password or "")
+ if password:
+ env["PGPASSWORD"] = password
+ try:
+ query = parse_qs(parsed.query, keep_blank_values=True, strict_parsing=True)
+ except ValueError as exc:
+ raise EvidenceError("postgres_url_query_invalid") from exc
+ for query_name, environment_name in (
+ ("sslmode", "PGSSLMODE"),
+ ("sslrootcert", "PGSSLROOTCERT"),
+ ):
+ values = query.get(query_name)
+ if values is None:
+ continue
+ if (
+ len(values) != 1
+ or not values[0]
+ or values[0] != values[0].strip()
+ or any(ord(character) < 32 or ord(character) == 127 for character in values[0])
+ ):
+ raise EvidenceError(f"postgres_{query_name}_invalid")
+ env[environment_name] = values[0]
+ return env
+
+
+def _postgres_client_major(executable: str, program: str) -> int:
+ completed = _run(
+ [executable, "--version"],
+ code=f"postgres_{program}_version_unavailable",
+ )
+ match = re.fullmatch(
+ rf"{re.escape(program)} \(PostgreSQL\) ([1-9][0-9]*)(?:\.[0-9]+)*(?:\s+.*)?",
+ completed.stdout.strip(),
+ )
+ if match is None:
+ raise EvidenceError(f"postgres_{program}_version_invalid")
+ return int(match.group(1))
+
+
+def _postgres_server_major(root_url: str) -> int:
+ import psycopg
+
+ try:
+ with psycopg.connect(root_url) as conn:
+ row = conn.execute("SHOW server_version_num").fetchone()
+ except Exception as exc:
+ raise EvidenceError("postgres_server_version_unavailable") from exc
+ raw_version: object | None
+ if isinstance(row, Mapping):
+ raw_version = row.get("server_version_num")
+ elif isinstance(row, (tuple, list)) and row:
+ raw_version = row[0]
+ else:
+ raw_version = None
+ value = str(raw_version) if isinstance(raw_version, (str, int)) else ""
+ if re.fullmatch(r"[0-9]{5,6}", value) is None:
+ raise EvidenceError("postgres_server_version_invalid")
+ return int(value) // 10000
+
+
+def _validate_postgres_toolchain(
+ *,
+ root_admin_url: str,
+ pg_dump: str,
+ pg_restore: str,
+) -> None:
+ client_majors = (
+ _postgres_client_major(pg_dump, "pg_dump"),
+ _postgres_client_major(pg_restore, "pg_restore"),
+ )
+ if client_majors != (POSTGRES_MAJOR, POSTGRES_MAJOR):
+ raise EvidenceError("postgres_client_major_mismatch")
+ if _postgres_server_major(root_admin_url) != POSTGRES_MAJOR:
+ raise EvidenceError("postgres_server_major_mismatch")
+
+
+def _create_database(root_url: str, database_name: str, *, app_role: str) -> None:
+ import psycopg
+ from psycopg import sql
+
+ with psycopg.connect(root_url, autocommit=True) as conn:
+ conn.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(database_name)))
+ conn.execute(
+ sql.SQL("GRANT CONNECT, TEMPORARY ON DATABASE {} TO {}").format(
+ sql.Identifier(database_name),
+ sql.Identifier(app_role),
+ )
+ )
+
+
+def _drop_database(root_url: str, database_name: str) -> None:
+ import psycopg
+ from psycopg import sql
+
+ with psycopg.connect(root_url, autocommit=True) as conn:
+ conn.execute(
+ sql.SQL("DROP DATABASE IF EXISTS {} WITH (FORCE)").format(sql.Identifier(database_name))
+ )
+
+
+def _dynamic_alembic_head() -> str:
+ from alembic.script import ScriptDirectory
+ from alicebot_api.migrations import make_alembic_config
+
+ scripts = ScriptDirectory.from_config(make_alembic_config())
+ heads = scripts.get_heads()
+ if len(heads) != 1:
+ raise EvidenceError("alembic_multiple_heads")
+ try:
+ migration_0093 = scripts.get_revision(MIGRATION_0093)
+ migration_0094 = scripts.get_revision(MIGRATION_0094)
+ except Exception as exc:
+ raise EvidenceError("required_migration_missing") from exc
+ if migration_0093 is None or migration_0094 is None:
+ raise EvidenceError("required_migration_missing")
+ return heads[0]
+
+
+def _migrate_postgres(database_url: str, revision: str = "head") -> None:
+ from alembic import command
+ from alicebot_api.migrations import make_alembic_config
+
+ command.upgrade(make_alembic_config(database_url), revision)
+
+
+def _postgres_counts(conn: Any) -> dict[str, int]:
+ counts: dict[str, int] = {}
+ for table in (
+ "users",
+ "memories",
+ "event_log",
+ "generated_artifacts",
+ "artifact_quality_ratings",
+ ):
+ row = conn.execute(f"SELECT count(*) AS count FROM {table}").fetchone()
+ if row is None or "count" not in row:
+ raise EvidenceError("postgres_count_row_invalid")
+ counts[table] = int(row["count"])
+ return counts
+
+
+def _verify_postgres_store(admin_url: str, app_url: str, *, expected_head: str) -> dict[str, object]:
+ import psycopg
+ from psycopg.rows import dict_row
+
+ from alicebot_api.db import direct_user_connection
+ from alicebot_api.vnext_embeddings import memory_embedding_signature_is_current
+ from alicebot_api.vnext_store import PostgresVNextStore
+
+ with psycopg.connect(admin_url, row_factory=dict_row) as conn:
+ revision_row = conn.execute("SELECT version_num FROM alembic_version").fetchone()
+ if revision_row is None:
+ raise EvidenceError("postgres_alembic_version_missing")
+ revision = str(revision_row["version_num"])
+ if revision != expected_head:
+ raise EvidenceError("postgres_alembic_head_mismatch")
+ capabilities_row = conn.execute(
+ "SELECT to_regclass('public.browser_clip_capabilities')"
+ ).fetchone()
+ if capabilities_row is None or capabilities_row["to_regclass"] is None:
+ raise EvidenceError("postgres_migration_0094_table_missing")
+ vector_row = conn.execute(
+ "SELECT embedding_vector IS NOT NULL AS present FROM memories WHERE id = %s::uuid",
+ (MEMORY_ID,),
+ ).fetchone()
+ if vector_row is None:
+ raise EvidenceError("postgres_seed_memory_missing")
+ vector_present = bool(
+ vector_row["present"]
+ )
+ counts = _postgres_counts(conn)
+ if not vector_present:
+ raise EvidenceError("postgres_embedding_missing")
+ with direct_user_connection(app_url, USER_ID) as conn:
+ store = PostgresVNextStore(conn)
+ memory = store.get_memory(MEMORY_ID)
+ recalled = store.search_memories_fts(
+ query=SEED_QUERY,
+ sensitivity_allowed=["internal"],
+ limit=5,
+ )
+ if memory is None or not memory_embedding_signature_is_current(memory):
+ raise EvidenceError("postgres_embedding_signature_invalid")
+ if MEMORY_ID not in {str(item.get("id")) for item in recalled}:
+ raise EvidenceError("postgres_recall_failed")
+ return {
+ "counts": counts,
+ "alembic_head": revision,
+ "recall": "matched",
+ "embedding_signature": "current",
+ }
+
+
+def _verify_migration_0093(admin_url: str) -> None:
+ import psycopg
+ from psycopg import errors
+
+ with psycopg.connect(admin_url) as conn:
+ rows = conn.execute(
+ """
+ SELECT id::text, usefulness
+ FROM artifact_quality_ratings
+ WHERE artifact_id = %s::uuid AND reviewer_id = %s
+ """,
+ (ARTIFACT_ID, REVIEWER_ID),
+ ).fetchall()
+ if rows != [(NEWER_RATING_ID, 5)]:
+ raise EvidenceError("migration_0093_survivor_mismatch")
+ try:
+ conn.execute(
+ """
+ INSERT INTO artifact_quality_ratings (
+ user_id, artifact_id, reviewer_id, usefulness, verbosity
+ ) VALUES (%s, %s, %s, 1, 'right_sized')
+ """,
+ (USER_ID, ARTIFACT_ID, REVIEWER_ID),
+ )
+ except errors.UniqueViolation:
+ conn.rollback()
+ else:
+ raise EvidenceError("migration_0093_unique_not_enforced")
+
+
+def _seed_postgres_baseline(
+ *,
+ baseline: Path,
+ admin_url: str,
+ app_url: str,
+) -> None:
+ env = _source_env(
+ baseline / "apps" / "api" / "src",
+ {"DATABASE_ADMIN_URL": admin_url, "DATABASE_URL": app_url},
+ )
+ _run(
+ [
+ sys.executable,
+ str(SEED_SCRIPT),
+ "--backend",
+ "postgres",
+ "--label",
+ "v0.12.0",
+ "--migrate-to-head",
+ "--seed-migration-0093-fixture",
+ ],
+ code="postgres_baseline_seed_failed",
+ env=env,
+ timeout=600,
+ )
+
+
+def _postgres_drill(
+ work_dir: Path,
+ *,
+ baseline: Path,
+ root_admin_url: str,
+ root_app_url: str,
+) -> dict[str, object]:
+ import psycopg
+
+ pg_dump = _require_tool("pg_dump")
+ pg_restore = _require_tool("pg_restore")
+ app_role = _app_role_from_url(root_app_url)
+ _validate_postgres_toolchain(
+ root_admin_url=root_admin_url,
+ pg_dump=pg_dump,
+ pg_restore=pg_restore,
+ )
+ suffix = uuid4().hex[:10]
+ database_name = f"alice_phase5_ops_{suffix}"
+ admin_url = _database_url_for(root_admin_url, database_name)
+ app_url = _database_url_for(root_app_url, database_name)
+ dump_path = work_dir / "postgres.dump"
+ result: dict[str, object] | None = None
+ primary_error: Exception | None = None
+ try:
+ _create_database(root_admin_url, database_name, app_role=app_role)
+ _seed_postgres_baseline(baseline=baseline, admin_url=admin_url, app_url=app_url)
+ with psycopg.connect(admin_url) as conn:
+ old_head_row = conn.execute("SELECT version_num FROM alembic_version").fetchone()
+ if old_head_row is None:
+ raise EvidenceError("postgres_baseline_version_missing")
+ old_head = str(old_head_row[0])
+ if old_head != BASELINE_POSTGRES_HEAD:
+ raise EvidenceError("postgres_baseline_head_mismatch")
+
+ current_head = _dynamic_alembic_head()
+ _migrate_postgres(admin_url)
+ _verify_migration_0093(admin_url)
+ before = _verify_postgres_store(admin_url, app_url, expected_head=current_head)
+
+ _run(
+ [pg_dump, "--format=custom"],
+ code="postgres_dump_failed",
+ env=_libpq_env(admin_url),
+ stdout_file=dump_path,
+ timeout=600,
+ )
+ _run(
+ [pg_restore, "--list"],
+ code="postgres_dump_archive_invalid",
+ env=_libpq_env(admin_url),
+ stdin_file=dump_path,
+ )
+ dump_sha256 = _sha256_file(dump_path)
+
+ _drop_database(root_admin_url, database_name)
+ _create_database(root_admin_url, database_name, app_role=app_role)
+ _run(
+ [pg_restore, "--exit-on-error", "--no-owner", f"--dbname={database_name}"],
+ code="postgres_restore_failed",
+ env=_libpq_env(admin_url),
+ stdin_file=dump_path,
+ timeout=600,
+ )
+ after = _verify_postgres_store(admin_url, app_url, expected_head=current_head)
+ _verify_migration_0093(admin_url)
+ if before["counts"] != after["counts"]:
+ raise EvidenceError("postgres_restore_count_mismatch")
+ result = {
+ "status": "passed",
+ "backup_sha256": dump_sha256,
+ "counts": after["counts"],
+ "destroy_restore": "proved_on_disposable_database",
+ "recall": after["recall"],
+ "embedding_signature": after["embedding_signature"],
+ "baseline_head": old_head,
+ "current_head": current_head,
+ "migration_0093": "newest_survived_and_unique_enforced",
+ "migration_0094": "table_present",
+ }
+ except Exception as exc:
+ primary_error = exc
+
+ cleanup_error: Exception | None = None
+ try:
+ # The name is random and DROP is idempotent, so always clean up. This
+ # also covers CREATE succeeding before its subsequent GRANT fails.
+ _drop_database(root_admin_url, database_name)
+ except Exception as exc:
+ cleanup_error = exc
+
+ if cleanup_error is not None:
+ if primary_error is None:
+ raise EvidenceError("postgres_cleanup_failed") from cleanup_error
+ primary_codes = (
+ primary_error.codes
+ if isinstance(primary_error, EvidenceError)
+ else (f"unexpected:{type(primary_error).__name__.lower()}",)
+ )
+ raise EvidenceError(*(primary_codes + ("postgres_cleanup_failed",))) from cleanup_error
+ if primary_error is not None:
+ raise primary_error
+ if result is None: # pragma: no cover - defensive exhaustiveness
+ raise EvidenceError("postgres_result_missing")
+ return result
+
+
+def _parse_timestamp(value: object) -> datetime | None:
+ if not isinstance(value, str) or not value.strip():
+ return None
+ try:
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
+ except ValueError:
+ return None
+ if parsed.tzinfo is None:
+ return None
+ return parsed.astimezone(UTC)
+
+
+def classify_scheduler_snapshot(snapshot: Mapping[str, object], *, now: datetime) -> dict[str, object]:
+ """Classify the documented scheduler status fields without leaking values."""
+
+ if snapshot.get("configured") is not True:
+ return {"state": "disabled", "reason_codes": []}
+ stuck: list[str] = []
+ degraded: list[str] = []
+ reported_running = snapshot.get("reported_running") is True
+ running = snapshot.get("running") is True
+ if reported_running and snapshot.get("ownership_verified") is not True:
+ stuck.append("ownership_unverified")
+ interval = snapshot.get("interval_seconds")
+ interval_seconds = float(interval) if isinstance(interval, (int, float)) and interval > 0 else 60.0
+ heartbeat = _parse_timestamp(snapshot.get("last_heartbeat_at"))
+ if running and (heartbeat is None or now - heartbeat > timedelta(seconds=max(60.0, interval_seconds * 3))):
+ stuck.append("heartbeat_stale")
+ if isinstance(snapshot.get("last_error_code"), str) and str(snapshot["last_error_code"]).strip():
+ degraded.append("last_error")
+ expired = snapshot.get("expired_claim_count", 0)
+ if isinstance(expired, int) and not isinstance(expired, bool) and expired > 0:
+ degraded.append("expired_claims")
+ reasons = sorted(set((*stuck, *degraded)))
+ state = "stuck" if stuck else "degraded" if degraded else "healthy" if running else "stopped"
+ return {"state": state, "reason_codes": reasons}
+
+
+def _monitoring_drill() -> dict[str, object]:
+ from alicebot_api.main import build_healthcheck_payload
+
+ class SettingsStub:
+ app_env = "phase5-evidence"
+ redis_url = "redis://operator:do-not-report@127.0.0.1:6379/0"
+
+ healthy = build_healthcheck_payload(SettingsStub(), True) # type: ignore[arg-type]
+ degraded = build_healthcheck_payload(SettingsStub(), False) # type: ignore[arg-type]
+ if healthy["status"] != "ok" or degraded["status"] != "degraded":
+ raise EvidenceError("health_contract_invalid")
+ if healthy["services"]["redis"]["status"] != "not_checked":
+ raise EvidenceError("health_redis_contract_invalid")
+ if healthy["services"]["object_storage"]["status"] != "not_checked":
+ raise EvidenceError("health_object_storage_contract_invalid")
+ now = datetime.now(UTC)
+ stuck = classify_scheduler_snapshot(
+ {
+ "configured": True,
+ "reported_running": True,
+ "running": True,
+ "ownership_verified": False,
+ "last_heartbeat_at": (now - timedelta(minutes=10)).isoformat(),
+ "interval_seconds": 30,
+ "last_error_code": "claim_failed",
+ "expired_claim_count": 1,
+ },
+ now=now,
+ )
+ expected_reasons = [
+ "expired_claims",
+ "heartbeat_stale",
+ "last_error",
+ "ownership_unverified",
+ ]
+ if stuck["state"] != "stuck" or stuck["reason_codes"] != expected_reasons:
+ raise EvidenceError("scheduler_stuck_contract_invalid")
+ return {
+ "status": "passed",
+ "healthz": {
+ "database": "checked",
+ "redis": "not_checked",
+ "object_storage": "not_checked",
+ "failure_status": "degraded",
+ },
+ "scheduler": {
+ "stuck_detection": "proved",
+ "fields": [
+ "running",
+ "ownership_verified",
+ "last_heartbeat_at",
+ "interval_seconds",
+ "last_error_code",
+ "expired_claim_count",
+ ],
+ },
+ }
+
+
+def _assert_report_safe(value: object) -> None:
+ forbidden_literals = (
+ SEED_QUERY,
+ "phase5-ops@example.invalid",
+ "do-not-report",
+ str(ROOT_DIR),
+ )
+
+ def visit(item: object) -> None:
+ if isinstance(item, Mapping):
+ for key, child in item.items():
+ lowered = str(key).lower()
+ if any(name in lowered for name in ("password", "secret", "database_url", "admin_url")):
+ raise EvidenceError("report_sensitive_key")
+ visit(child)
+ elif isinstance(item, (list, tuple)):
+ for child in item:
+ visit(child)
+ elif isinstance(item, str):
+ if _CREDENTIAL_URL.search(item) or any(literal in item for literal in forbidden_literals):
+ raise EvidenceError("report_sensitive_value")
+
+ visit(value)
+
+
+def _write_report(path: Path, report: dict[str, object]) -> None:
+ _assert_report_safe(report)
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
+ fd, raw_temp = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
+ temp = Path(raw_temp)
+ try:
+ with os.fdopen(fd, "w", encoding="utf-8") as stream:
+ os.fchmod(stream.fileno(), 0o600)
+ json.dump(report, stream, sort_keys=True, indent=2)
+ stream.write("\n")
+ stream.flush()
+ os.fsync(stream.fileno())
+ os.replace(temp, path)
+ finally:
+ temp.unlink(missing_ok=True)
+
+
+def _build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ description="Execute Phase 5 operations evidence with sanitized JSON output."
+ )
+ parser.add_argument("--backend", choices=("sqlite", "postgres", "all"), default="all")
+ parser.add_argument(
+ "--work-dir",
+ default=None,
+ help="Parent for private ephemeral drill files; raw stores are removed on exit.",
+ )
+ parser.add_argument("--database-admin-url", default=os.getenv("DATABASE_ADMIN_URL"))
+ parser.add_argument("--database-url", default=os.getenv("DATABASE_URL"))
+ parser.add_argument("--output", default=None, help="Optional durable sanitized JSON report path.")
+ return parser
+
+
+def _scope_proof_gaps(backend: str) -> list[str]:
+ if backend == "sqlite":
+ return ["postgres_not_requested"]
+ if backend == "postgres":
+ return ["sqlite_not_requested"]
+ return []
+
+
+def run_evidence(args: argparse.Namespace) -> dict[str, object]:
+ requested = args.backend
+ if requested in {"postgres", "all"} and (
+ not args.database_admin_url or not args.database_url
+ ):
+ raise EvidenceError("missing_prerequisite:postgres_urls")
+ parent = Path(args.work_dir).expanduser() if args.work_dir else None
+ if parent is not None:
+ parent.mkdir(parents=True, exist_ok=True, mode=0o700)
+ checks: dict[str, object] = {}
+ baseline: Path | None = None
+ with tempfile.TemporaryDirectory(prefix="alice-phase5-ops-", dir=parent) as raw_dir:
+ work_dir = Path(raw_dir)
+ os.chmod(work_dir, 0o700)
+ if requested in {"sqlite", "all", "postgres"}:
+ baseline = _extract_baseline(work_dir)
+ if requested in {"sqlite", "all"}:
+ physical, restored_source = _sqlite_physical_drill(work_dir)
+ checks["sqlite_physical_backup_restore"] = physical
+ checks["portable_export_import"] = _sqlite_portable_drill(work_dir, restored_source)
+ assert baseline is not None
+ checks["sqlite_v0_12_upgrade"] = _sqlite_upgrade_drill(work_dir, baseline)
+ if requested in {"postgres", "all"}:
+ assert baseline is not None
+ checks["postgres_backup_restore_upgrade"] = _postgres_drill(
+ work_dir,
+ baseline=baseline,
+ root_admin_url=args.database_admin_url,
+ root_app_url=args.database_url,
+ )
+ checks["health_and_monitoring"] = _monitoring_drill()
+
+ carrier_identity = repository_carrier_identity()
+ report: dict[str, object] = {
+ "artifact_version": REPORT_VERSION,
+ "generated_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
+ "status": "passed",
+ "backend_scope": requested,
+ "repository": {
+ **carrier_identity,
+ "baseline_tag": BASELINE_TAG,
+ "baseline_commit": BASELINE_COMMIT,
+ },
+ "checks": checks,
+ "proof_gaps": _scope_proof_gaps(requested),
+ }
+ _assert_report_safe(report)
+ return report
+
+
+def main(argv: list[str] | None = None) -> int:
+ args = _build_parser().parse_args(argv)
+ try:
+ report = run_evidence(args)
+ exit_code = 0
+ except EvidenceError as exc:
+ report = {
+ "artifact_version": REPORT_VERSION,
+ "generated_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
+ "status": "failed",
+ "backend_scope": args.backend,
+ "checks": {},
+ "failure_codes": list(exc.codes),
+ "proof_gaps": _scope_proof_gaps(args.backend),
+ }
+ exit_code = 1
+ except Exception as exc: # pragma: no cover - fail-closed process boundary
+ report = {
+ "artifact_version": REPORT_VERSION,
+ "generated_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
+ "status": "failed",
+ "backend_scope": args.backend,
+ "checks": {},
+ "failure_codes": [f"unexpected:{type(exc).__name__.lower()}"],
+ "proof_gaps": _scope_proof_gaps(args.backend),
+ }
+ exit_code = 1
+ _assert_report_safe(report)
+ if args.output:
+ _write_report(Path(args.output).expanduser(), report)
+ print(json.dumps(report, sort_keys=True, separators=(",", ":")))
+ return exit_code
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/run_single_tenant_deployment_smoke.py b/scripts/run_single_tenant_deployment_smoke.py
new file mode 100644
index 00000000..4ff21086
--- /dev/null
+++ b/scripts/run_single_tenant_deployment_smoke.py
@@ -0,0 +1,766 @@
+#!/usr/bin/env python3
+"""Validate the documented single-tenant deployment configuration contract.
+
+This smoke intentionally does not claim that CI provisioned public DNS, a public
+certificate, or a cloud host. It validates the checked-in fail-closed examples
+and emits a path- and secret-free receipt that names the remaining owner-run
+deployment proof.
+"""
+
+from __future__ import annotations
+
+import argparse
+from collections.abc import Mapping
+import errno
+import hashlib
+import json
+import os
+from pathlib import Path
+import re
+import stat
+import subprocess
+import tempfile
+from typing import Any
+from urllib.parse import parse_qs, urlsplit
+from uuid import UUID
+
+
+ROOT = Path(__file__).resolve().parents[1]
+ENV_RELATIVE_PATH = Path("packaging/cloud/single-tenant.env.example")
+CADDY_RELATIVE_PATH = Path("packaging/cloud/Caddyfile.example")
+GUIDE_RELATIVE_PATH = Path("docs/deployment/single-tenant-self-hosted.md")
+WORKFLOW_RELATIVE_PATH = Path(".github/workflows/deployment-guide-smoke.yml")
+WEB_API_SOURCE_RELATIVE_PATH = Path("apps/web/lib/api.ts")
+VALIDATED_ASSETS = {
+ "caddyfile_example": CADDY_RELATIVE_PATH,
+ "deployment_guide": GUIDE_RELATIVE_PATH,
+ "environment_example": ENV_RELATIVE_PATH,
+ "web_api_source": WEB_API_SOURCE_RELATIVE_PATH,
+ "workflow": WORKFLOW_RELATIVE_PATH,
+}
+CONTRACT_INPUTS = dict(VALIDATED_ASSETS)
+REPORT_VERSION = "single_tenant_deployment_contract.v1"
+OWNER_RECEIPT_BLOCKER = "owner_real_host_deployment_receipt"
+_SAFE_FAILURE_CODE = re.compile(r"^[a-z0-9_.:-]+$")
+_FULL_SHA = re.compile(r"[0-9a-f]{40}")
+_IMAGE_DIGEST = re.compile(r"@sha256:[0-9a-f]{64}(?:\s|$)")
+_SECRET_MARKERS = (
+ "postgresql://",
+ "alice_sk_",
+ "BEGIN PRIVATE KEY",
+ "ALICEBOT_DB_APP_PASSWORD",
+ "ALICEBOT_DB_ADMIN_PASSWORD",
+)
+
+
+class DeploymentContractError(RuntimeError):
+ """A validation failure represented only by a stable, public-safe code."""
+
+ def __init__(self, code: str):
+ if _SAFE_FAILURE_CODE.fullmatch(code) is None:
+ raise ValueError("deployment failure codes must be stable identifiers")
+ super().__init__(code)
+ self.code = code
+
+
+def _require(condition: bool, code: str) -> None:
+ if not condition:
+ raise DeploymentContractError(code)
+
+
+def _git_bytes(repo_root: Path, arguments: list[str], *, code: str) -> bytes:
+ try:
+ completed = subprocess.run(
+ ["git", *arguments],
+ cwd=repo_root,
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.DEVNULL,
+ check=False,
+ timeout=60,
+ )
+ except (OSError, subprocess.TimeoutExpired) as exc:
+ raise DeploymentContractError(code) from exc
+ if completed.returncode != 0:
+ raise DeploymentContractError(code)
+ return completed.stdout
+
+
+def _snapshot_paths(repo_root: Path) -> tuple[list[bytes], bytes]:
+ index = _git_bytes(
+ repo_root,
+ ["ls-files", "--stage", "-z"],
+ code="carrier_index_unavailable",
+ )
+ if any(entry.startswith(b"160000 ") for entry in index.split(b"\0") if entry):
+ raise DeploymentContractError("carrier_snapshot_gitlink_unsupported")
+ current = _git_bytes(
+ repo_root,
+ ["ls-files", "-z", "--cached", "--others", "--exclude-standard", "--"],
+ code="carrier_paths_unavailable",
+ )
+ head = _git_bytes(
+ repo_root,
+ ["ls-tree", "-r", "--name-only", "-z", "HEAD"],
+ code="carrier_head_paths_unavailable",
+ )
+ return sorted({path for path in (*current.split(b"\0"), *head.split(b"\0")) if path}), index
+
+
+def _snapshot_entry(root_fd: int, relative_path: bytes) -> tuple[bytes, int, bytes]:
+ parts = relative_path.split(b"/")
+ if not parts or any(part in {b"", b".", b".."} for part in parts):
+ raise DeploymentContractError("carrier_path_invalid")
+ nofollow = getattr(os, "O_NOFOLLOW", None)
+ directory = getattr(os, "O_DIRECTORY", None)
+ if nofollow is None or directory is None:
+ raise DeploymentContractError("carrier_snapshot_nofollow_unavailable")
+ parent_fd = os.dup(root_fd)
+ try:
+ try:
+ for component in parts[:-1]:
+ child_fd = os.open(
+ component,
+ os.O_RDONLY | directory | nofollow,
+ dir_fd=parent_fd,
+ )
+ os.close(parent_fd)
+ parent_fd = child_fd
+ file_name = parts[-1]
+ info = os.stat(file_name, dir_fd=parent_fd, follow_symlinks=False)
+ except OSError as exc:
+ if exc.errno in {errno.ENOENT, errno.ENOTDIR, errno.ELOOP}:
+ return b"missing", 0, b""
+ raise DeploymentContractError("carrier_entry_stat_failed") from exc
+
+ mode = info.st_mode & 0o177777
+ if stat.S_ISLNK(info.st_mode):
+ try:
+ target = os.readlink(file_name, dir_fd=parent_fd)
+ except OSError as exc:
+ raise DeploymentContractError("carrier_symlink_read_failed") from exc
+ return b"symlink", mode, target if isinstance(target, bytes) else os.fsencode(target)
+ if not stat.S_ISREG(info.st_mode):
+ raise DeploymentContractError("carrier_entry_type_unsupported")
+ try:
+ file_fd = os.open(file_name, os.O_RDONLY | nofollow, dir_fd=parent_fd)
+ except OSError as exc:
+ raise DeploymentContractError("carrier_file_open_failed") from exc
+ try:
+ before = os.fstat(file_fd)
+ if not stat.S_ISREG(before.st_mode):
+ raise DeploymentContractError("carrier_entry_changed_during_snapshot")
+ content = hashlib.sha256()
+ while True:
+ chunk = os.read(file_fd, 1024 * 1024)
+ if not chunk:
+ break
+ content.update(chunk)
+ after = os.fstat(file_fd)
+ finally:
+ os.close(file_fd)
+ stable_fields = ("st_dev", "st_ino", "st_mode", "st_size", "st_mtime_ns", "st_ctime_ns")
+ if any(getattr(before, field) != getattr(after, field) for field in stable_fields):
+ raise DeploymentContractError("carrier_entry_changed_during_snapshot")
+ return b"regular", mode, content.digest()
+ finally:
+ os.close(parent_fd)
+
+
+def _digest_field(digest: Any, value: bytes) -> None:
+ digest.update(len(value).to_bytes(8, "big"))
+ digest.update(value)
+
+
+def _carrier_snapshot_once(repo_root: Path) -> str:
+ paths, index = _snapshot_paths(repo_root)
+ digest = hashlib.sha256(b"alice-carrier-snapshot-v1\0")
+ _digest_field(digest, index)
+ root_fd = os.open(repo_root, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
+ try:
+ for relative_path in paths:
+ entry_type, mode, payload = _snapshot_entry(root_fd, relative_path)
+ _digest_field(digest, relative_path)
+ _digest_field(digest, entry_type)
+ _digest_field(digest, f"{mode:o}".encode("ascii"))
+ _digest_field(digest, payload)
+ finally:
+ os.close(root_fd)
+ return digest.hexdigest()
+
+
+def _read_regular_asset(root_fd: int, relative_path: Path) -> bytes:
+ path_bytes = relative_path.as_posix().encode("utf-8")
+ parts = path_bytes.split(b"/")
+ nofollow = getattr(os, "O_NOFOLLOW", None)
+ directory = getattr(os, "O_DIRECTORY", None)
+ if nofollow is None or directory is None:
+ raise DeploymentContractError("carrier_snapshot_nofollow_unavailable")
+ parent_fd = os.dup(root_fd)
+ try:
+ try:
+ for component in parts[:-1]:
+ child_fd = os.open(
+ component,
+ os.O_RDONLY | directory | nofollow,
+ dir_fd=parent_fd,
+ )
+ os.close(parent_fd)
+ parent_fd = child_fd
+ file_fd = os.open(parts[-1], os.O_RDONLY | nofollow, dir_fd=parent_fd)
+ except OSError as exc:
+ raise DeploymentContractError("validated_asset_unreadable") from exc
+ try:
+ before = os.fstat(file_fd)
+ if not stat.S_ISREG(before.st_mode):
+ raise DeploymentContractError("validated_asset_not_regular")
+ chunks: list[bytes] = []
+ while True:
+ chunk = os.read(file_fd, 1024 * 1024)
+ if not chunk:
+ break
+ chunks.append(chunk)
+ after = os.fstat(file_fd)
+ finally:
+ os.close(file_fd)
+ stable_fields = ("st_dev", "st_ino", "st_mode", "st_size", "st_mtime_ns", "st_ctime_ns")
+ if any(getattr(before, field) != getattr(after, field) for field in stable_fields):
+ raise DeploymentContractError("carrier_entry_changed_during_snapshot")
+ return b"".join(chunks)
+ finally:
+ os.close(parent_fd)
+
+
+def _read_contract_inputs(repo_root: Path) -> dict[str, bytes]:
+ root_fd = os.open(repo_root, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
+ try:
+ return {
+ logical_name: _read_regular_asset(root_fd, relative_path)
+ for logical_name, relative_path in CONTRACT_INPUTS.items()
+ }
+ finally:
+ os.close(root_fd)
+
+
+def repository_carrier_identity(repo_root: Path) -> tuple[dict[str, object], dict[str, bytes]]:
+ repo_root = repo_root.resolve(strict=True)
+ before_head = _git_bytes(repo_root, ["rev-parse", "HEAD"], code="source_head_commit_unavailable").strip()
+ before_tree = _git_bytes(
+ repo_root,
+ ["rev-parse", "HEAD^{tree}"],
+ code="source_head_tree_unavailable",
+ ).strip()
+ before_status = _git_bytes(
+ repo_root,
+ ["status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignored=no"],
+ code="carrier_status_unavailable",
+ )
+ before_assets = _read_contract_inputs(repo_root)
+ first_snapshot = _carrier_snapshot_once(repo_root)
+ second_snapshot = _carrier_snapshot_once(repo_root)
+ after_assets = _read_contract_inputs(repo_root)
+ after_status = _git_bytes(
+ repo_root,
+ ["status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignored=no"],
+ code="carrier_status_unavailable",
+ )
+ after_head = _git_bytes(repo_root, ["rev-parse", "HEAD"], code="source_head_commit_unavailable").strip()
+ after_tree = _git_bytes(
+ repo_root,
+ ["rev-parse", "HEAD^{tree}"],
+ code="source_head_tree_unavailable",
+ ).strip()
+ if (
+ first_snapshot != second_snapshot
+ or before_assets != after_assets
+ or before_status != after_status
+ or before_head != after_head
+ or before_tree != after_tree
+ ):
+ raise DeploymentContractError("carrier_changed_during_snapshot")
+ try:
+ source_head_commit = before_head.decode("ascii")
+ source_head_tree = before_tree.decode("ascii")
+ except UnicodeDecodeError as exc:
+ raise DeploymentContractError("source_identity_invalid") from exc
+ _require(re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", source_head_commit) is not None, "source_identity_invalid")
+ _require(re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", source_head_tree) is not None, "source_identity_invalid")
+ provenance: dict[str, object] = {
+ "source_head_commit": source_head_commit,
+ "source_head_tree": source_head_tree,
+ "carrier_state": "dirty" if before_status else "clean",
+ "carrier_snapshot_sha256": first_snapshot,
+ "validated_asset_sha256": {
+ logical_name: hashlib.sha256(before_assets[logical_name]).hexdigest()
+ for logical_name in sorted(VALIDATED_ASSETS)
+ },
+ }
+ return provenance, before_assets
+
+
+def parse_env_example(text: str) -> dict[str, str]:
+ values: dict[str, str] = {}
+ for raw_line in text.splitlines():
+ line = raw_line.strip()
+ if line == "" or line.startswith("#"):
+ continue
+ _require("=" in line, "env_line_invalid")
+ key, value = line.split("=", 1)
+ key = key.strip()
+ _require(re.fullmatch(r"[A-Z][A-Z0-9_]*", key) is not None, "env_key_invalid")
+ _require(key not in values, "env_key_duplicate")
+ values[key] = value.strip().strip('"').strip("'")
+ return values
+
+
+def _validate_exact_https_origin(value: str, *, code: str) -> None:
+ try:
+ parsed = urlsplit(value)
+ except ValueError as exc:
+ raise DeploymentContractError(code) from exc
+ _require(
+ parsed.scheme == "https"
+ and parsed.hostname is not None
+ and parsed.username is None
+ and parsed.password is None
+ and parsed.path == ""
+ and parsed.query == ""
+ and parsed.fragment == "",
+ code,
+ )
+ _require("*" not in value and "," not in value, code)
+
+
+def _validate_database_url(
+ value: str,
+ *,
+ expected_user: str,
+ expected_placeholder: str,
+) -> tuple[str, tuple[str, int, str]]:
+ try:
+ parsed = urlsplit(value)
+ except ValueError as exc:
+ raise DeploymentContractError("database_url_invalid") from exc
+ _require(parsed.scheme in {"postgres", "postgresql"}, "database_scheme_invalid")
+ _require(parsed.username == expected_user, "database_role_invalid")
+ _require(parsed.password == expected_placeholder, "database_secret_placeholder_invalid")
+ _require(parsed.hostname not in {None, "", "localhost", "127.0.0.1", "::1"}, "database_host_invalid")
+ _require(parsed.path == "/alicebot", "database_name_invalid")
+ try:
+ port = parsed.port or 5432
+ except ValueError as exc:
+ raise DeploymentContractError("database_port_invalid") from exc
+ query = parse_qs(parsed.query, keep_blank_values=True)
+ _require(query.get("sslmode") == ["verify-full"], "database_tls_invalid")
+ _require(
+ query.get("sslrootcert") == ["/run/secrets/alicebot/postgres-ca.pem"],
+ "database_ca_path_invalid",
+ )
+ return expected_user, ((parsed.hostname or "").lower(), port, parsed.path)
+
+
+def validate_environment(values: Mapping[str, str]) -> None:
+ required = {
+ "APP_ENV",
+ "APP_HOST",
+ "APP_PORT",
+ "DATABASE_URL",
+ "ALICEBOT_AUTH_USER_ID",
+ "ALICE_LEGACY_SURFACES",
+ "LEGACY_V0_ENABLED_OUTSIDE_DEV",
+ "CORS_ALLOWED_ORIGINS",
+ "CORS_ALLOW_CREDENTIALS",
+ "NEXT_PUBLIC_ALICEBOT_API_BASE_URL",
+ "NEXT_PUBLIC_ALICEBOT_USER_ID",
+ "TRUST_PROXY_HEADERS",
+ "TRUSTED_PROXY_IPS",
+ "ALICE_WEB_HOST",
+ "ALICE_WEB_PORT",
+ }
+ _require(required <= set(values), "env_required_key_missing")
+ _require(values["APP_ENV"] == "production", "app_env_not_production")
+ _require(values["APP_HOST"] == "127.0.0.1", "api_bind_not_loopback")
+ _require(values["ALICE_WEB_HOST"] == "127.0.0.1", "web_bind_not_loopback")
+ _require(values["APP_PORT"] == "8000", "api_port_invalid")
+ _require(values["ALICE_WEB_PORT"] == "3000", "web_port_invalid")
+ _require(values["ALICE_LEGACY_SURFACES"] == "0", "legacy_surface_not_disabled")
+ _require(values["LEGACY_V0_ENABLED_OUTSIDE_DEV"].lower() == "false", "legacy_v0_production_gate_enabled")
+ _require(values["CORS_ALLOW_CREDENTIALS"].lower() == "false", "cors_credentials_invalid")
+
+ origin = values["CORS_ALLOWED_ORIGINS"]
+ _validate_exact_https_origin(origin, code="cors_origin_invalid")
+ _require(values["NEXT_PUBLIC_ALICEBOT_API_BASE_URL"] == origin, "web_api_origin_mismatch")
+ _require(values.get("PUBLIC_ORIGIN") == origin, "public_origin_mismatch")
+
+ try:
+ auth_user = UUID(values["ALICEBOT_AUTH_USER_ID"])
+ web_user = UUID(values["NEXT_PUBLIC_ALICEBOT_USER_ID"])
+ except ValueError as exc:
+ raise DeploymentContractError("auth_user_invalid") from exc
+ _require(auth_user.int != 0 and auth_user == web_user, "auth_user_mismatch")
+
+ _require(values["TRUST_PROXY_HEADERS"].lower() == "true", "proxy_headers_not_enabled")
+ _require(values["TRUSTED_PROXY_IPS"] == "127.0.0.1", "trusted_proxy_invalid")
+
+ _validate_database_url(
+ values["DATABASE_URL"],
+ expected_user="alicebot_app",
+ expected_placeholder="${ALICEBOT_DB_APP_PASSWORD}",
+ )
+
+ for forbidden in (
+ "DATABASE_ADMIN_URL",
+ "S3_ACCESS_KEY",
+ "S3_SECRET_KEY",
+ "WORKSPACE_PROVIDER_CONFIGS_JSON",
+ ):
+ _require(forbidden not in values, "deprecated_or_inline_secret_setting_present")
+
+
+def validate_role_separated_database_contract(
+ values: Mapping[str, str],
+ guide_text: str,
+) -> None:
+ match = re.search(r'(?m)^DATABASE_ADMIN_URL="([^"\n]+)"$', guide_text)
+ if match is None:
+ raise DeploymentContractError("migration_admin_database_example_missing")
+ runtime_role, runtime_endpoint = _validate_database_url(
+ values["DATABASE_URL"],
+ expected_user="alicebot_app",
+ expected_placeholder="${ALICEBOT_DB_APP_PASSWORD}",
+ )
+ admin_role, admin_endpoint = _validate_database_url(
+ match.group(1),
+ expected_user="alicebot_admin",
+ expected_placeholder="${ALICEBOT_DB_ADMIN_PASSWORD}",
+ )
+ _require(runtime_role != admin_role, "database_roles_not_separated")
+ _require(runtime_endpoint == admin_endpoint, "database_endpoints_mismatch")
+
+
+def validate_caddyfile(text: str) -> None:
+ normalized = "\n".join(line.split("#", 1)[0].rstrip() for line in text.splitlines())
+ directives = {
+ tuple(line.split())
+ for line in normalized.splitlines()
+ if line.strip() and line.strip() not in {"{", "}"}
+ }
+ _require(("alice.example.com", "{") in directives, "caddy_public_host_missing")
+ _require("admin 127.0.0.1:2019" in normalized, "caddy_admin_not_loopback")
+ _require("strict_sni_host on" in normalized, "caddy_strict_sni_missing")
+ _require("client_auth" in normalized, "caddy_authentication_missing")
+ _require("mode require_and_verify" in normalized, "caddy_mtls_not_fail_closed")
+ _require(
+ "trust_pool file /run/secrets/alicebot/client-ca.pem" in normalized,
+ "caddy_mtls_trust_pool_missing",
+ )
+ _require(("reverse_proxy", "127.0.0.1:8000") in directives, "caddy_api_upstream_invalid")
+ _require(("reverse_proxy", "127.0.0.1:3000") in directives, "caddy_web_upstream_invalid")
+ api_matcher = re.search(r"(?m)^\s*@alice_api\s+path\s+([^\n]+)$", normalized)
+ if api_matcher is None:
+ raise DeploymentContractError("caddy_public_api_routes_invalid")
+ public_api_paths = set(api_matcher.group(1).split())
+ _require(
+ public_api_paths
+ == {
+ "/healthz",
+ "/openapi.json",
+ "/docs",
+ "/docs/*",
+ "/redoc",
+ "/redoc/*",
+ "/v0/vnext",
+ "/v0/vnext/*",
+ },
+ "caddy_public_api_routes_invalid",
+ )
+ _require("/v1" not in normalized and "/v0/*" not in normalized, "caddy_public_api_routes_invalid")
+ _require("tls internal" not in normalized, "caddy_public_ca_disabled")
+ _require(
+ 'Strict-Transport-Security "max-age=31536000; includeSubDomains"' in normalized,
+ "caddy_hsts_missing",
+ )
+ _require(
+ 'Content-Security-Policy "frame-ancestors \'none\'"' in normalized
+ and 'X-Frame-Options "DENY"' in normalized,
+ "caddy_clickjacking_defense_missing",
+ )
+ _require(
+ re.search(r"(?im)^\s*header_up\s+X-Forwarded-For\b", normalized) is None,
+ "caddy_forwarded_client_overridden",
+ )
+ for forbidden in ("reverse_proxy 0.0.0.0", "reverse_proxy localhost", "http://alice.example.com"):
+ _require(forbidden not in normalized, "caddy_non_loopback_or_plaintext_upstream")
+
+
+def validate_guide(text: str) -> None:
+ normalized = re.sub(r"\s+", " ", text)
+ required_phrases = (
+ "Keyless equals local-machine-owner trust",
+ "APP_ENV=production",
+ "PostgreSQL 16",
+ "pgvector",
+ "sslmode=verify-full",
+ "percent-encode each database password as URL userinfo",
+ "alicebot_admin",
+ "alicebot_app",
+ "env:TELEGRAM_BOT_TOKEN",
+ "before opening the firewall",
+ "must return 401",
+ "`/healthz` checks PostgreSQL only",
+ "external scheduler",
+ "off-host encrypted",
+ "disposable restore",
+ "no in-place schema downgrade",
+ OWNER_RECEIPT_BLOCKER,
+ "multi-tenant",
+ "SLA",
+ "high availability",
+ "managed backup",
+ "managed alert",
+ "carrier_snapshot_sha256",
+ "validated_asset_sha256",
+ "/vnext is the only live authenticated browser console",
+ "future BFF or client-side refactor",
+ "Remote /v1 is unsupported",
+ "no-client-certificate rejection",
+ "untrusted-client-certificate rejection",
+ "revoked-client-certificate rejection",
+ "HSTS and clickjacking response headers",
+ "DATABASE_ADMIN_URL is absent from the API runtime environment",
+ "DATABASE_ADMIN_URL is required for migrations",
+ "EnvironmentFile=/run/secrets/alicebot/backup-restore.env",
+ "BindReadOnlyPaths=/run/secrets/alicebot/postgres-ca.pem",
+ "run_phase5_ops_evidence.py --backend postgres",
+ "runtime DB role=alicebot_app",
+ "SELECT session_user, current_user",
+ "admin DSN absent from API service environment",
+ "remote-v1-not-api",
+ "remote-non-vnext-v0-not-api",
+ "remote-vnext-lookalike-not-api",
+ )
+ for phrase in required_phrases:
+ _require(phrase in normalized, "deployment_guide_contract_incomplete")
+
+
+def _typescript_function_source(source: str, name: str) -> str:
+ match = re.search(rf"(?:export\s+)?function\s+{re.escape(name)}\s*\(", source)
+ if match is None:
+ raise DeploymentContractError("web_trust_function_missing")
+ opening = source.find("{", match.end())
+ _require(opening >= 0, "web_trust_function_invalid")
+ depth = 0
+ for index in range(opening, len(source)):
+ character = source[index]
+ if character == "{":
+ depth += 1
+ elif character == "}":
+ depth -= 1
+ if depth == 0:
+ return source[match.start() : index + 1]
+ raise DeploymentContractError("web_trust_function_invalid")
+
+
+def validate_web_trust_contract(source: str) -> None:
+ current_origin = _typescript_function_source(source, "currentAliceWebOrigin")
+ for fragment in (
+ "process.env.PUBLIC_ORIGIN",
+ "window.location.origin",
+ 'parsed.protocol !== "https:"',
+ "parsed.username",
+ "parsed.password",
+ 'parsed.pathname !== "/"',
+ "parsed.search",
+ "parsed.hash",
+ "return parsed.origin",
+ ):
+ _require(fragment in current_origin, "web_current_origin_contract_invalid")
+
+ trusted = _typescript_function_source(source, "isTrustedApiBaseUrl")
+ for fragment in (
+ "export function isTrustedApiBaseUrl",
+ "isLocalApiBaseUrl(normalized)",
+ 'parsed.protocol === "https:"',
+ 'parsed.pathname === "/"',
+ "parsed.origin === currentAliceWebOrigin()",
+ ):
+ _require(fragment in trusted, "web_exact_origin_trust_invalid")
+
+ live_config = _typescript_function_source(source, "hasLiveApiConfig")
+ _require(
+ "isTrustedApiBaseUrl(config.apiBaseUrl)" in live_config,
+ "web_live_config_bypasses_trust",
+ )
+ operator_key = _typescript_function_source(source, "shouldAttachVNextOperatorAgentApiKey")
+ for fragment in (
+ "isTrustedApiBaseUrl(apiBaseUrl)",
+ 'logicalPath === "/v0/vnext"',
+ 'logicalPath.startsWith("/v0/vnext/")',
+ ):
+ _require(fragment in operator_key, "web_operator_key_bypasses_trust")
+
+
+def validate_supply_chain_pins(workflow_text: str) -> dict[str, int]:
+ action_count = 0
+ image_count = 0
+ for raw_line in workflow_text.splitlines():
+ line = raw_line.strip()
+ action_match = re.match(r"uses:\s*([^\s#]+)", line)
+ if action_match is not None:
+ reference = action_match.group(1)
+ if not reference.startswith("./"):
+ action_count += 1
+ _require("@" in reference, "workflow_action_unpinned")
+ revision = reference.rsplit("@", 1)[1]
+ _require(_FULL_SHA.fullmatch(revision) is not None, "workflow_action_unpinned")
+ image_match = re.match(r"image:\s*([^\s#]+)", line)
+ if image_match is not None:
+ image_count += 1
+ _require(_IMAGE_DIGEST.search(image_match.group(1)) is not None, "workflow_image_unpinned")
+ _require(action_count > 0, "workflow_actions_missing")
+ return {"actions": action_count, "images": image_count}
+
+
+def _base_report(environment: str) -> dict[str, object]:
+ return {
+ "report_version": REPORT_VERSION,
+ "environment": environment,
+ "cloud_provider": "none",
+ "public_dns": False,
+ "public_ca": False,
+ "evidence_kind": "configuration_contract_only",
+ "real_cloud_host_exercised": False,
+ }
+
+
+def _assert_report_safe(report: Mapping[str, object]) -> None:
+ serialized = json.dumps(report, sort_keys=True)
+ for marker in _SECRET_MARKERS:
+ _require(marker not in serialized, "report_contains_secret_marker")
+ _require(re.search(r"(?:^|[\s\"'])/(?:Users|home|private|tmp|var)/", serialized) is None, "report_contains_path")
+
+
+def _decode_asset(assets: Mapping[str, bytes], logical_name: str) -> str:
+ try:
+ return assets[logical_name].decode("utf-8")
+ except UnicodeDecodeError as exc:
+ raise DeploymentContractError("validated_asset_not_utf8") from exc
+
+
+def _validated_report(
+ *,
+ provenance: Mapping[str, object],
+ assets: Mapping[str, bytes],
+ environment: str,
+) -> dict[str, object]:
+ env_text = _decode_asset(assets, "environment_example")
+ caddy_text = _decode_asset(assets, "caddyfile_example")
+ guide_text = _decode_asset(assets, "deployment_guide")
+ workflow_text = _decode_asset(assets, "workflow")
+ web_api_source = _decode_asset(assets, "web_api_source")
+
+ values = parse_env_example(env_text)
+ validate_environment(values)
+ validate_role_separated_database_contract(values, guide_text)
+ validate_caddyfile(caddy_text)
+ validate_guide(guide_text)
+ validate_web_trust_contract(web_api_source)
+ pins = validate_supply_chain_pins(workflow_text)
+
+ report = {
+ **_base_report(environment),
+ **provenance,
+ "status": "passed",
+ "checks": {
+ "api_and_web_loopback": "passed",
+ "database_role_and_tls_contract": "passed",
+ "exact_https_origin": "passed",
+ "proxy_trust_boundary": "passed",
+ "web_exact_origin_trust": "passed",
+ "guide_claim_boundaries": "passed",
+ "supply_chain_pins": {"status": "passed", **pins},
+ },
+ "blockers": [OWNER_RECEIPT_BLOCKER],
+ }
+ _assert_report_safe(report)
+ return report
+
+
+def run_smoke(*, root: Path = ROOT, environment: str = "local_validation") -> dict[str, object]:
+ provenance, assets = repository_carrier_identity(root)
+ return _validated_report(provenance=provenance, assets=assets, environment=environment)
+
+
+def _write_report(path: Path, report: Mapping[str, object]) -> None:
+ _assert_report_safe(report)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
+ try:
+ os.fchmod(fd, 0o600)
+ with os.fdopen(fd, "w", encoding="utf-8") as stream:
+ json.dump(report, stream, sort_keys=True, indent=2)
+ stream.write("\n")
+ stream.flush()
+ os.fsync(stream.fileno())
+ os.replace(temp_name, path)
+ os.chmod(path, 0o600)
+ except BaseException:
+ try:
+ os.unlink(temp_name)
+ except FileNotFoundError:
+ pass
+ raise
+
+
+def _build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--root", type=Path, default=ROOT)
+ parser.add_argument(
+ "--environment",
+ choices=("local_validation", "ephemeral_ci"),
+ default="local_validation",
+ )
+ parser.add_argument("--output", type=Path)
+ return parser
+
+
+def main(argv: list[str] | None = None) -> int:
+ args = _build_parser().parse_args(argv)
+ provenance: dict[str, object] = {}
+ try:
+ provenance, assets = repository_carrier_identity(args.root)
+ report = _validated_report(
+ provenance=provenance,
+ assets=assets,
+ environment=args.environment,
+ )
+ exit_code = 0
+ except (DeploymentContractError, OSError) as exc:
+ failure_code = exc.code if isinstance(exc, DeploymentContractError) else "deployment_asset_unreadable"
+ report = {
+ **_base_report(args.environment),
+ **provenance,
+ "status": "failed",
+ "failure_codes": [failure_code],
+ "blockers": [OWNER_RECEIPT_BLOCKER],
+ }
+ exit_code = 1
+ _assert_report_safe(report)
+ if args.output is not None:
+ try:
+ _write_report(args.output, report)
+ except OSError:
+ report = {
+ **_base_report(args.environment),
+ **provenance,
+ "status": "failed",
+ "failure_codes": ["receipt_write_failed"],
+ "blockers": [OWNER_RECEIPT_BLOCKER],
+ }
+ exit_code = 1
+ print(json.dumps(report, sort_keys=True))
+ return exit_code
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tests/integration/test_browser_clip_capabilities.py b/tests/integration/test_browser_clip_capabilities.py
new file mode 100644
index 00000000..e9b37e47
--- /dev/null
+++ b/tests/integration/test_browser_clip_capabilities.py
@@ -0,0 +1,148 @@
+from __future__ import annotations
+
+from concurrent.futures import ThreadPoolExecutor
+from hashlib import sha256
+from threading import Barrier
+from uuid import UUID, uuid4
+
+from alicebot_api.db import user_connection
+from alicebot_api.store import ContinuityStore
+from alicebot_api.vnext_store import PostgresVNextStore
+
+
+ORIGIN = "https://docs.example.test"
+
+
+def _seed_user(database_url: str, *, email: str) -> UUID:
+ user_id = uuid4()
+ with user_connection(database_url, user_id) as conn:
+ ContinuityStore(conn).create_user(user_id, email, "Browser Clip User")
+ return user_id
+
+
+def test_postgres_capability_is_hash_only_rls_bound_expiring_and_atomically_one_time(
+ migrated_database_urls,
+) -> None:
+ database_url = migrated_database_urls["app"]
+ owner_id = _seed_user(database_url, email=f"clip-owner-{uuid4().hex}@example.test")
+ other_id = _seed_user(database_url, email=f"clip-other-{uuid4().hex}@example.test")
+ raw_capability = "alice_clip_concurrent-postgres-redemption"
+ capability_hash = sha256(raw_capability.encode("utf-8")).hexdigest()
+
+ with user_connection(database_url, owner_id) as conn:
+ store = PostgresVNextStore(conn)
+ issued = store.create_browser_clip_capability(
+ capability_hash=capability_hash,
+ origin=ORIGIN,
+ ttl_seconds=120,
+ )
+ assert set(issued) == {"id", "user_id", "origin", "expires_at", "consumed_at", "created_at"}
+ assert issued["user_id"] == owner_id
+ assert issued["origin"] == ORIGIN
+ assert issued["consumed_at"] is None
+ assert 119.9 <= (issued["expires_at"] - issued["created_at"]).total_seconds() <= 120.1
+ with conn.cursor() as cur:
+ cur.execute(
+ "SELECT capability_hash FROM browser_clip_capabilities WHERE id = %s::uuid",
+ (issued["id"],),
+ )
+ persisted = cur.fetchone()
+ assert persisted == {"capability_hash": capability_hash}
+ assert raw_capability not in repr(persisted)
+
+ expired_hash = sha256(b"alice_clip_expired-postgres").hexdigest()
+ expired = store.create_browser_clip_capability(
+ capability_hash=expired_hash,
+ origin=ORIGIN,
+ ttl_seconds=120,
+ )
+ with conn.cursor() as cur:
+ cur.execute(
+ """
+ UPDATE browser_clip_capabilities
+ SET created_at = clock_timestamp() - interval '2 minutes',
+ expires_at = clock_timestamp() - interval '1 minute'
+ WHERE id = %s::uuid
+ """,
+ (expired["id"],),
+ )
+ assert (
+ store.consume_browser_clip_capability(
+ capability_hash=expired_hash,
+ origin=ORIGIN,
+ )
+ is None
+ )
+
+ with user_connection(database_url, other_id) as conn:
+ other_store = PostgresVNextStore(conn)
+ assert (
+ other_store.consume_browser_clip_capability(
+ capability_hash=capability_hash,
+ origin=ORIGIN,
+ )
+ is None
+ )
+ with conn.cursor() as cur:
+ cur.execute("SELECT count(*) AS capability_count FROM browser_clip_capabilities")
+ assert cur.fetchone() == {"capability_count": 0}
+
+ with user_connection(database_url, owner_id) as conn:
+ assert (
+ PostgresVNextStore(conn).consume_browser_clip_capability(
+ capability_hash=capability_hash,
+ origin="https://other.example.test",
+ )
+ is None
+ )
+
+ start = Barrier(2)
+
+ def redeem(attempt: int) -> bool:
+ with user_connection(database_url, owner_id) as conn:
+ store = PostgresVNextStore(conn)
+ start.wait(timeout=10)
+ redeemed = store.consume_browser_clip_capability(
+ capability_hash=capability_hash,
+ origin=ORIGIN,
+ )
+ if redeemed is None:
+ return False
+ store.create_source(
+ {
+ "source_type": "browser_clip",
+ "title": f"Concurrent clip {attempt}",
+ "uri": f"{ORIGIN}/guide",
+ "content_hash": "sha256:browser-clip-concurrent-postgres-redemption",
+ "connector_name": "browser_clipper",
+ "domain": "professional",
+ "sensitivity": "private",
+ "metadata_json": {"transport": "one_time_capability"},
+ },
+ actor_type="user",
+ )
+ return True
+
+ with ThreadPoolExecutor(max_workers=2) as executor:
+ results = list(executor.map(redeem, (1, 2)))
+
+ assert sorted(results) == [False, True]
+ with user_connection(database_url, owner_id) as conn:
+ store = PostgresVNextStore(conn)
+ assert (
+ store.consume_browser_clip_capability(
+ capability_hash=capability_hash,
+ origin=ORIGIN,
+ )
+ is None
+ )
+ with conn.cursor() as cur:
+ cur.execute(
+ """
+ SELECT count(*) AS source_count
+ FROM sources
+ WHERE connector_name = 'browser_clipper'
+ AND content_hash = 'sha256:browser-clip-concurrent-postgres-redemption'
+ """
+ )
+ assert cur.fetchone() == {"source_count": 1}
diff --git a/tests/integration/test_default_surface_integration.py b/tests/integration/test_default_surface_integration.py
index a60eceb9..48daa28f 100644
--- a/tests/integration/test_default_surface_integration.py
+++ b/tests/integration/test_default_surface_integration.py
@@ -21,7 +21,7 @@
REPO_ROOT = Path(__file__).resolve().parents[2]
-DEFAULT_HTTP_OPERATION_COUNT = 182
+DEFAULT_HTTP_OPERATION_COUNT = 183
CORE_MCP_TOOL_NAMES = {
"alice_capture",
"alice_recall",
diff --git a/tests/integration/test_migrations.py b/tests/integration/test_migrations.py
index 5c2b0f0d..ac6ba087 100644
--- a/tests/integration/test_migrations.py
+++ b/tests/integration/test_migrations.py
@@ -414,7 +414,7 @@ def _assert_all_pointers_truthful() -> None:
with psycopg.connect(database_urls["admin"], row_factory=dict_row) as conn:
with conn.cursor() as cur:
cur.execute("SELECT version_num FROM alembic_version")
- assert cur.fetchone()["version_num"] == "20260721_0093"
+ assert cur.fetchone()["version_num"] == "20260721_0094"
# Repeat the additive post-release migrations through their downgrade
# boundary. The already repaired data remains correct and the second
diff --git a/tests/integration/test_provider_runtime_api.py b/tests/integration/test_provider_runtime_api.py
index 7834b9f0..72849279 100644
--- a/tests/integration/test_provider_runtime_api.py
+++ b/tests/integration/test_provider_runtime_api.py
@@ -1387,9 +1387,11 @@ def fake_discovery_urlopen(request, timeout):
def test_provider_invocation_telemetry_persists_for_test_and_runtime(
migrated_database_urls,
monkeypatch,
+ caplog,
) -> None:
_configure_settings(migrated_database_urls, monkeypatch)
- install_openai_compatible_success(
+ provider_secret = f"STAGE_A_PROVIDER_SECRET_{uuid4().hex}"
+ captured_requests = install_openai_compatible_success(
monkeypatch,
models=["gpt-5-mini"],
response_text="Telemetry runtime response",
@@ -1405,7 +1407,7 @@ def test_provider_invocation_telemetry_persists_for_test_and_runtime(
"provider_key": "openai_compatible",
"display_name": "Telemetry OpenAI",
"base_url": "https://provider.example/v1",
- "api_key": "provider-secret-key",
+ "api_key": provider_secret,
"default_model": "gpt-5-mini",
},
headers=identity_header(user_id),
@@ -1441,6 +1443,22 @@ def test_provider_invocation_telemetry_persists_for_test_and_runtime(
assert runtime_status == 200
assert runtime_payload["assistant"]["response_id"] == "resp_telemetry_1"
+ # Prove the configured sentinel was the credential exercised by both
+ # discovery/test and runtime transport, then prove it never crossed back
+ # into Alice's public payloads or logs.
+ assert captured_requests
+ assert all(
+ record["headers"].get("Authorization") == f"Bearer {provider_secret}"
+ for record in captured_requests
+ )
+ serialized_public_payloads = json.dumps(
+ [create_payload, provider_test_payload, runtime_payload],
+ default=str,
+ sort_keys=True,
+ )
+ assert provider_secret not in serialized_public_payloads
+ assert provider_secret not in caplog.text
+
with psycopg.connect(migrated_database_urls["admin"]) as conn:
with conn.cursor() as cur:
cur.execute(
@@ -1450,7 +1468,8 @@ def test_provider_invocation_telemetry_persists_for_test_and_runtime(
thread_id,
response_id,
usage->>'total_tokens' AS total_tokens,
- runtime_provider
+ runtime_provider,
+ to_jsonb(provider_invocation_telemetry) AS complete_row
FROM provider_invocation_telemetry
WHERE workspace_id = %s
AND provider_id = %s
@@ -1473,6 +1492,11 @@ def test_provider_invocation_telemetry_persists_for_test_and_runtime(
assert telemetry_rows[1][3] == "resp_telemetry_1"
assert telemetry_rows[1][4] == "17"
assert telemetry_rows[1][5] == "openai_responses"
+ assert provider_secret not in json.dumps(
+ [row[6] for row in telemetry_rows],
+ default=str,
+ sort_keys=True,
+ )
def test_provider_invocation_telemetry_respects_workspace_rls(
@@ -2164,7 +2188,8 @@ def test_provider_error_reflection_and_persistence_are_sanitized(
_configure_settings(migrated_database_urls, monkeypatch)
install_openai_compatible_success(monkeypatch)
user_id, _, user_account_id = _bootstrap_local_workspace("provider-security-sanitized-errors@example.com")
- sensitive_detail = "UPSTREAM_SECRET_TOKEN_ABC123"
+ provider_secret = f"UPSTREAM_PROVIDER_SECRET_{uuid4().hex}"
+ sensitive_detail = provider_secret
def fake_urlopen(request, timeout):
del timeout
@@ -2185,7 +2210,7 @@ def fake_urlopen(request, timeout):
"provider_key": "openai_compatible",
"display_name": "OpenAI Sanitized Errors",
"base_url": "https://provider.example/v1",
- "api_key": "provider-secret-key",
+ "api_key": provider_secret,
"default_model": "gpt-5-mini",
},
headers=identity_header(user_id),
@@ -2250,7 +2275,7 @@ def fake_urlopen(request, timeout):
with conn.cursor() as cur:
cur.execute(
"""
- SELECT te.payload->>'error_message'
+ SELECT te.payload
FROM trace_events te
JOIN traces t
ON t.id = te.trace_id
@@ -2264,5 +2289,5 @@ def fake_urlopen(request, timeout):
)
trace_row = cur.fetchone()
assert trace_row is not None
- assert trace_row[0] == UPSTREAM_FAILURE.message
- assert sensitive_detail not in trace_row[0]
+ assert trace_row[0]["error_message"] == UPSTREAM_FAILURE.message
+ assert sensitive_detail not in json.dumps(trace_row[0], default=str, sort_keys=True)
diff --git a/tests/integration/test_review_dashboard_demo.py b/tests/integration/test_review_dashboard_demo.py
new file mode 100644
index 00000000..1e24eab7
--- /dev/null
+++ b/tests/integration/test_review_dashboard_demo.py
@@ -0,0 +1,254 @@
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from typing import Any
+from urllib.parse import urlencode
+from uuid import UUID, uuid4
+
+import anyio
+import pytest
+
+import alicebot_api.main as main_module
+from alicebot_api.config import Settings
+from alicebot_api.db import user_connection
+from alicebot_api.routers import vnext_memories as vnext_memories_router
+from alicebot_api.routers import vnext_retrieval as vnext_retrieval_router
+from alicebot_api.routers import workspaces as workspaces_router
+from alicebot_api.store import ContinuityStore
+from alicebot_api.vnext_agent_keys import create_agent_key
+from alicebot_api.vnext_store import PostgresVNextStore
+
+
+REPO_ROOT = Path(__file__).resolve().parents[2]
+
+
+def _invoke_request(
+ method: str,
+ path: str,
+ *,
+ payload: dict[str, Any] | None = None,
+ query_params: dict[str, str] | None = None,
+ authorization: str | None = None,
+) -> tuple[int, dict[str, Any]]:
+ messages: list[dict[str, object]] = []
+ encoded_body = b"" if payload is None else json.dumps(payload).encode()
+ request_received = False
+
+ async def receive() -> dict[str, object]:
+ nonlocal request_received
+ if request_received:
+ return {"type": "http.disconnect"}
+ request_received = True
+ return {"type": "http.request", "body": encoded_body, "more_body": False}
+
+ async def send(message: dict[str, object]) -> None:
+ messages.append(message)
+
+ headers = [(b"content-type", b"application/json")]
+ if authorization is not None:
+ headers.append((b"authorization", authorization.encode()))
+
+ scope = {
+ "type": "http",
+ "asgi": {"version": "3.0"},
+ "http_version": "1.1",
+ "method": method,
+ "scheme": "http",
+ "path": path,
+ "raw_path": path.encode(),
+ "query_string": urlencode(query_params or {}).encode(),
+ "headers": headers,
+ "client": ("testclient", 50000),
+ "server": ("testserver", 80),
+ "root_path": "",
+ }
+
+ anyio.run(main_module.app, scope, receive, send)
+
+ start = next(message for message in messages if message["type"] == "http.response.start")
+ body = b"".join(
+ message.get("body", b"")
+ for message in messages
+ if message["type"] == "http.response.body"
+ )
+ return int(start["status"]), json.loads(body)
+
+
+def _seed_user(database_url: str) -> UUID:
+ user_id = uuid4()
+ with user_connection(database_url, user_id) as conn:
+ ContinuityStore(conn).create_user(
+ user_id,
+ f"review-dashboard-{uuid4().hex[:12]}@example.invalid",
+ "Review Dashboard Demo",
+ )
+ return user_id
+
+
+def test_review_dashboard_demo_routes_keyed_browser_review_to_vnext() -> None:
+ guide = (REPO_ROOT / "docs/alpha/review-dashboard-demo.md").read_text(encoding="utf-8")
+
+ assert "With an active key, open `/vnext`" in guide
+ assert "Only in the zero-key local/demo posture may `/memories` and `/traces`" in guide
+ assert "cannot forward" in guide
+ assert "browser-memory Bearer" in guide
+ assert 'auth_args=(-H "Authorization: Bearer ${ALICE_AGENT_API_KEY}")' not in guide
+ assert 'chmod 600 "$auth_config"' in guide
+ assert 'auth_args=(--config "$auth_config")' in guide
+
+
+@pytest.mark.parametrize("auth_mode", ["keyless", "unbound_admin"])
+def test_public_review_dashboard_demo_keeps_trace_truth_and_redaction_boundary(
+ migrated_database_urls,
+ monkeypatch,
+ auth_mode: str,
+) -> None:
+ """Exercise the documented API path without claiming a browser redaction control."""
+
+ monkeypatch.delenv("ALICE_EMBEDDINGS_BASE_URL", raising=False)
+ monkeypatch.delenv("ALICE_EMBEDDINGS_MODEL", raising=False)
+ monkeypatch.delenv("ALICE_EMBEDDINGS_API_KEY", raising=False)
+ settings = Settings(database_url=migrated_database_urls["app"])
+ monkeypatch.setattr(main_module, "get_settings", lambda: settings)
+ monkeypatch.setattr(vnext_memories_router, "get_settings", lambda: settings)
+ monkeypatch.setattr(vnext_retrieval_router, "get_settings", lambda: settings)
+ monkeypatch.setattr(workspaces_router, "get_settings", lambda: settings)
+
+ user_id = _seed_user(migrated_database_urls["app"])
+ user_id_text = str(user_id)
+ authorization: str | None = None
+ if auth_mode == "unbound_admin":
+ with user_connection(migrated_database_urls["app"], user_id) as conn:
+ _key_record, raw_key = create_agent_key(
+ PostgresVNextStore(conn),
+ user_id=user_id,
+ agent_id="review-dashboard-operator",
+ permission_profile="admin_agent",
+ label="Review dashboard integration",
+ )
+ authorization = f"Bearer {raw_key}"
+
+ raw_sentinel = f"DASHBOARD-DEMO-RAW-{uuid4().hex}"
+ source_status, source_payload = _invoke_request(
+ "POST",
+ "/v0/vnext/sources",
+ payload={
+ "user_id": user_id_text,
+ "raw_text": f"Decision: {raw_sentinel} is accepted only after operator review.",
+ "title": "Review dashboard public-API demo",
+ "domain": "professional",
+ "sensitivity": "private",
+ },
+ authorization=authorization,
+ )
+ assert source_status == 201
+ assert source_payload["candidate_memory_count"] == 1
+ source_id = source_payload["source_id"]
+
+ source_review_status, source_review_payload = _invoke_request(
+ "POST",
+ f"/v0/vnext/sources/{source_id}/review",
+ payload={
+ "user_id": user_id_text,
+ "action": "review",
+ "review_note": "Reviewed in the scripted enterprise demo.",
+ },
+ authorization=authorization,
+ )
+ assert source_review_status == 200
+ source_trace = source_review_payload["trace"]
+ assert source_trace["trace_kind"] == "capture_to_brief"
+ assert source_trace["summary"]["source_id"] == source_id
+ assert source_trace["sampling"]["trace_complete"] is True
+ assert len(source_trace["candidate_memories"]) == 1
+ memory_id = source_trace["candidate_memories"][0]["id"]
+
+ accept_status, accept_payload = _invoke_request(
+ "POST",
+ f"/v0/vnext/memories/{memory_id}/review",
+ payload={"user_id": user_id_text, "action": "accept"},
+ authorization=authorization,
+ )
+ assert accept_status == 200
+ assert accept_payload["memory"]["status"] == "active"
+
+ with user_connection(migrated_database_urls["app"], user_id) as conn:
+ store = PostgresVNextStore(conn)
+ source_before_trace = store.get_source(source_id)
+ source_events_before_trace = store.list_events(
+ target_type="source",
+ target_id=source_id,
+ limit=500,
+ )
+ assert source_before_trace is not None
+
+ trace_status, trace = _invoke_request(
+ "GET",
+ f"/v0/vnext/traces/sources/{source_id}",
+ query_params={"user_id": user_id_text},
+ authorization=authorization,
+ )
+ assert trace_status == 200
+ traced_memory = next(item for item in trace["candidate_memories"] if item["id"] == memory_id)
+ assert traced_memory["status"] == "active"
+ assert trace["summary"]["candidate_memory_count"] == 1
+ assert any(event["event_type"] == "review.item_accepted" for event in trace["events"])
+
+ with user_connection(migrated_database_urls["app"], user_id) as conn:
+ store = PostgresVNextStore(conn)
+ source_after_trace = store.get_source(source_id)
+ source_events_after_trace = store.list_events(
+ target_type="source",
+ target_id=source_id,
+ limit=500,
+ )
+ assert source_after_trace is not None
+ assert source_after_trace == source_before_trace
+ assert source_after_trace["metadata_json"] == source_before_trace["metadata_json"]
+ assert source_events_after_trace == source_events_before_trace
+
+ redact_status, redact_payload = _invoke_request(
+ "POST",
+ "/v0/vnext/memories/redact",
+ payload={
+ "user_id": user_id_text,
+ "memory_id": memory_id,
+ "reason": "Scripted enterprise demo cleanup.",
+ },
+ authorization=authorization,
+ )
+ assert redact_status == 200
+ assert redact_payload["status"] == "redacted"
+ assert redact_payload["forgotten_first"] is True
+
+ with user_connection(migrated_database_urls["app"], user_id) as conn:
+ store = PostgresVNextStore(conn)
+ redacted_memory = store.get_memory_for_redaction(memory_id)
+ revisions = store.list_revisions(memory_id)
+ events = store.list_events(
+ target_type="memory",
+ target_id=memory_id,
+ limit=500,
+ )
+ source = store.get_source(source_id)
+
+ assert redacted_memory is not None
+ assert str(redacted_memory["id"]) == memory_id
+ assert redacted_memory["status"] == "archived"
+ assert redacted_memory["canonical_text"] == "[REDACTED]"
+ assert redacted_memory["metadata_json"]["redacted"] is True
+ assert revisions
+ assert any(event["event_type"] == "memory.redacted" for event in events)
+ governed_memory_graph = json.dumps(
+ {"memory": redacted_memory, "revisions": revisions, "events": events},
+ default=str,
+ sort_keys=True,
+ )
+ assert raw_sentinel not in governed_memory_graph
+
+ # True memory redaction intentionally preserves separately governed source
+ # evidence. The demo and its test state this boundary instead of claiming a
+ # global erasure that the public API does not perform.
+ assert source is not None
+ assert str(source["id"]) == source_id
diff --git a/tests/integration/test_stage_a_agent_key_isolation.py b/tests/integration/test_stage_a_agent_key_isolation.py
new file mode 100644
index 00000000..e9a4526c
--- /dev/null
+++ b/tests/integration/test_stage_a_agent_key_isolation.py
@@ -0,0 +1,140 @@
+from __future__ import annotations
+
+import json
+from typing import Any
+from urllib.parse import urlencode
+from uuid import UUID, uuid4
+
+import anyio
+
+import alicebot_api.main as main_module
+from alicebot_api.config import Settings
+from alicebot_api.db import user_connection
+from alicebot_api.routers import vnext_projects as vnext_projects_router
+from alicebot_api.store import ContinuityStore
+from alicebot_api.vnext_agent_keys import create_agent_key, hash_agent_key
+from alicebot_api.vnext_store import PostgresVNextStore
+
+
+def _invoke_get(
+ path: str,
+ *,
+ user_id: UUID,
+ authorization: str | None = None,
+) -> tuple[int, dict[str, Any]]:
+ messages: list[dict[str, object]] = []
+ received = False
+
+ async def receive() -> dict[str, object]:
+ nonlocal received
+ if received:
+ return {"type": "http.disconnect"}
+ received = True
+ return {"type": "http.request", "body": b"", "more_body": False}
+
+ async def send(message: dict[str, object]) -> None:
+ messages.append(message)
+
+ headers: list[tuple[bytes, bytes]] = [(b"content-type", b"application/json")]
+ if authorization is not None:
+ headers.append((b"authorization", authorization.encode("utf-8")))
+ scope = {
+ "type": "http",
+ "asgi": {"version": "3.0"},
+ "http_version": "1.1",
+ "method": "GET",
+ "scheme": "http",
+ "path": path,
+ "raw_path": path.encode("utf-8"),
+ "query_string": urlencode({"user_id": str(user_id)}).encode("ascii"),
+ "headers": headers,
+ "client": ("stage-a-key-isolation", 50000),
+ "server": ("testserver", 80),
+ "root_path": "",
+ }
+ anyio.run(main_module.app, scope, receive, send)
+
+ start = next(message for message in messages if message["type"] == "http.response.start")
+ response_body = b"".join(
+ message.get("body", b"")
+ for message in messages
+ if message["type"] == "http.response.body"
+ )
+ return int(start["status"]), json.loads(response_body)
+
+
+def test_agent_key_activation_and_visibility_are_isolated_per_user(
+ migrated_database_urls: dict[str, str],
+ monkeypatch,
+ caplog,
+) -> None:
+ user_a = uuid4()
+ user_b = uuid4()
+ with user_connection(migrated_database_urls["app"], user_a) as conn:
+ ContinuityStore(conn).create_user(
+ user_a,
+ f"stage-a-key-owner-{user_a.hex[:12]}@example.invalid",
+ "Stage A key owner",
+ )
+ key_a_record, raw_key_a = create_agent_key(
+ PostgresVNextStore(conn),
+ user_id=user_a,
+ agent_id="stage-a-owner-agent",
+ permission_profile="trusted_local_agent",
+ )
+ with user_connection(migrated_database_urls["app"], user_b) as conn:
+ ContinuityStore(conn).create_user(
+ user_b,
+ f"stage-a-keyless-{user_b.hex[:12]}@example.invalid",
+ "Stage A keyless user",
+ )
+
+ settings = Settings(app_env="test", database_url=migrated_database_urls["app"])
+ monkeypatch.setattr(main_module, "get_settings", lambda: settings)
+ monkeypatch.setattr(vnext_projects_router, "get_settings", lambda: settings)
+
+ owner_keyless_status, owner_keyless_payload = _invoke_get(
+ "/v0/vnext/projects",
+ user_id=user_a,
+ )
+ keyless_status, keyless_payload = _invoke_get(
+ "/v0/vnext/projects",
+ user_id=user_b,
+ )
+ with caplog.at_level("ERROR", logger="alicebot_api.public_errors"):
+ foreign_status, foreign_payload = _invoke_get(
+ "/v0/vnext/projects",
+ user_id=user_b,
+ authorization=f"Bearer {raw_key_a}",
+ )
+
+ assert owner_keyless_status == 401
+ assert owner_keyless_payload == {
+ "detail": {"code": "authentication_failed", "message": "Authentication failed"}
+ }
+ assert keyless_status == 200
+ assert keyless_payload["items"] == []
+ assert foreign_status == 401
+ assert foreign_payload == {
+ "detail": {"code": "authentication_failed", "message": "Authentication failed"}
+ }
+
+ with user_connection(migrated_database_urls["app"], user_a) as conn:
+ owner_store = PostgresVNextStore(conn)
+ assert owner_store.count_active_agent_api_keys() == 1
+ assert [str(row["id"]) for row in owner_store.list_agent_api_keys()] == [
+ str(key_a_record["id"])
+ ]
+ assert owner_store.get_agent_api_key_by_hash(hash_agent_key(raw_key_a)) is not None
+
+ with user_connection(migrated_database_urls["app"], user_b) as conn:
+ other_store = PostgresVNextStore(conn)
+ assert other_store.count_active_agent_api_keys() == 0
+ assert other_store.list_agent_api_keys() == []
+ assert other_store.get_agent_api_key_by_hash(hash_agent_key(raw_key_a)) is None
+
+ assert raw_key_a not in json.dumps(
+ [owner_keyless_payload, keyless_payload, foreign_payload],
+ sort_keys=True,
+ )
+ assert raw_key_a not in caplog.text
diff --git a/tests/unit/test_20260721_0094_browser_clip_capabilities.py b/tests/unit/test_20260721_0094_browser_clip_capabilities.py
new file mode 100644
index 00000000..fa6bb8b7
--- /dev/null
+++ b/tests/unit/test_20260721_0094_browser_clip_capabilities.py
@@ -0,0 +1,58 @@
+from __future__ import annotations
+
+import importlib
+
+
+MODULE_NAME = "apps.api.alembic.versions.20260721_0094_browser_clip_capabilities"
+
+
+def load_migration_module():
+ return importlib.import_module(MODULE_NAME)
+
+
+def test_revision_chain_extends_artifact_rating_reviewer_constraint() -> None:
+ module = load_migration_module()
+
+ assert module.revision == "20260721_0094"
+ assert module.down_revision == "20260721_0093"
+
+
+def test_upgrade_creates_hash_only_short_lived_capability_table_with_rls(monkeypatch) -> None:
+ module = load_migration_module()
+ executed: list[str] = []
+ monkeypatch.setattr(module.op, "execute", executed.append)
+
+ module.upgrade()
+
+ joined = "\n".join(executed)
+ assert "CREATE TABLE browser_clip_capabilities" in joined
+ assert "user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE" in joined
+ assert "capability_hash text NOT NULL UNIQUE" in joined
+ assert "origin text NOT NULL" in joined
+ assert "expires_at timestamptz NOT NULL" in joined
+ assert "consumed_at timestamptz NULL" in joined
+ assert "created_at timestamptz NOT NULL DEFAULT clock_timestamp()" in joined
+ assert "browser_clip_capabilities_hash_check" in joined
+ assert "browser_clip_capabilities_expiry_range_check" in joined
+ assert "interval '5 minutes'" in joined
+ assert "browser_clip_capabilities_live_expiry_idx" in joined
+ assert "browser_clip_capabilities_consumed_idx" in joined
+ assert "GRANT SELECT, INSERT, UPDATE, DELETE ON browser_clip_capabilities TO alicebot_app" in joined
+ assert "ALTER TABLE browser_clip_capabilities ENABLE ROW LEVEL SECURITY" in executed
+ assert "ALTER TABLE browser_clip_capabilities FORCE ROW LEVEL SECURITY" in executed
+ assert "CREATE POLICY browser_clip_capabilities_is_owner" in joined
+ assert "user_id = app.current_user_id()" in joined
+
+ assert "raw_capability" not in joined
+ assert "capture_token" not in joined
+ assert "alice_clip_" not in joined
+
+
+def test_downgrade_drops_browser_clip_capabilities_table(monkeypatch) -> None:
+ module = load_migration_module()
+ executed: list[str] = []
+ monkeypatch.setattr(module.op, "execute", executed.append)
+
+ module.downgrade()
+
+ assert executed == ["DROP TABLE IF EXISTS browser_clip_capabilities"]
diff --git a/tests/unit/test_browser_clip_capabilities.py b/tests/unit/test_browser_clip_capabilities.py
new file mode 100644
index 00000000..a27ee49e
--- /dev/null
+++ b/tests/unit/test_browser_clip_capabilities.py
@@ -0,0 +1,288 @@
+from __future__ import annotations
+
+from datetime import UTC, datetime, timedelta
+from hashlib import sha256
+import re
+
+import pytest
+
+from alicebot_api.browser_clip_capabilities import (
+ BROWSER_CLIP_CAPABILITY_PREFIX,
+ BROWSER_CLIP_CAPABILITY_TTL_SECONDS,
+ BrowserClipCapabilityValidationError,
+ browser_clip_capability_hash,
+ browser_clip_url_origin,
+ consume_browser_clip_capability,
+ issue_browser_clip_capability,
+ normalize_browser_clip_origin,
+)
+
+
+class _MemoryCapabilityStore:
+ def __init__(self, *, now: datetime, return_iso_expiry: bool = False) -> None:
+ self.now = now
+ self.return_iso_expiry = return_iso_expiry
+ self.records: dict[str, dict[str, object]] = {}
+ self.create_calls: list[dict[str, object]] = []
+ self.consume_calls: list[dict[str, str]] = []
+
+ def create_browser_clip_capability(
+ self,
+ *,
+ capability_hash: str,
+ origin: str,
+ ttl_seconds: int,
+ ) -> dict[str, object]:
+ call = {
+ "capability_hash": capability_hash,
+ "origin": origin,
+ "ttl_seconds": ttl_seconds,
+ }
+ self.create_calls.append(call)
+ expires_at = self.now + timedelta(seconds=ttl_seconds)
+ row: dict[str, object] = {
+ "origin": origin,
+ "expires_at": expires_at,
+ "consumed_at": None,
+ }
+ self.records[capability_hash] = row
+ result = dict(row)
+ if self.return_iso_expiry:
+ result["expires_at"] = expires_at.isoformat().replace("+00:00", "Z")
+ return result
+
+ def consume_browser_clip_capability(
+ self,
+ *,
+ capability_hash: str,
+ origin: str,
+ ) -> dict[str, object] | None:
+ self.consume_calls.append({"capability_hash": capability_hash, "origin": origin})
+ row = self.records.get(capability_hash)
+ expires_at = row.get("expires_at") if row is not None else None
+ if (
+ row is None
+ or row["origin"] != origin
+ or row["consumed_at"] is not None
+ or not isinstance(expires_at, datetime)
+ or expires_at <= self.now
+ ):
+ return None
+ row["consumed_at"] = self.now
+ return dict(row)
+
+
+@pytest.mark.parametrize(
+ ("raw", "expected"),
+ [
+ ("HTTPS://ExAmPle.COM:443", "https://example.com"),
+ ("http://EXAMPLE.com:80", "http://example.com"),
+ ("https://BÜCHER.example:8443", "https://xn--bcher-kva.example:8443"),
+ ("https://BÜCHER.example.", "https://xn--bcher-kva.example."),
+ ("https://[2001:0DB8:0:0::1]:443", "https://[2001:db8::1]"),
+ ("http://[::1]:8080", "http://[::1]:8080"),
+ ("http://127.0.0.1:80", "http://127.0.0.1"),
+ ("http://127.0.0.1.:80", "http://127.0.0.1"),
+ ],
+)
+def test_normalize_browser_clip_origin_canonicalizes_browser_origins(raw: str, expected: str) -> None:
+ assert normalize_browser_clip_origin(raw) == expected
+
+
+@pytest.mark.parametrize(
+ "raw",
+ [
+ "",
+ "null",
+ "data:text/plain,hello",
+ "file:///tmp/capture",
+ "https://user@example.test",
+ "https://user:password@example.test",
+ "https://example.test/",
+ "https://example.test/path",
+ "https://example.test?",
+ "https://example.test?query=1",
+ "https://example.test#",
+ "https://example.test#fragment",
+ "https://example.test:",
+ "https://127.1",
+ "https://0x7f000001",
+ "https://[fe80::1%25en0]",
+ " https://example.test",
+ "https://example.test ",
+ "https://exa\nmple.test",
+ "https://exa\x00mple.test",
+ "https://exa\u200emple.test",
+ "https://exa\u00a0mple.test",
+ "https:\\example.test",
+ ],
+)
+def test_normalize_browser_clip_origin_rejects_non_origins_and_ambiguous_text(raw: str) -> None:
+ with pytest.raises(BrowserClipCapabilityValidationError):
+ normalize_browser_clip_origin(raw)
+
+
+def test_browser_clip_url_origin_allows_page_components_but_normalizes_only_the_origin() -> None:
+ assert (
+ browser_clip_url_origin("HTTPS://BÜCHER.example:443/articles/1?q=memory#selection")
+ == "https://xn--bcher-kva.example"
+ )
+
+
+@pytest.mark.parametrize(
+ "capture_url",
+ [
+ "null",
+ "blob:https://example.test/id",
+ "https://user:password@example.test/page",
+ "https://exa\nmple.test/page",
+ "https:\\example.test/page",
+ ],
+)
+def test_browser_clip_url_origin_rejects_opaque_credentialed_and_controlled_urls(capture_url: str) -> None:
+ with pytest.raises(BrowserClipCapabilityValidationError):
+ browser_clip_url_origin(capture_url)
+
+
+@pytest.mark.parametrize("return_iso_expiry", [False, True])
+def test_issue_uses_256_bit_opaque_token_hash_only_and_database_expiry(return_iso_expiry: bool) -> None:
+ database_now = datetime(2040, 2, 3, 4, 5, 6, tzinfo=UTC)
+ store = _MemoryCapabilityStore(now=database_now, return_iso_expiry=return_iso_expiry)
+
+ issued = issue_browser_clip_capability(store, origin="HTTPS://BÜCHER.example:443")
+
+ assert re.fullmatch(r"alice_clip_[A-Za-z0-9_-]{43}", issued.capability)
+ assert issued.origin == "https://xn--bcher-kva.example"
+ assert issued.expires_at == database_now + timedelta(seconds=BROWSER_CLIP_CAPABILITY_TTL_SECONDS)
+ assert store.create_calls == [
+ {
+ "capability_hash": sha256(issued.capability.encode("utf-8")).hexdigest(),
+ "origin": issued.origin,
+ "ttl_seconds": BROWSER_CLIP_CAPABILITY_TTL_SECONDS,
+ }
+ ]
+ assert issued.capability not in repr(store.create_calls)
+ assert issued.capability not in repr(store.records)
+ assert len(str(store.create_calls[0]["capability_hash"])) == 64
+
+
+def test_capability_can_be_redeemed_once_and_replay_and_tamper_fail() -> None:
+ store = _MemoryCapabilityStore(now=datetime(2040, 2, 3, tzinfo=UTC))
+ issued = issue_browser_clip_capability(store, origin="https://example.test")
+
+ replacement = "A" if issued.capability[-1] != "A" else "B"
+ tampered = f"{issued.capability[:-1]}{replacement}"
+ with pytest.raises(BrowserClipCapabilityValidationError):
+ consume_browser_clip_capability(
+ store,
+ capability=tampered,
+ capture_url="https://example.test/article",
+ request_origin="https://example.test",
+ )
+
+ redeemed = consume_browser_clip_capability(
+ store,
+ capability=issued.capability,
+ capture_url="https://example.test/article?id=1#selection",
+ request_origin="https://example.test",
+ )
+ assert redeemed["origin"] == "https://example.test"
+
+ with pytest.raises(BrowserClipCapabilityValidationError):
+ consume_browser_clip_capability(
+ store,
+ capability=issued.capability,
+ capture_url="https://example.test/article",
+ request_origin="https://example.test",
+ )
+
+
+def test_expired_capability_is_rejected_by_the_store() -> None:
+ store = _MemoryCapabilityStore(now=datetime(2040, 2, 3, tzinfo=UTC))
+ issued = issue_browser_clip_capability(store, origin="https://example.test")
+ store.now = issued.expires_at
+
+ with pytest.raises(BrowserClipCapabilityValidationError):
+ consume_browser_clip_capability(
+ store,
+ capability=issued.capability,
+ capture_url="https://example.test/article",
+ request_origin="https://example.test",
+ )
+
+
+def test_wrong_origin_attempts_do_not_consume_the_capability() -> None:
+ store = _MemoryCapabilityStore(now=datetime(2040, 2, 3, tzinfo=UTC))
+ issued = issue_browser_clip_capability(store, origin="https://example.test")
+ capability_hash = browser_clip_capability_hash(issued.capability)
+
+ with pytest.raises(BrowserClipCapabilityValidationError):
+ consume_browser_clip_capability(
+ store,
+ capability=issued.capability,
+ capture_url="https://attacker.test/article",
+ request_origin="https://attacker.test",
+ )
+ assert store.records[capability_hash]["consumed_at"] is None
+
+ calls_before_url_mismatch = len(store.consume_calls)
+ with pytest.raises(BrowserClipCapabilityValidationError, match="origin does not match"):
+ consume_browser_clip_capability(
+ store,
+ capability=issued.capability,
+ capture_url="https://attacker.test/article",
+ request_origin="https://example.test",
+ )
+ assert len(store.consume_calls) == calls_before_url_mismatch
+ assert store.records[capability_hash]["consumed_at"] is None
+
+ redeemed = consume_browser_clip_capability(
+ store,
+ capability=issued.capability,
+ capture_url="HTTPS://EXAMPLE.test:443/article",
+ request_origin="HTTPS://EXAMPLE.test:443",
+ )
+ assert redeemed["consumed_at"] == store.now
+
+
+@pytest.mark.parametrize(
+ "capability",
+ [
+ "",
+ BROWSER_CLIP_CAPABILITY_PREFIX,
+ f"{BROWSER_CLIP_CAPABILITY_PREFIX}{'A' * 42}",
+ f"{BROWSER_CLIP_CAPABILITY_PREFIX}{'A' * 44}",
+ f"{BROWSER_CLIP_CAPABILITY_PREFIX}{'A' * 42}=",
+ f"{BROWSER_CLIP_CAPABILITY_PREFIX}{'A' * 42}!",
+ f" {BROWSER_CLIP_CAPABILITY_PREFIX}{'A' * 43}",
+ f"{BROWSER_CLIP_CAPABILITY_PREFIX}{'A' * 43}\n",
+ f"other_clip_{'A' * 43}",
+ ],
+)
+def test_malformed_capability_is_rejected_without_touching_the_store(capability: str) -> None:
+ store = _MemoryCapabilityStore(now=datetime(2040, 2, 3, tzinfo=UTC))
+
+ with pytest.raises(BrowserClipCapabilityValidationError):
+ consume_browser_clip_capability(
+ store,
+ capability=capability,
+ capture_url="https://example.test/article",
+ request_origin="https://example.test",
+ )
+
+ assert store.consume_calls == []
+
+
+def test_missing_origin_is_rejected_without_touching_the_store() -> None:
+ store = _MemoryCapabilityStore(now=datetime(2040, 2, 3, tzinfo=UTC))
+ capability = f"{BROWSER_CLIP_CAPABILITY_PREFIX}{'A' * 43}"
+
+ with pytest.raises(BrowserClipCapabilityValidationError, match="origin is required"):
+ consume_browser_clip_capability(
+ store,
+ capability=capability,
+ capture_url="https://example.test/article",
+ request_origin=None,
+ )
+
+ assert store.consume_calls == []
diff --git a/tests/unit/test_browser_clip_capability_storage.py b/tests/unit/test_browser_clip_capability_storage.py
new file mode 100644
index 00000000..cf8a6932
--- /dev/null
+++ b/tests/unit/test_browser_clip_capability_storage.py
@@ -0,0 +1,215 @@
+from __future__ import annotations
+
+from concurrent.futures import ThreadPoolExecutor
+from datetime import datetime
+from hashlib import sha256
+import sqlite3
+from threading import Barrier
+from uuid import uuid4
+
+import pytest
+
+from alicebot_api.sqlite_schema import bootstrap_sqlite_schema
+from alicebot_api.sqlite_store import SQLiteVNextStore, ensure_sqlite_user, sqlite_user_connection
+
+
+ORIGIN = "https://docs.example.test"
+
+
+def _digest(raw_capability: str) -> str:
+ return sha256(raw_capability.encode("utf-8")).hexdigest()
+
+
+def _parse_sqlite_timestamp(value: object) -> datetime:
+ return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
+
+
+def _make_store(conn: sqlite3.Connection, user_id: str) -> SQLiteVNextStore:
+ ensure_sqlite_user(conn, user_id, f"{user_id}@example.test", "Clip User")
+ return SQLiteVNextStore(conn, user_id)
+
+
+def test_sqlite_capability_is_hash_only_tenant_bound_origin_bound_and_one_time() -> None:
+ conn = sqlite3.connect(":memory:")
+ bootstrap_sqlite_schema(conn)
+ owner_id = str(uuid4())
+ other_id = str(uuid4())
+ owner = _make_store(conn, owner_id)
+ other = _make_store(conn, other_id)
+ raw_capability = "alice_clip_this-raw-value-must-never-be-persisted"
+ capability_hash = _digest(raw_capability)
+
+ issued = owner.create_browser_clip_capability(
+ capability_hash=capability_hash,
+ origin=ORIGIN,
+ ttl_seconds=120,
+ )
+
+ assert set(issued) == {"id", "user_id", "origin", "expires_at", "consumed_at", "created_at"}
+ assert issued["user_id"] == owner_id
+ assert issued["origin"] == ORIGIN
+ assert issued["consumed_at"] is None
+ ttl = _parse_sqlite_timestamp(issued["expires_at"]) - _parse_sqlite_timestamp(issued["created_at"])
+ assert 119.9 <= ttl.total_seconds() <= 120.1
+
+ persisted = conn.execute(
+ "SELECT * FROM browser_clip_capabilities WHERE id = ?",
+ (issued["id"],),
+ ).fetchone()
+ assert persisted is not None
+ assert capability_hash in persisted
+ assert raw_capability not in repr(persisted)
+
+ assert (
+ other.consume_browser_clip_capability(
+ capability_hash=capability_hash,
+ origin=ORIGIN,
+ )
+ is None
+ )
+ assert (
+ owner.consume_browser_clip_capability(
+ capability_hash=capability_hash,
+ origin="https://other.example.test",
+ )
+ is None
+ )
+
+ redeemed = owner.consume_browser_clip_capability(
+ capability_hash=capability_hash,
+ origin=ORIGIN,
+ )
+ assert redeemed is not None
+ assert redeemed["consumed_at"] is not None
+ assert (
+ owner.consume_browser_clip_capability(
+ capability_hash=capability_hash,
+ origin=ORIGIN,
+ )
+ is None
+ )
+
+
+def test_sqlite_capability_rejects_expired_rows_and_out_of_range_ttls() -> None:
+ conn = sqlite3.connect(":memory:")
+ bootstrap_sqlite_schema(conn)
+ owner = _make_store(conn, str(uuid4()))
+ capability_hash = _digest("alice_clip_expired")
+ issued = owner.create_browser_clip_capability(
+ capability_hash=capability_hash,
+ origin=ORIGIN,
+ ttl_seconds=120,
+ )
+ conn.execute(
+ """
+ UPDATE browser_clip_capabilities
+ SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-2 minutes'),
+ expires_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-1 minute')
+ WHERE id = ?
+ """,
+ (issued["id"],),
+ )
+
+ assert (
+ owner.consume_browser_clip_capability(
+ capability_hash=capability_hash,
+ origin=ORIGIN,
+ )
+ is None
+ )
+ max_ttl = owner.create_browser_clip_capability(
+ capability_hash=_digest("alice_clip_max_ttl"),
+ origin=ORIGIN,
+ ttl_seconds=300,
+ )
+ max_ttl_delta = _parse_sqlite_timestamp(max_ttl["expires_at"]) - _parse_sqlite_timestamp(
+ max_ttl["created_at"]
+ )
+ assert 299.9 <= max_ttl_delta.total_seconds() <= 300.1
+ for ttl_seconds in (0, 301):
+ with pytest.raises(ValueError, match="TTL must be between 1 and 300 seconds"):
+ owner.create_browser_clip_capability(
+ capability_hash=_digest(f"alice_clip_bad_ttl_{ttl_seconds}"),
+ origin=ORIGIN,
+ ttl_seconds=ttl_seconds,
+ )
+
+ with pytest.raises(sqlite3.IntegrityError):
+ conn.execute(
+ """
+ INSERT INTO browser_clip_capabilities (
+ id, user_id, capability_hash, origin, expires_at, created_at
+ )
+ VALUES (?, ?, ?, ?, 'not-a-timestamp', 'not-a-timestamp')
+ """,
+ (str(uuid4()), owner.user_id, _digest("alice_clip_invalid_time"), ORIGIN),
+ )
+
+
+def test_sqlite_concurrent_double_redemption_commits_exactly_one_source(tmp_path) -> None:
+ database_path = tmp_path / "browser-clip.db"
+ owner_id = str(uuid4())
+ raw_capability = "alice_clip_concurrent-redemption"
+ capability_hash = _digest(raw_capability)
+ with sqlite_user_connection(database_path, owner_id) as conn:
+ conn.execute("PRAGMA journal_mode = WAL")
+ store = _make_store(conn, owner_id)
+ store.create_browser_clip_capability(
+ capability_hash=capability_hash,
+ origin=ORIGIN,
+ ttl_seconds=120,
+ )
+
+ start = Barrier(2)
+
+ def redeem(attempt: int) -> bool:
+ conn = sqlite3.connect(str(database_path), timeout=10)
+ conn.execute("PRAGMA busy_timeout = 10000")
+ try:
+ store = SQLiteVNextStore(conn, owner_id)
+ start.wait(timeout=5)
+ redeemed = store.consume_browser_clip_capability(
+ capability_hash=capability_hash,
+ origin=ORIGIN,
+ )
+ if redeemed is None:
+ conn.commit()
+ return False
+ store.create_source(
+ {
+ "source_type": "browser_clip",
+ "title": f"Concurrent clip {attempt}",
+ "uri": f"{ORIGIN}/guide",
+ "content_hash": "sha256:browser-clip-concurrent-redemption",
+ "connector_name": "browser_clipper",
+ "domain": "professional",
+ "sensitivity": "private",
+ "metadata_json": {"transport": "one_time_capability"},
+ },
+ actor_type="user",
+ )
+ conn.commit()
+ return True
+ except BaseException:
+ conn.rollback()
+ raise
+ finally:
+ conn.close()
+
+ with ThreadPoolExecutor(max_workers=2) as executor:
+ results = list(executor.map(redeem, (1, 2)))
+
+ assert sorted(results) == [False, True]
+ with sqlite_user_connection(database_path, owner_id) as conn:
+ assert (
+ conn.execute(
+ """
+ SELECT count(*)
+ FROM sources
+ WHERE user_id = ?
+ AND connector_name = 'browser_clipper'
+ """,
+ (owner_id,),
+ ).fetchone()["count(*)"]
+ == 1
+ )
diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py
index cffdd8ba..367a52b6 100644
--- a/tests/unit/test_config.py
+++ b/tests/unit/test_config.py
@@ -455,7 +455,6 @@ def test_core_only_production_settings_boot_without_s3_credentials(
"APP_ENV": "production",
"ALICEBOT_AUTH_USER_ID": "00000000-0000-4000-8000-000000000001",
"DATABASE_URL": "postgresql://secure-app:secret@db/alicebot",
- "DATABASE_ADMIN_URL": "postgresql://secure-admin:secret@db/alicebot",
"CORS_ALLOWED_ORIGINS": "https://alice.example",
**s3_environment,
}
@@ -464,3 +463,17 @@ def test_core_only_production_settings_boot_without_s3_credentials(
assert settings.app_env == "production"
assert settings.s3_access_key == "alicebot"
assert settings.s3_secret_key == "alicebot-secret"
+
+
+def test_production_api_settings_do_not_require_migration_database_url() -> None:
+ runtime_environment = {
+ "APP_ENV": "production",
+ "ALICEBOT_AUTH_USER_ID": "00000000-0000-4000-8000-000000000001",
+ "DATABASE_URL": "postgresql://alicebot_app:secret@db/alicebot",
+ "CORS_ALLOWED_ORIGINS": "https://alice.example",
+ }
+
+ settings = Settings.from_env(runtime_environment)
+
+ assert "DATABASE_ADMIN_URL" not in runtime_environment
+ assert settings.database_url == runtime_environment["DATABASE_URL"]
diff --git a/tests/unit/test_legacy_gated_router_split.py b/tests/unit/test_legacy_gated_router_split.py
index 55b98735..a19305c3 100644
--- a/tests/unit/test_legacy_gated_router_split.py
+++ b/tests/unit/test_legacy_gated_router_split.py
@@ -240,8 +240,8 @@
EXPECTED_OWNED_SUPPORT_AST_SHA256 = "8bbbcb0cf52c4bd1da5fce4e50878d8a62599be59ee25e09dcd905c94950d406"
EXPECTED_FULL_SUPPORT_AST_SHA256 = "07acb5deafb700e040c459969610e8877e86e62b04d4e9d86493fe314cb02868"
EXPECTED_GATED_OPERATION_SHA256 = "55490460fd78990a6872ebf22c6cfd927f7d0a5548b6df90adde8261ede8da65"
-EXPECTED_DEFAULT_DEEP_ROUTE_SHA256 = "524338f3f37e4673c6e1fa7bea72152edc157770e0435267d33971987c44c6f7"
-EXPECTED_LEGACY_DEEP_ROUTE_SHA256 = "a1f1816b7097297527e20fb9a96144ce3c41db7b84c31cf8aa966ecd090b31d4"
+EXPECTED_DEFAULT_DEEP_ROUTE_SHA256 = "a865b2264c911ed1b055853777b850e070bd594d79fafb167df7b915b7c3c9ce"
+EXPECTED_LEGACY_DEEP_ROUTE_SHA256 = "6743a8b03d649328341eab462aeea0271f997f14c5c558a666b5aff92f297d79"
EXPECTED_INTEGRATION_PATCH_COUNTS = {
"tests/integration/test_approval_api.py": 8,
@@ -667,9 +667,9 @@ def test_main_preserves_frozen_flag_policy_and_five_mount_seams() -> None:
def test_flagged_surface_preserves_deep_order_ids_and_import_timing() -> None:
default = _isolated_surface_manifest(None)
assert default == {
- "operation_count": 182,
+ "operation_count": 183,
"legacy_count": 0,
- "deep_count": 186,
+ "deep_count": 187,
"deep_digest": EXPECTED_DEFAULT_DEEP_ROUTE_SHA256,
"gated_count": 0,
"gated_digest": hashlib.sha256(b"[]").hexdigest(),
@@ -679,9 +679,9 @@ def test_flagged_surface_preserves_deep_order_ids_and_import_timing() -> None:
}
legacy = _isolated_surface_manifest("1")
assert legacy == {
- "operation_count": 231,
+ "operation_count": 232,
"legacy_count": 49,
- "deep_count": 235,
+ "deep_count": 236,
"deep_digest": EXPECTED_LEGACY_DEEP_ROUTE_SHA256,
"gated_count": 49,
"gated_digest": EXPECTED_GATED_OPERATION_SHA256,
diff --git a/tests/unit/test_legacy_surface_test_posture.py b/tests/unit/test_legacy_surface_test_posture.py
index e3d1b509..1208a769 100644
--- a/tests/unit/test_legacy_surface_test_posture.py
+++ b/tests/unit/test_legacy_surface_test_posture.py
@@ -68,9 +68,10 @@ def test_postgres_matrix_has_a_required_flag_off_default_surface_row() -> None:
"unset ALICE_LEGACY_SURFACES ALICE_MCP_LEGACY_TOOLS ALICE_AGENT_API_KEY"
) in integration_job
assert (
- "./.venv/bin/python -m pytest "
- "tests/integration/test_default_surface_integration.py -q -p no:cacheprovider "
- "--require-executed-tests"
+ "./.venv/bin/python -m pytest \\\n"
+ " tests/integration/test_default_surface_integration.py \\\n"
+ " tests/integration/test_openai_agents_sdk_tool.py \\\n"
+ " -q -p no:cacheprovider --require-executed-tests"
) in integration_job
assert "ALICE_LEGACY_SURFACES:" not in integration_job.split(" steps:", 1)[0]
diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py
index 2ead8d71..2b76e46c 100644
--- a/tests/unit/test_main.py
+++ b/tests/unit/test_main.py
@@ -312,7 +312,7 @@ def test_openapi_has_concrete_success_contracts_and_accurate_statuses() -> None:
}
operations = list(operations_by_key.values())
- assert len(operations) == 182
+ assert len(operations) == 183
assert all(operation.get("tags") for operation in operations)
assert all(operation.get("description") for operation in operations)
assert all("default" in operation["responses"] for operation in operations)
@@ -323,7 +323,7 @@ def test_openapi_has_concrete_success_contracts_and_accurate_statuses() -> None:
if status.startswith("2")
for json_body in [response.get("content", {}).get("application/json", {})]
]
- assert len(success_schemas) == 186
+ assert len(success_schemas) == 187
assert "APIJsonDocument" not in components
assert all(document.get("$ref", "").startswith("#/components/schemas/") for document in success_schemas)
resolved_success_schemas = [components[document["$ref"].rsplit("/", 1)[-1]] for document in success_schemas]
@@ -347,7 +347,7 @@ def test_openapi_has_concrete_success_contracts_and_accurate_statuses() -> None:
assert exact_keys | set(operation_registry) == set(operations_by_key), coverage_report
assert exact_keys.isdisjoint(operation_registry), coverage_report
assert len(exact_keys) == 42
- assert len(operation_registry) == 140
+ assert len(operation_registry) == 141
assert 0 < len(polymorphic_operations) <= 3
assert set(polymorphic_operations) <= set(operation_registry)
assert all(reason.strip() for reason in polymorphic_operations.values())
@@ -380,6 +380,26 @@ def test_openapi_has_concrete_success_contracts_and_accurate_statuses() -> None:
assert broad_components.isdisjoint(referenced_component_names)
assert broad_components.isdisjoint(all_response_component_names)
+ clip_request_properties = components["VNextBrowserClipperCaptureRequest"]["properties"]
+ assert clip_request_properties["capture_capability"]["writeOnly"] is True
+ clip_request_content = operations_by_key[
+ ("POST", "/v0/vnext/connectors/browser-clipper/capture")
+ ]["requestBody"]["content"]
+ assert set(clip_request_content) == {"application/json", "text/plain"}
+ assert clip_request_content["text/plain"]["schema"] == {
+ "type": "string",
+ "contentMediaType": "application/json",
+ "description": (
+ "JSON-encoded VNextBrowserClipperCaptureRequest used by the "
+ "CORS-safelisted one-time bookmarklet transport."
+ ),
+ }
+ capability_response = components["CreateVnextBrowserClipCapabilitySuccessResponse"]
+ assert capability_response["properties"]["capability"]["readOnly"] is True
+ assert "example" not in json.dumps(
+ operations_by_key[("POST", "/v0/vnext/connectors/browser-clipper/capabilities")]
+ ).casefold()
+
registry_component_names: list[str] = []
for operation_key, (component_name, registered_schema) in operation_registry.items():
registry_component_names.append(component_name)
@@ -1219,6 +1239,163 @@ def test_rewrite_user_id_json_body_rejects_mismatch() -> None:
asyncio.run(main_module._rewrite_user_id_json_body(request, uuid4()))
+def test_browser_clip_simple_transport_is_bounded_and_rewritten_to_json(monkeypatch) -> None:
+ user_id = str(uuid4())
+ raw_body = json.dumps(
+ {
+ "user_id": user_id,
+ "url": "https://example.test/article",
+ "selected_text": "Bounded simple request.",
+ "capture_capability": f"alice_clip_{'A' * 43}",
+ }
+ ).encode("utf-8")
+ request = _build_request(
+ method="POST",
+ path="/v0/vnext/connectors/browser-clipper/capture",
+ body=raw_body,
+ headers={"content-type": "text/plain;charset=UTF-8"},
+ )
+
+ rewritten, payload = asyncio.run(main_module._prepare_browser_clip_simple_request(request))
+
+ assert payload is not None and payload["user_id"] == user_id
+ assert rewritten.headers["content-type"] == "application/json"
+ assert asyncio.run(rewritten.body()) == raw_body
+
+ monkeypatch.setattr(main_module, "_BROWSER_CLIP_SIMPLE_BODY_MAX_BYTES", len(raw_body) - 1)
+ oversized = _build_request(
+ method="POST",
+ path="/v0/vnext/connectors/browser-clipper/capture",
+ body=raw_body,
+ headers={"content-type": "text/plain;charset=UTF-8"},
+ )
+ with pytest.raises(ValueError, match="too large"):
+ asyncio.run(main_module._prepare_browser_clip_simple_request(oversized))
+
+
+def test_browser_clip_simple_transport_stops_buffering_at_limit(monkeypatch) -> None:
+ monkeypatch.setattr(main_module, "_BROWSER_CLIP_SIMPLE_BODY_MAX_BYTES", 8)
+ received_chunks = 0
+ handler_called = False
+ chunks = (
+ {"type": "http.request", "body": b'{"cap":', "more_body": True},
+ {"type": "http.request", "body": b'"too-large', "more_body": True},
+ {"type": "http.request", "body": b'-never-read"}', "more_body": False},
+ )
+ base_request = _build_request(
+ method="POST",
+ path="/v0/vnext/connectors/browser-clipper/capture",
+ headers={"content-type": "text/plain;charset=UTF-8"},
+ )
+
+ async def receive() -> dict[str, object]:
+ nonlocal received_chunks
+ message = chunks[received_chunks]
+ received_chunks += 1
+ return message
+
+ async def call_next(_: Request) -> Response:
+ nonlocal handler_called
+ handler_called = True
+ return Response(status_code=204)
+
+ request = Request(base_request.scope, receive)
+ response = asyncio.run(main_module._vnext_protected_http_auth(request, call_next))
+
+ assert response.status_code == 400
+ assert received_chunks == 2
+ assert handler_called is False
+
+
+def test_browser_clip_simple_transport_rejects_declared_oversize_before_read(monkeypatch) -> None:
+ monkeypatch.setattr(main_module, "_BROWSER_CLIP_SIMPLE_BODY_MAX_BYTES", 8)
+ receive_called = False
+ base_request = _build_request(
+ method="POST",
+ path="/v0/vnext/connectors/browser-clipper/capture",
+ headers={
+ "content-type": "text/plain;charset=UTF-8",
+ "content-length": "9",
+ },
+ )
+
+ async def receive() -> dict[str, object]:
+ nonlocal receive_called
+ receive_called = True
+ return {"type": "http.request", "body": b"oversized", "more_body": False}
+
+ request = Request(base_request.scope, receive)
+ with pytest.raises(ValueError, match="too large"):
+ asyncio.run(main_module._prepare_browser_clip_simple_request(request))
+
+ assert receive_called is False
+
+
+@pytest.mark.parametrize(
+ "payload",
+ (
+ [],
+ {"user_id": "00000000-0000-0000-0000-000000000001"},
+ {"capture_capability": ""},
+ ),
+)
+def test_browser_clip_simple_transport_rejects_non_object_or_missing_capability(payload: object) -> None:
+ request = _build_request(
+ method="POST",
+ path="/v0/vnext/connectors/browser-clipper/capture",
+ body=json.dumps(payload).encode("utf-8"),
+ headers={"content-type": "text/plain;charset=UTF-8"},
+ )
+
+ with pytest.raises(ValueError):
+ asyncio.run(main_module._prepare_browser_clip_simple_request(request))
+
+
+def test_vnext_capability_auth_exception_is_confined_to_capture_route(monkeypatch) -> None:
+ user_id = str(uuid4())
+ capability = f"alice_clip_{'A' * 43}"
+ auth_calls: list[str] = []
+
+ def fake_resolve_vnext_http_auth(**kwargs):
+ auth_calls.append(str(kwargs["route_path"]))
+ return None, None
+
+ async def call_next(_: Request) -> Response:
+ return Response(status_code=204)
+
+ monkeypatch.setattr(main_module, "get_settings", lambda: Settings(database_url="postgresql://db"))
+ monkeypatch.setattr(main_module, "_resolve_vnext_http_auth", fake_resolve_vnext_http_auth)
+ capture = _build_request(
+ method="POST",
+ path="/v0/vnext/connectors/browser-clipper/capture",
+ body=json.dumps(
+ {
+ "user_id": user_id,
+ "url": "https://example.test/article",
+ "capture_capability": capability,
+ }
+ ).encode("utf-8"),
+ headers={"content-type": "application/json", "origin": "https://example.test"},
+ )
+ other = _build_request(
+ method="POST",
+ path="/v0/vnext/sources",
+ body=json.dumps(
+ {
+ "user_id": user_id,
+ "raw_text": "The capability must not authorize this route.",
+ "capture_capability": capability,
+ }
+ ).encode("utf-8"),
+ headers={"content-type": "application/json"},
+ )
+
+ assert asyncio.run(main_module._vnext_protected_http_auth(capture, call_next)).status_code == 204
+ assert auth_calls == []
+ assert asyncio.run(main_module._vnext_protected_http_auth(other, call_next)).status_code == 204
+ assert auth_calls == ["/v0/vnext/sources"]
+
+
def test_request_client_identifier_ignores_forwarded_header_when_proxy_not_trusted() -> None:
retained_path = "/v1/continuity/brief"
assert retained_path in _registered_route_paths()
diff --git a/tests/unit/test_memories_legacy_router_split.py b/tests/unit/test_memories_legacy_router_split.py
index ef17eeb3..56a08d8e 100644
--- a/tests/unit/test_memories_legacy_router_split.py
+++ b/tests/unit/test_memories_legacy_router_split.py
@@ -328,7 +328,9 @@ def test_direct_unit_monkeypatches_follow_moved_definition_ownership() -> None:
},
"tests/unit/test_main.py": {
"main_module": {
- "get_settings": 4,
+ "_BROWSER_CLIP_SIMPLE_BODY_MAX_BYTES": 3,
+ "_resolve_vnext_http_auth": 1,
+ "get_settings": 5,
"ping_database": 2,
},
"providers_router": {
diff --git a/tests/unit/test_phase5_enterprise_handoff_truth.py b/tests/unit/test_phase5_enterprise_handoff_truth.py
new file mode 100644
index 00000000..597aeb67
--- /dev/null
+++ b/tests/unit/test_phase5_enterprise_handoff_truth.py
@@ -0,0 +1,805 @@
+from __future__ import annotations
+
+from collections.abc import Callable
+import hashlib
+import json
+import os
+from pathlib import Path
+import re
+import stat
+import subprocess
+import tomllib
+
+import pytest
+
+
+ROOT = Path(__file__).resolve().parents[2]
+BASE = "c9d24243920a694eaf00ad595da392a1478710dd"
+BASE_TREE = "ecc16a53f580308959e97e8b1f02edd04bbe3bfc"
+FORMAT = "alice-v0.14.0-phase5-enterprise-track-explicit-carrier-v1"
+HANDOFF_REL = "docs/handoff/2026-07-21-v0.14.0-phase5-enterprise-track"
+HANDOFF = ROOT / HANDOFF_REL
+CARRIER_PATHS = (
+ ".github/workflows/deployment-guide-smoke.yml",
+ ".github/workflows/ops-evidence.yml",
+ ".github/workflows/tests.yml",
+ ".gitignore",
+ "Makefile",
+ "README.md",
+ "SECURITY.md",
+ "apps/api/alembic/versions/20260721_0094_browser_clip_capabilities.py",
+ "apps/api/src/alicebot_api/browser_clip_capabilities.py",
+ "apps/api/src/alicebot_api/config.py",
+ "apps/api/src/alicebot_api/main.py",
+ "apps/api/src/alicebot_api/openapi_operation_contracts.py",
+ "apps/api/src/alicebot_api/routers/vnext_memories.py",
+ "apps/api/src/alicebot_api/sqlite_schema.py",
+ "apps/api/src/alicebot_api/sqlite_store.py",
+ "apps/api/src/alicebot_api/vnext_connectors.py",
+ "apps/api/src/alicebot_api/vnext_secrets.py",
+ "apps/api/src/alicebot_api/vnext_store.py",
+ "apps/api/src/alicebot_api/vnext_stores/postgres/browser_clip_capabilities.py",
+ "apps/api/src/alicebot_api/vnext_stores/sqlite/browser_clip_capabilities.py",
+ "apps/web/app/approvals/loading.tsx",
+ "apps/web/app/approvals/page.test.tsx",
+ "apps/web/app/approvals/page.tsx",
+ "apps/web/app/artifacts/loading.tsx",
+ "apps/web/app/artifacts/page.test.tsx",
+ "apps/web/app/artifacts/page.tsx",
+ "apps/web/app/entities/loading.tsx",
+ "apps/web/app/entities/page.test.tsx",
+ "apps/web/app/entities/page.tsx",
+ "apps/web/app/globals.css",
+ "apps/web/app/memories/loading.tsx",
+ "apps/web/app/memories/page.test.tsx",
+ "apps/web/app/memories/page.tsx",
+ "apps/web/app/traces/loading.tsx",
+ "apps/web/app/traces/page.test.tsx",
+ "apps/web/components/approval-detail.tsx",
+ "apps/web/components/approval-list.tsx",
+ "apps/web/components/artifact-chunk-list.tsx",
+ "apps/web/components/artifact-detail.tsx",
+ "apps/web/components/artifact-list.tsx",
+ "apps/web/components/browser-clipper.test.ts",
+ "apps/web/components/entity-detail.tsx",
+ "apps/web/components/entity-edge-list.tsx",
+ "apps/web/components/entity-list.tsx",
+ "apps/web/components/memory-label-list.tsx",
+ "apps/web/components/memory-review-lists.test.tsx",
+ "apps/web/components/memory-revision-list.tsx",
+ "apps/web/components/trace-list.tsx",
+ "apps/web/components/vnext-brain-workspace.tsx",
+ "apps/web/components/vnext-operator-auth.test.tsx",
+ "apps/web/components/vnext-workspace-model.ts",
+ "apps/web/lib/api.test.ts",
+ "apps/web/lib/api.ts",
+ "apps/web/test/browser/navigation.spec.ts",
+ "apps/web/test/browser/review-dashboard-demo.spec.ts",
+ "docs/alpha/README.md",
+ "docs/alpha/agent-integration.md",
+ "docs/alpha/backup-and-restore.md",
+ "docs/alpha/demo-mode.md",
+ "docs/alpha/first-run.md",
+ "docs/alpha/headless-ubuntu-install.md",
+ "docs/alpha/known-limitations.md",
+ "docs/alpha/quickstart.md",
+ "docs/alpha/review-dashboard-demo.md",
+ "docs/alpha/security-and-privacy.md",
+ "docs/deployment/single-tenant-self-hosted.md",
+ f"{HANDOFF_REL}/ENGINEER_HANDOFF.md",
+ f"{HANDOFF_REL}/FIX_MATRIX.md",
+ f"{HANDOFF_REL}/README.md",
+ "docs/runbooks/disaster-recovery.md",
+ "docs/runbooks/health-and-monitoring.md",
+ "docs/runbooks/upgrade-v0.12-to-current.md",
+ "docs/runbooks/vnext-dogfood-daily-checklist.md",
+ "docs/security/README.md",
+ "docs/security/auth-authorization.md",
+ "docs/security/dependency-posture.md",
+ "docs/security/external-review-brief.md",
+ "docs/security/input-validation.md",
+ "docs/security/secrets-redaction.md",
+ "docs/security/stage-a-evidence.md",
+ "docs/security/threat-model.md",
+ "docs/vnext/architecture.md",
+ "docs/vnext/security-privacy.md",
+ "packaging/cloud/Caddyfile.example",
+ "packaging/cloud/single-tenant.env.example",
+ "scripts/_phase5_ops_seed.py",
+ "scripts/migrate.sh",
+ "scripts/run_phase5_ops_evidence.py",
+ "scripts/run_single_tenant_deployment_smoke.py",
+ "tests/integration/test_browser_clip_capabilities.py",
+ "tests/integration/test_default_surface_integration.py",
+ "tests/integration/test_migrations.py",
+ "tests/integration/test_provider_runtime_api.py",
+ "tests/integration/test_review_dashboard_demo.py",
+ "tests/integration/test_stage_a_agent_key_isolation.py",
+ "tests/unit/test_20260721_0094_browser_clip_capabilities.py",
+ "tests/unit/test_browser_clip_capabilities.py",
+ "tests/unit/test_browser_clip_capability_storage.py",
+ "tests/unit/test_config.py",
+ "tests/unit/test_legacy_gated_router_split.py",
+ "tests/unit/test_legacy_surface_test_posture.py",
+ "tests/unit/test_main.py",
+ "tests/unit/test_memories_legacy_router_split.py",
+ "tests/unit/test_phase5_enterprise_handoff_truth.py",
+ "tests/unit/test_phase5_ops_evidence.py",
+ "tests/unit/test_providers_router_split.py",
+ "tests/unit/test_runnable_docs_secret_argv.py",
+ "tests/unit/test_single_tenant_deployment.py",
+ "tests/unit/test_sqlite_store.py",
+ "tests/unit/test_stage_a_vnext_auth_surface.py",
+ "tests/unit/test_store_events_revisions_split.py",
+ "tests/unit/test_store_graph_open_loops_split.py",
+ "tests/unit/test_store_memory_access_split.py",
+ "tests/unit/test_store_memory_lifecycle_split.py",
+ "tests/unit/test_surface_gates.py",
+ "tests/unit/test_vnext_agent_keys.py",
+ "tests/unit/test_vnext_connectors.py",
+ "tests/unit/test_vnext_main.py",
+ "tests/unit/test_vnext_production_proxy_auth.py",
+ "tests/unit/test_vnext_release_polish.py",
+ "tests/unit/test_vnext_secrets.py",
+ "tests/unit/test_workspaces_router_split.py",
+)
+RECEIPT_EXCLUSIONS = (
+ f"{HANDOFF_REL}/BUILD_REPORT.md",
+ f"{HANDOFF_REL}/REVIEW_REPORT.md",
+)
+PROTECTED_SQLITE_PATH = "apps/api/src/alicebot_api/vnext_stores/sqlite/memory_access.py"
+Record = tuple[bytes, bytes, bytes]
+RecordReader = Callable[[str], Record]
+
+
+def _git(*arguments: str, check: bool = True) -> subprocess.CompletedProcess[bytes]:
+ return subprocess.run(
+ ("git", "-C", str(ROOT), *arguments),
+ check=check,
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ )
+
+
+def _base_history_available() -> bool:
+ probe = _git("cat-file", "-e", f"{BASE}^{{commit}}", check=False)
+ if probe.returncode == 0:
+ return True
+ shallow = _git("rev-parse", "--is-shallow-repository", check=False)
+ assert shallow.returncode == 0 and shallow.stdout.strip() == b"true", (
+ "phase 5 base commit is missing from a checkout that is not verified shallow"
+ )
+ return False
+
+
+def _require_full_git_history() -> None:
+ if not _base_history_available():
+ pytest.skip(
+ "phase 5 base commit unavailable in this checkout (shallow CI "
+ "clone); the full-history ops workflow owns ancestry assertions"
+ )
+
+
+def _field(name: bytes, value: bytes) -> bytes:
+ return name + b"\0" + len(value).to_bytes(8, "big") + value
+
+
+def _base_mode(relative_path: str) -> bytes:
+ entry = _git("ls-tree", "-z", BASE, "--", relative_path).stdout
+ assert entry, relative_path
+ return entry.split(b" ", 1)[0]
+
+
+def _read_live_record(
+ relative_path: str,
+ *,
+ root: Path = ROOT,
+ base_mode_reader: Callable[[str], bytes] = _base_mode,
+) -> Record:
+ parts = relative_path.encode("utf-8").split(b"/")
+ assert parts and all(part not in {b"", b".", b".."} for part in parts)
+ nofollow = getattr(os, "O_NOFOLLOW", None)
+ directory = getattr(os, "O_DIRECTORY", None)
+ assert nofollow is not None and directory is not None
+ try:
+ parent_fd = os.open(root, os.O_RDONLY | directory | nofollow)
+ except OSError as exc:
+ raise AssertionError(f"unsafe carrier root: {root}") from exc
+ try:
+ for component in parts[:-1]:
+ try:
+ component_info = os.stat(
+ component,
+ dir_fd=parent_fd,
+ follow_symlinks=False,
+ )
+ except FileNotFoundError:
+ return base_mode_reader(relative_path), b"deleted", b""
+ assert stat.S_ISDIR(component_info.st_mode), (
+ f"unsafe carrier parent component: {relative_path}"
+ )
+ try:
+ child_fd = os.open(
+ component,
+ os.O_RDONLY | directory | nofollow,
+ dir_fd=parent_fd,
+ )
+ except OSError as exc:
+ raise AssertionError(
+ f"carrier parent changed during receipt: {relative_path}"
+ ) from exc
+ child_info = os.fstat(child_fd)
+ try:
+ assert stat.S_ISDIR(child_info.st_mode)
+ assert (component_info.st_dev, component_info.st_ino) == (
+ child_info.st_dev,
+ child_info.st_ino,
+ ), f"carrier parent changed during receipt: {relative_path}"
+ except BaseException:
+ os.close(child_fd)
+ raise
+ os.close(parent_fd)
+ parent_fd = child_fd
+
+ file_name = parts[-1]
+ try:
+ info = os.stat(file_name, dir_fd=parent_fd, follow_symlinks=False)
+ except FileNotFoundError:
+ return base_mode_reader(relative_path), b"deleted", b""
+ if stat.S_ISLNK(info.st_mode):
+ target = os.readlink(file_name, dir_fd=parent_fd)
+ return b"120000", b"symlink", (
+ target if isinstance(target, bytes) else os.fsencode(target)
+ )
+ assert stat.S_ISREG(info.st_mode), (
+ f"unsupported carrier entry type: {relative_path}"
+ )
+ try:
+ descriptor = os.open(file_name, os.O_RDONLY | nofollow, dir_fd=parent_fd)
+ except OSError as exc:
+ raise AssertionError(
+ f"carrier entry changed during receipt: {relative_path}"
+ ) from exc
+ try:
+ before = os.fstat(descriptor)
+ assert stat.S_ISREG(before.st_mode)
+ assert (info.st_dev, info.st_ino) == (before.st_dev, before.st_ino), (
+ f"carrier entry changed during receipt: {relative_path}"
+ )
+ chunks: list[bytes] = []
+ while True:
+ chunk = os.read(descriptor, 1024 * 1024)
+ if not chunk:
+ break
+ chunks.append(chunk)
+ after = os.fstat(descriptor)
+ finally:
+ os.close(descriptor)
+ stable = ("st_dev", "st_ino", "st_mode", "st_size", "st_mtime_ns", "st_ctime_ns")
+ assert all(getattr(before, key) == getattr(after, key) for key in stable), (
+ relative_path
+ )
+ mode = b"100755" if before.st_mode & stat.S_IXUSR else b"100644"
+ return mode, b"regular", b"".join(chunks)
+ finally:
+ os.close(parent_fd)
+
+
+def _read_commit_record(commit: str, relative_path: str) -> Record:
+ entry = _git("ls-tree", "-z", commit, "--", relative_path).stdout
+ if not entry:
+ return _base_mode(relative_path), b"deleted", b""
+ metadata, actual_path = entry.rstrip(b"\0").split(b"\t", 1)
+ mode, object_type, _object_id = metadata.split(b" ", 2)
+ assert object_type == b"blob" and actual_path.decode() == relative_path
+ payload = _git("show", f"{commit}:{relative_path}").stdout
+ return mode, b"symlink" if mode == b"120000" else b"regular", payload
+
+
+def _build_receipt(reader: RecordReader) -> bytes:
+ parts = (
+ _field(b"format", FORMAT.encode()),
+ _field(b"base", BASE.encode()),
+ _field(b"base_tree", BASE_TREE.encode()),
+ )
+ output = bytearray(b"".join(parts))
+ for relative_path in CARRIER_PATHS:
+ mode, kind, payload = reader(relative_path)
+ output.extend(_field(b"path", relative_path.encode()))
+ output.extend(_field(b"mode", mode))
+ output.extend(_field(b"kind", kind))
+ output.extend(_field(b"sha256", hashlib.sha256(payload).hexdigest().encode()))
+ return bytes(output)
+
+
+def build_live_receipt() -> bytes:
+ return _build_receipt(_read_live_record)
+
+
+def _receipt_from_report(text: str) -> str:
+ match = re.search(r"carrier receipt sha256:\s*`([0-9a-f]{64})`", text)
+ assert match is not None, "BUILD_REPORT.md lacks the frozen carrier receipt"
+ return match.group(1)
+
+
+def _carrier_paths_from_report(text: str) -> tuple[str, ...]:
+ match = re.search(
+ r"Receipt-listed paths \(122, bytewise sorted\):\n\n```text\n(.*?)\n```",
+ text,
+ flags=re.DOTALL,
+ )
+ assert match is not None, "BUILD_REPORT.md lacks the explicit carrier path list"
+ return tuple(match.group(1).splitlines())
+
+
+def _live_dirty_paths() -> set[str]:
+ changed = _git("diff", "--name-only", "-z", BASE, "--").stdout.split(b"\0")
+ untracked = _git("ls-files", "--others", "--exclude-standard", "-z").stdout.split(b"\0")
+ return {
+ path.decode("utf-8", errors="surrogateescape")
+ for path in (*changed, *untracked)
+ if path
+ }
+
+
+def _worktree_delta_paths() -> set[str]:
+ changed = _git("diff", "--name-only", "-z", "HEAD", "--").stdout.split(b"\0")
+ untracked = _git("ls-files", "--others", "--exclude-standard", "-z").stdout.split(
+ b"\0"
+ )
+ return {
+ path.decode("utf-8", errors="surrogateescape")
+ for path in (*changed, *untracked)
+ if path
+ }
+
+
+def _receipt_scoped_delta_paths(paths: set[str]) -> set[str]:
+ return paths & (set(CARRIER_PATHS) | set(RECEIPT_EXCLUSIONS))
+
+
+def _carrier_commit(receipt: str) -> str | None:
+ commits = _git(
+ "log",
+ "--format=%H",
+ "--fixed-strings",
+ f"--grep=Alice-Carrier-Receipt-SHA256: {receipt}",
+ "HEAD",
+ check=False,
+ )
+ if commits.returncode != 0:
+ return None
+ matches = commits.stdout.decode().split()
+ assert len(matches) <= 1, f"duplicate carrier receipt commits: {matches}"
+ return matches[0] if matches else None
+
+
+def _assert_carrier_directly_descends_from_base(carrier: str) -> None:
+ carrier_line = (
+ _git("rev-list", "--parents", "-n", "1", carrier).stdout.decode().split()
+ )
+ assert carrier_line == [carrier, BASE], (
+ "the replacement carrier must be one fresh commit directly on the Phase 5 base"
+ )
+
+
+def _assert_commit_report_receipts(commit: str, receipt: str) -> None:
+ message = _git("show", "-s", "--format=%B", commit).stdout.decode()
+ assert f"Alice-Carrier-Receipt-SHA256: {receipt}" in message
+ for filename, trailer in (
+ ("BUILD_REPORT.md", "Alice-Build-Report-SHA256"),
+ ("REVIEW_REPORT.md", "Alice-Review-Report-SHA256"),
+ ):
+ payload = _git("show", f"{commit}:{HANDOFF_REL}/{filename}").stdout
+ expected = hashlib.sha256(payload).hexdigest()
+ assert f"{trailer}: {expected}" in message
+
+
+def _assert_integrated_handoff_immutable(carrier: str, *, head: str = "HEAD") -> None:
+ handoff_diff = _git(
+ "diff",
+ "--quiet",
+ carrier,
+ head,
+ "--",
+ HANDOFF_REL,
+ check=False,
+ )
+ assert handoff_diff.returncode == 0, (
+ "the integrated Phase 5 handoff changed after its receipt-trailed carrier commit"
+ )
+ for filename in ("BUILD_REPORT.md", "REVIEW_REPORT.md"):
+ relative_path = f"{HANDOFF_REL}/{filename}"
+ carrier_payload = _git("show", f"{carrier}:{relative_path}").stdout
+ head_payload = _git("show", f"{head}:{relative_path}").stdout
+ assert head_payload == carrier_payload, f"post-carrier report drift: {filename}"
+
+
+def _assert_integrated_carrier_scope_immutable(
+ carrier: str,
+ *,
+ head: str = "HEAD",
+) -> None:
+ carrier_diff = _git(
+ "diff",
+ "--quiet",
+ carrier,
+ head,
+ "--",
+ *CARRIER_PATHS,
+ check=False,
+ )
+ assert carrier_diff.returncode == 0, (
+ "receipt-listed carrier content changed after the reviewed carrier commit"
+ )
+ protected_diff = _git(
+ "diff",
+ "--quiet",
+ BASE,
+ head,
+ "--",
+ PROTECTED_SQLITE_PATH,
+ check=False,
+ )
+ assert protected_diff.returncode == 0, (
+ "the protected SQLite memory-access path changed during Phase 5"
+ )
+
+
+def _is_ignored(relative_path: str) -> bool:
+ result = _git("check-ignore", "--no-index", "--quiet", relative_path, check=False)
+ assert result.returncode in {0, 1}, result.stderr
+ return result.returncode == 0
+
+
+def test_phase5_handoff_package_and_owner_boundaries() -> None:
+ assert {path.name for path in HANDOFF.iterdir()} == {
+ "README.md",
+ "FIX_MATRIX.md",
+ "BUILD_REPORT.md",
+ "ENGINEER_HANDOFF.md",
+ "REVIEW_REPORT.md",
+ }
+ documents = {
+ name: (HANDOFF / name).read_text(encoding="utf-8")
+ for name in (
+ "README.md",
+ "FIX_MATRIX.md",
+ "BUILD_REPORT.md",
+ "ENGINEER_HANDOFF.md",
+ "REVIEW_REPORT.md",
+ )
+ }
+ review = documents["REVIEW_REPORT.md"]
+ assert "Code carrier" in review
+ assert "Phase 5 completion" in review
+ assert "5.4 OWNER GATE OPEN" in review
+ assert "COMMITTED-SHA CI GATE OPEN" in review
+ assert "no remaining P0-P3" in review
+ for name in documents:
+ normalized = " ".join(documents[name].split())
+ lowered = normalized.lower()
+ assert "NO-GO pending the 5.4 owner gate and green committed-SHA CI" in normalized, name
+ assert "Stage A" in normalized, name
+ assert "Stage B" in normalized, name
+ assert "automated security scanning under OpenAI Trusted Access" in normalized, name
+ assert "plus internal adversarial review" in normalized, name
+ assert "5.1.c OWNER GATE OPEN" not in normalized, name
+ assert "has been independently audited" not in lowered, name
+ assert "external audit completed" not in lowered, name
+ assert "5.4 OWNER GATE OPEN" in normalized, name
+ assert "COMMITTED-SHA CI GATE OPEN" in normalized, name
+
+
+def test_phase5_ops_workflow_runs_truth_guard_with_full_history() -> None:
+ workflow = (ROOT / ".github/workflows/ops-evidence.yml").read_text(encoding="utf-8")
+ assert (
+ " - name: Checkout full history\n"
+ " uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7\n"
+ " with:\n"
+ " fetch-depth: 0\n"
+ ) in workflow
+ assert (
+ " - name: Run evidence contract tests\n"
+ " run: >-\n"
+ " ./.venv/bin/python -m pytest\n"
+ " tests/unit/test_phase5_ops_evidence.py\n"
+ " tests/unit/test_phase5_enterprise_handoff_truth.py\n"
+ " -q -p no:cacheprovider\n"
+ ) in workflow
+ assert ".github/workflows/ops-evidence.yml" in CARRIER_PATHS
+ assert "tests/unit/test_phase5_enterprise_handoff_truth.py" in CARRIER_PATHS
+
+
+def test_phase5_versions_and_protected_scope_are_bound_to_the_carrier() -> None:
+ python_version = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))[
+ "project"
+ ]["version"]
+ web_version = json.loads((ROOT / "apps/web/package.json").read_text(encoding="utf-8"))[
+ "version"
+ ]
+ assert python_version == web_version
+ assert "pyproject.toml" not in CARRIER_PATHS
+ assert "apps/web/package.json" not in CARRIER_PATHS
+ assert PROTECTED_SQLITE_PATH not in CARRIER_PATHS
+ assert all(not path.startswith("docs/release/") for path in CARRIER_PATHS)
+ assert _git("diff", "--quiet", "HEAD", "--", "docs/release").returncode == 0
+ assert _git("diff", "--quiet", "HEAD", "--", PROTECTED_SQLITE_PATH).returncode == 0
+
+
+def test_phase5_base_tree_and_historical_scope_are_bound_to_the_carrier() -> None:
+ _require_full_git_history()
+ assert _git("rev-parse", f"{BASE}^{{tree}}").stdout.decode().strip() == BASE_TREE
+ head = _git("rev-parse", "HEAD").stdout.decode().strip()
+ if head == BASE:
+ python_version = tomllib.loads(
+ (ROOT / "pyproject.toml").read_text(encoding="utf-8")
+ )["project"]["version"]
+ assert python_version == "0.13.1"
+ assert _git("diff", "--quiet", BASE, "--", "docs/release").returncode == 0
+ assert _git("diff", "--quiet", BASE, "--", PROTECTED_SQLITE_PATH).returncode == 0
+ prior_handoff_changes = (
+ _git("diff", "--name-only", BASE, "--", "docs/handoff")
+ .stdout.decode()
+ .splitlines()
+ )
+ assert all(path.startswith(f"{HANDOFF_REL}/") for path in prior_handoff_changes)
+
+
+def test_phase5_history_probe_degrades_only_for_a_verified_shallow_clone(
+ monkeypatch,
+) -> None:
+ def shallow_git(
+ *arguments: str,
+ check: bool = True,
+ ) -> subprocess.CompletedProcess[bytes]:
+ del check
+ if arguments[0] == "cat-file":
+ return subprocess.CompletedProcess(arguments, 128, b"", b"missing")
+ assert arguments == ("rev-parse", "--is-shallow-repository")
+ return subprocess.CompletedProcess(arguments, 0, b"true\n", b"")
+
+ monkeypatch.setattr(f"{__name__}._git", shallow_git)
+ assert _base_history_available() is False
+
+ def full_git(
+ *arguments: str,
+ check: bool = True,
+ ) -> subprocess.CompletedProcess[bytes]:
+ del check
+ if arguments[0] == "cat-file":
+ return subprocess.CompletedProcess(arguments, 128, b"", b"missing")
+ return subprocess.CompletedProcess(arguments, 0, b"false\n", b"")
+
+ monkeypatch.setattr(f"{__name__}._git", full_git)
+ with pytest.raises(AssertionError, match="not verified shallow"):
+ _base_history_available()
+
+
+def test_phase5_receipt_exclusions_and_ignore_rules_are_exact() -> None:
+ assert RECEIPT_EXCLUSIONS == (
+ f"{HANDOFF_REL}/BUILD_REPORT.md",
+ f"{HANDOFF_REL}/REVIEW_REPORT.md",
+ )
+ assert all(not _is_ignored(path) for path in RECEIPT_EXCLUSIONS)
+ assert _is_ignored("BUILD_REPORT.md")
+ assert _is_ignored("REVIEW_REPORT.md")
+ assert tuple(sorted(CARRIER_PATHS, key=lambda value: value.encode())) == CARRIER_PATHS
+ report = (HANDOFF / "BUILD_REPORT.md").read_text(encoding="utf-8")
+ assert _carrier_paths_from_report(report) == CARRIER_PATHS
+
+
+def test_phase5_live_carrier_scope_and_receipt() -> None:
+ head = _git("rev-parse", "HEAD").stdout.decode().strip()
+ report = (HANDOFF / "BUILD_REPORT.md").read_text(encoding="utf-8")
+ receipt = _receipt_from_report(report)
+ if head == BASE:
+ assert _git("diff", "--cached", "--quiet").returncode == 0
+ expected_paths = set(CARRIER_PATHS) | set(RECEIPT_EXCLUSIONS)
+ assert _live_dirty_paths() == expected_paths
+ assert hashlib.sha256(build_live_receipt()).hexdigest() == receipt
+ return
+
+ receipt_delta = _receipt_scoped_delta_paths(_worktree_delta_paths())
+ assert not receipt_delta, (
+ "integrated checkout has live receipt or report drift: "
+ f"{sorted(receipt_delta)}"
+ )
+
+ # A shallow PR checkout can still prove the carrier bytes when HEAD itself
+ # is the receipt-trailed carrier. Full ancestry is proved separately in the
+ # fetch-depth:0 Phase 5 ops workflow.
+ message = _git("show", "-s", "--format=%B", "HEAD").stdout.decode()
+ if f"Alice-Carrier-Receipt-SHA256: {receipt}" not in message:
+ pytest.skip("live carrier is integrated; full-history receipt test owns ancestry")
+ assert hashlib.sha256(_build_receipt(lambda path: _read_commit_record("HEAD", path))).hexdigest() == receipt
+ _assert_commit_report_receipts("HEAD", receipt)
+
+
+def test_phase5_integrated_carrier_is_ancestry_and_content_bound() -> None:
+ head = _git("rev-parse", "HEAD").stdout.decode().strip()
+ if head == BASE:
+ pytest.skip("uncommitted carrier: live receipt test is authoritative")
+ _require_full_git_history()
+
+ report = (HANDOFF / "BUILD_REPORT.md").read_text(encoding="utf-8")
+ receipt = _receipt_from_report(report)
+ carrier = _carrier_commit(receipt)
+ assert carrier is not None
+ _assert_carrier_directly_descends_from_base(carrier)
+ assert _git("merge-base", "--is-ancestor", BASE, carrier, check=False).returncode == 0
+ assert _git("merge-base", "--is-ancestor", carrier, "HEAD", check=False).returncode == 0
+ changed = set(_git("diff", "--name-only", BASE, carrier, "--").stdout.decode().splitlines())
+ assert changed == set(CARRIER_PATHS) | set(RECEIPT_EXCLUSIONS)
+ integrated = _build_receipt(lambda path: _read_commit_record(carrier, path))
+ assert hashlib.sha256(integrated).hexdigest() == receipt
+ _assert_commit_report_receipts(carrier, receipt)
+ _assert_integrated_carrier_scope_immutable(carrier)
+ _assert_integrated_handoff_immutable(carrier)
+
+ python_at_carrier = tomllib.loads(_git("show", f"{carrier}:pyproject.toml").stdout.decode())[
+ "project"
+ ]["version"]
+ web_at_carrier = json.loads(
+ _git("show", f"{carrier}:apps/web/package.json").stdout.decode()
+ )["version"]
+ assert python_at_carrier == web_at_carrier == "0.13.1"
+
+
+def test_phase5_receipt_records_fail_on_old_bytes_modes_deletions_and_symlinks(
+ tmp_path: Path,
+) -> None:
+ candidate = tmp_path / "carrier.txt"
+ candidate.write_bytes(b"first\n")
+ base_mode_reader = lambda _path: b"100644"
+ first = _read_live_record(
+ "carrier.txt",
+ root=tmp_path,
+ base_mode_reader=base_mode_reader,
+ )
+ candidate.write_bytes(b"second\n")
+ second = _read_live_record(
+ "carrier.txt",
+ root=tmp_path,
+ base_mode_reader=base_mode_reader,
+ )
+ assert first != second
+
+ candidate.chmod(0o755)
+ executable = _read_live_record(
+ "carrier.txt",
+ root=tmp_path,
+ base_mode_reader=base_mode_reader,
+ )
+ assert executable[0] == b"100755" and executable != second
+
+ candidate.unlink()
+ os.symlink("target-that-is-never-followed", candidate)
+ linked = _read_live_record(
+ "carrier.txt",
+ root=tmp_path,
+ base_mode_reader=base_mode_reader,
+ )
+ assert linked == (b"120000", b"symlink", b"target-that-is-never-followed")
+
+ candidate.unlink()
+ deleted = _read_live_record(
+ "carrier.txt",
+ root=tmp_path,
+ base_mode_reader=base_mode_reader,
+ )
+ assert deleted == (b"100644", b"deleted", b"")
+
+ real_parent = tmp_path / "real-parent"
+ real_parent.mkdir()
+ (real_parent / "nested.txt").write_text("payload\n", encoding="utf-8")
+ os.symlink("real-parent", tmp_path / "linked-parent", target_is_directory=True)
+ with pytest.raises(AssertionError, match="unsafe carrier parent component"):
+ _read_live_record(
+ "linked-parent/nested.txt",
+ root=tmp_path,
+ base_mode_reader=base_mode_reader,
+ )
+
+
+def test_phase5_integrated_handoff_rejects_post_carrier_drift(monkeypatch) -> None:
+ observed: list[tuple[str, ...]] = []
+
+ def fake_git(*arguments: str, check: bool = True) -> subprocess.CompletedProcess[bytes]:
+ observed.append(arguments)
+ return subprocess.CompletedProcess(arguments, 1, b"", b"")
+
+ monkeypatch.setattr(f"{__name__}._git", fake_git)
+ with pytest.raises(AssertionError, match="handoff changed"):
+ _assert_integrated_handoff_immutable("carrier-commit")
+ assert observed == [
+ ("diff", "--quiet", "carrier-commit", "HEAD", "--", HANDOFF_REL)
+ ]
+
+
+def test_phase5_integrated_carrier_rejects_a_child_of_the_failed_carrier(
+ monkeypatch,
+) -> None:
+ def fake_git(
+ *arguments: str,
+ check: bool = True,
+ ) -> subprocess.CompletedProcess[bytes]:
+ del check
+ assert arguments == ("rev-list", "--parents", "-n", "1", "replacement")
+ return subprocess.CompletedProcess(
+ arguments,
+ 0,
+ b"replacement failed-carrier\n",
+ b"",
+ )
+
+ monkeypatch.setattr(f"{__name__}._git", fake_git)
+ with pytest.raises(AssertionError, match="fresh commit directly"):
+ _assert_carrier_directly_descends_from_base("replacement")
+
+
+def test_phase5_integrated_live_delta_ignores_only_unrelated_transient_paths() -> None:
+ unrelated = {
+ ".coverage.ci-host.pid123.Xabc123.Habcdefghijh",
+ "temporary-test-artifact.txt",
+ }
+ assert _receipt_scoped_delta_paths(unrelated) == set()
+
+ receipt_path = CARRIER_PATHS[0]
+ report_path = RECEIPT_EXCLUSIONS[0]
+ assert _receipt_scoped_delta_paths(
+ {*unrelated, receipt_path, report_path}
+ ) == {receipt_path, report_path}
+
+
+def test_phase5_integrated_handoff_rejects_report_byte_drift(monkeypatch) -> None:
+ def fake_git(*arguments: str, check: bool = True) -> subprocess.CompletedProcess[bytes]:
+ del check
+ if arguments[0] == "diff":
+ return subprocess.CompletedProcess(arguments, 0, b"", b"")
+ commit_spec = arguments[1]
+ payload = b"carrier report" if commit_spec.startswith("carrier-commit:") else b"drift"
+ return subprocess.CompletedProcess(arguments, 0, payload, b"")
+
+ monkeypatch.setattr(f"{__name__}._git", fake_git)
+ with pytest.raises(AssertionError, match="post-carrier report drift"):
+ _assert_integrated_handoff_immutable("carrier-commit")
+
+
+def test_phase5_integrated_carrier_rejects_receipt_input_drift(monkeypatch) -> None:
+ observed: list[tuple[str, ...]] = []
+
+ def fake_git(*arguments: str, check: bool = True) -> subprocess.CompletedProcess[bytes]:
+ del check
+ observed.append(arguments)
+ return subprocess.CompletedProcess(arguments, 1, b"", b"")
+
+ monkeypatch.setattr(f"{__name__}._git", fake_git)
+ with pytest.raises(AssertionError, match="receipt-listed carrier content changed"):
+ _assert_integrated_carrier_scope_immutable("carrier-commit")
+ assert observed == [
+ ("diff", "--quiet", "carrier-commit", "HEAD", "--", *CARRIER_PATHS)
+ ]
+
+
+def test_phase5_integrated_carrier_rejects_protected_path_drift(monkeypatch) -> None:
+ observed: list[tuple[str, ...]] = []
+
+ def fake_git(*arguments: str, check: bool = True) -> subprocess.CompletedProcess[bytes]:
+ del check
+ observed.append(arguments)
+ return subprocess.CompletedProcess(
+ arguments,
+ 0 if len(observed) == 1 else 1,
+ b"",
+ b"",
+ )
+
+ monkeypatch.setattr(f"{__name__}._git", fake_git)
+ with pytest.raises(AssertionError, match="protected SQLite memory-access path"):
+ _assert_integrated_carrier_scope_immutable("carrier-commit")
+ assert observed == [
+ ("diff", "--quiet", "carrier-commit", "HEAD", "--", *CARRIER_PATHS),
+ ("diff", "--quiet", BASE, "HEAD", "--", PROTECTED_SQLITE_PATH),
+ ]
diff --git a/tests/unit/test_phase5_ops_evidence.py b/tests/unit/test_phase5_ops_evidence.py
new file mode 100644
index 00000000..633cf242
--- /dev/null
+++ b/tests/unit/test_phase5_ops_evidence.py
@@ -0,0 +1,632 @@
+from __future__ import annotations
+
+from datetime import UTC, datetime, timedelta
+import importlib.util
+import json
+import os
+from pathlib import Path
+import re
+import subprocess
+import sys
+from types import SimpleNamespace
+
+import pytest
+
+
+ROOT = Path(__file__).resolve().parents[2]
+SCRIPT_PATH = ROOT / "scripts" / "run_phase5_ops_evidence.py"
+SEED_PATH = ROOT / "scripts" / "_phase5_ops_seed.py"
+WORKFLOW_PATH = ROOT / ".github" / "workflows" / "ops-evidence.yml"
+_SPEC = importlib.util.spec_from_file_location("run_phase5_ops_evidence", SCRIPT_PATH)
+assert _SPEC is not None and _SPEC.loader is not None
+ops = importlib.util.module_from_spec(_SPEC)
+_SPEC.loader.exec_module(ops)
+
+
+def _require_baseline_tag() -> None:
+ probe = subprocess.run(
+ ["git", "rev-parse", f"{ops.BASELINE_TAG}^{{commit}}"],
+ cwd=ROOT,
+ check=False,
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ )
+ if probe.returncode != 0:
+ pytest.skip(
+ "v0.12 baseline tag unavailable in this shallow/no-tag checkout; "
+ "the fetch-depth:0 Phase 5 ops workflow executes the real upgrade drill"
+ )
+ assert probe.stdout.strip() == ops.BASELINE_COMMIT
+
+
+def test_sqlite_operations_evidence_executes_full_physical_portable_and_upgrade_drill(tmp_path) -> None:
+ _require_baseline_tag()
+ args = ops._build_parser().parse_args(
+ ["--backend", "sqlite", "--work-dir", str(tmp_path / "private")]
+ )
+
+ report = ops.run_evidence(args)
+
+ assert report["status"] == "passed"
+ assert report["proof_gaps"] == ["postgres_not_requested"]
+ assert report["repository"]["baseline_commit"] == ops.BASELINE_COMMIT
+ repository = report["repository"]
+ assert "current_commit" not in repository
+ assert re.fullmatch(r"[0-9a-f]{40}", repository["source_head_commit"])
+ assert re.fullmatch(r"[0-9a-f]{40}", repository["source_head_tree"])
+ assert repository["carrier_state"] in {"clean", "dirty"}
+ assert re.fullmatch(r"[0-9a-f]{64}", repository["carrier_snapshot_sha256"])
+ checks = report["checks"]
+ assert checks["sqlite_physical_backup_restore"]["destroy_restore"] == "proved"
+ assert checks["sqlite_physical_backup_restore"]["embedding_signature"] == "current"
+ assert checks["portable_export_import"]["fidelity"] == (
+ "canonical_digest_and_counts_match"
+ )
+ assert checks["portable_export_import"]["embeddings"] == "omitted_by_contract"
+ assert checks["sqlite_v0_12_upgrade"]["source_method"] == "git_archive_no_checkout"
+ assert checks["sqlite_v0_12_upgrade"]["embedding_stamp"] == (
+ "one_nonempty_stable_row"
+ )
+ serialized = json.dumps(report, sort_keys=True)
+ assert ops.SEED_QUERY not in serialized
+ assert str(tmp_path) not in serialized
+ assert "postgresql://" not in serialized
+
+
+def _git(repo: Path, *arguments: str) -> str:
+ return subprocess.run(
+ ["git", *arguments],
+ cwd=repo,
+ check=True,
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ ).stdout.strip()
+
+
+def test_carrier_identity_binds_tracked_and_untracked_changes_without_following_symlinks(
+ tmp_path,
+) -> None:
+ repo = tmp_path / "repo"
+ repo.mkdir()
+ _git(repo, "init", "-q")
+ _git(repo, "config", "user.email", "phase5@example.invalid")
+ _git(repo, "config", "user.name", "Phase 5 Test")
+ (repo / ".gitignore").write_text("ignored-output.json\n", encoding="utf-8")
+ tracked = repo / "tracked.txt"
+ tracked.write_text("original\n", encoding="utf-8")
+ _git(repo, "add", ".gitignore", "tracked.txt")
+ _git(repo, "commit", "-q", "-m", "baseline")
+
+ clean = ops.repository_carrier_identity(repo)
+ assert clean["carrier_state"] == "clean"
+
+ tracked.write_text("tracked carrier change\n", encoding="utf-8")
+ tracked_change = ops.repository_carrier_identity(repo)
+ assert tracked_change["source_head_commit"] == clean["source_head_commit"]
+ assert tracked_change["source_head_tree"] == clean["source_head_tree"]
+ assert tracked_change["carrier_state"] == "dirty"
+ assert tracked_change["carrier_snapshot_sha256"] != clean["carrier_snapshot_sha256"]
+
+ tracked.unlink()
+ tracked_deletion = ops.repository_carrier_identity(repo)
+ assert tracked_deletion["source_head_commit"] == clean["source_head_commit"]
+ assert tracked_deletion["source_head_tree"] == clean["source_head_tree"]
+ assert tracked_deletion["carrier_state"] == "dirty"
+ assert tracked_deletion["carrier_snapshot_sha256"] != clean["carrier_snapshot_sha256"]
+ assert (
+ tracked_deletion["carrier_snapshot_sha256"]
+ != tracked_change["carrier_snapshot_sha256"]
+ )
+
+ tracked.write_text("original\n", encoding="utf-8")
+ untracked = repo / "new-evidence.txt"
+ untracked.write_text("untracked carrier change\n", encoding="utf-8")
+ untracked_change = ops.repository_carrier_identity(repo)
+ assert untracked_change["source_head_commit"] == clean["source_head_commit"]
+ assert untracked_change["source_head_tree"] == clean["source_head_tree"]
+ assert untracked_change["carrier_state"] == "dirty"
+ assert untracked_change["carrier_snapshot_sha256"] != clean["carrier_snapshot_sha256"]
+
+ untracked.unlink()
+ ignored = repo / "ignored-output.json"
+ ignored.write_text("ignored receipt one\n", encoding="utf-8")
+ ignored_change = ops.repository_carrier_identity(repo)
+ ignored.write_text("ignored receipt two\n", encoding="utf-8")
+ assert ignored_change == ops.repository_carrier_identity(repo)
+ assert ignored_change == clean
+
+ outside = tmp_path / "outside.txt"
+ outside.write_text("outside version one\n", encoding="utf-8")
+ link = repo / "outside-link"
+ os.symlink(outside, link)
+ linked = ops.repository_carrier_identity(repo)
+ outside.write_text("outside version two\n", encoding="utf-8")
+ linked_after_outside_change = ops.repository_carrier_identity(repo)
+ assert linked["carrier_state"] == "dirty"
+ assert linked["carrier_snapshot_sha256"] != clean["carrier_snapshot_sha256"]
+ assert linked_after_outside_change == linked
+
+
+def test_scheduler_classifier_covers_disabled_healthy_degraded_and_stuck() -> None:
+ now = datetime(2026, 7, 21, 12, tzinfo=UTC)
+ assert ops.classify_scheduler_snapshot({}, now=now) == {
+ "state": "disabled",
+ "reason_codes": [],
+ }
+ healthy = ops.classify_scheduler_snapshot(
+ {
+ "configured": True,
+ "reported_running": True,
+ "running": True,
+ "ownership_verified": True,
+ "last_heartbeat_at": (now - timedelta(seconds=10)).isoformat(),
+ "interval_seconds": 30,
+ "last_error_code": None,
+ "expired_claim_count": 0,
+ },
+ now=now,
+ )
+ assert healthy == {"state": "healthy", "reason_codes": []}
+ degraded = ops.classify_scheduler_snapshot(
+ {
+ "configured": True,
+ "reported_running": False,
+ "running": False,
+ "ownership_verified": True,
+ "last_error_code": "claim_failed",
+ "expired_claim_count": 2,
+ },
+ now=now,
+ )
+ assert degraded == {
+ "state": "degraded",
+ "reason_codes": ["expired_claims", "last_error"],
+ }
+ stuck = ops.classify_scheduler_snapshot(
+ {
+ "configured": True,
+ "reported_running": True,
+ "running": True,
+ "ownership_verified": False,
+ "last_heartbeat_at": (now - timedelta(minutes=10)).isoformat(),
+ "interval_seconds": 30,
+ },
+ now=now,
+ )
+ assert stuck == {
+ "state": "stuck",
+ "reason_codes": ["heartbeat_stale", "ownership_unverified"],
+ }
+
+
+@pytest.mark.parametrize(
+ "payload",
+ [
+ {"database_url": "redacted"},
+ {"value": "postgresql://alice:password@example.invalid/alice"},
+ {"value": ops.SEED_QUERY},
+ {"value": "phase5-ops@example.invalid"},
+ ],
+)
+def test_sanitized_report_guard_rejects_credentials_paths_and_seed_content(payload) -> None:
+ with pytest.raises(ops.EvidenceError):
+ ops._assert_report_safe(payload)
+
+
+def test_postgres_cli_environment_keeps_credentials_out_of_command_arguments() -> None:
+ dsn = (
+ "postgresql://alicebot_admin:s3cret@db.example.invalid:5544/alice"
+ "?sslmode=verify-full&sslrootcert=%2Frun%2Fsecrets%2Falicebot%2Fpostgres-ca.pem"
+ )
+
+ env = ops._libpq_env(dsn)
+ command = ["pg_dump", "--format=custom", "--file=alice.dump"]
+
+ assert env["PGHOST"] == "db.example.invalid"
+ assert env["PGPORT"] == "5544"
+ assert env["PGUSER"] == "alicebot_admin"
+ assert env["PGPASSWORD"] == "s3cret"
+ assert env["PGDATABASE"] == "alice"
+ assert env["PGSSLMODE"] == "verify-full"
+ assert env["PGSSLROOTCERT"] == "/run/secrets/alicebot/postgres-ca.pem"
+ assert all("s3cret" not in argument and "postgresql://" not in argument for argument in command)
+
+
+def test_v0_12_rating_seed_sets_deterministic_timestamps_at_insert_only() -> None:
+ source = SEED_PATH.read_text(encoding="utf-8")
+
+ assert "INSERT INTO artifact_quality_ratings" in source
+ assert "UPDATE artifact_quality_ratings" not in source
+ assert "created_at," in source
+ assert '"2020-01-01T00:00:00Z"' in source
+ assert '"2021-01-01T00:00:00Z"' in source
+
+
+def test_postgres_count_probe_uses_named_dict_row_shape() -> None:
+ queries: list[str] = []
+
+ class FakeResult:
+ def fetchone(self):
+ return {"count": "3"}
+
+ class FakeConnection:
+ def execute(self, query):
+ queries.append(query)
+ return FakeResult()
+
+ counts = ops._postgres_counts(FakeConnection())
+
+ assert set(counts) == {
+ "users",
+ "memories",
+ "event_log",
+ "generated_artifacts",
+ "artifact_quality_ratings",
+ }
+ assert set(counts.values()) == {3}
+ assert queries and all("count(*) AS count" in query for query in queries)
+
+
+def test_postgres_client_version_probe_rejects_malformed_output(monkeypatch) -> None:
+ monkeypatch.setattr(
+ ops,
+ "_run",
+ lambda command, **_kwargs: subprocess.CompletedProcess(
+ command,
+ 0,
+ "pg_dump version sixteen",
+ "",
+ ),
+ )
+
+ with pytest.raises(ops.EvidenceError) as raised:
+ ops._postgres_client_major("/usr/bin/pg_dump", "pg_dump")
+
+ assert raised.value.codes == ("postgres_pg_dump_version_invalid",)
+
+
+def test_postgres_server_version_probe_rejects_malformed_output(monkeypatch) -> None:
+ class FakeResult:
+ def fetchone(self):
+ return ("PostgreSQL sixteen",)
+
+ class FakeConnection:
+ def __enter__(self):
+ return self
+
+ def __exit__(self, exc_type, exc, traceback):
+ return False
+
+ def execute(self, query):
+ assert query == "SHOW server_version_num"
+ return FakeResult()
+
+ monkeypatch.setitem(
+ sys.modules,
+ "psycopg",
+ SimpleNamespace(connect=lambda _url: FakeConnection()),
+ )
+
+ with pytest.raises(ops.EvidenceError) as raised:
+ ops._postgres_server_major("postgresql://db.example.invalid/alice")
+
+ assert raised.value.codes == ("postgres_server_version_invalid",)
+
+
+@pytest.mark.parametrize(
+ ("client_major", "server_major", "failure_code"),
+ [
+ (18, 16, "postgres_client_major_mismatch"),
+ (16, 17, "postgres_server_major_mismatch"),
+ ],
+)
+def test_postgres_toolchain_preflight_rejects_client_or_server_major_mismatch(
+ monkeypatch,
+ client_major,
+ server_major,
+ failure_code,
+) -> None:
+ monkeypatch.setattr(
+ ops,
+ "_postgres_client_major",
+ lambda _executable, _program: client_major,
+ )
+ monkeypatch.setattr(ops, "_postgres_server_major", lambda _url: server_major)
+
+ with pytest.raises(ops.EvidenceError) as raised:
+ ops._validate_postgres_toolchain(
+ root_admin_url="postgresql://db.example.invalid/alice",
+ pg_dump="/usr/bin/pg_dump",
+ pg_restore="/usr/bin/pg_restore",
+ )
+
+ assert raised.value.codes == (failure_code,)
+
+
+@pytest.mark.parametrize(
+ ("query", "failure_code"),
+ [
+ ("sslrootcert=", "postgres_sslrootcert_invalid"),
+ (
+ "sslrootcert=%2Ffirst.pem&sslrootcert=%2Fsecond.pem",
+ "postgres_sslrootcert_invalid",
+ ),
+ ("sslrootcert=%0A%2Fca.pem", "postgres_sslrootcert_invalid"),
+ ("sslrootcert", "postgres_url_query_invalid"),
+ ("sslmode=require&sslmode=verify-full", "postgres_sslmode_invalid"),
+ ],
+)
+def test_postgres_cli_environment_rejects_ambiguous_or_unsafe_tls_query_values(
+ query,
+ failure_code,
+) -> None:
+ with pytest.raises(ops.EvidenceError) as raised:
+ ops._libpq_env(f"postgresql://alicebot_admin@db.example.invalid/alice?{query}")
+
+ assert raised.value.codes == (failure_code,)
+
+
+def _mock_successful_postgres_drill(monkeypatch, tmp_path) -> None:
+ class FakeQueryResult:
+ def __init__(self, value):
+ self.value = value
+
+ def fetchone(self):
+ return (self.value,)
+
+ class FakeConnection:
+ def __enter__(self):
+ return self
+
+ def __exit__(self, exc_type, exc, traceback):
+ return False
+
+ def execute(self, query):
+ value = "160013" if "server_version_num" in query else ops.BASELINE_POSTGRES_HEAD
+ return FakeQueryResult(value)
+
+ monkeypatch.setitem(
+ sys.modules,
+ "psycopg",
+ SimpleNamespace(connect=lambda _url: FakeConnection()),
+ )
+ monkeypatch.setattr(ops, "_require_tool", lambda name: name)
+ monkeypatch.setattr(ops, "_extract_baseline", lambda _work_dir: tmp_path / "baseline")
+ monkeypatch.setattr(ops, "_create_database", lambda *_args, **_kwargs: None)
+ monkeypatch.setattr(ops, "_seed_postgres_baseline", lambda **_kwargs: None)
+ monkeypatch.setattr(ops, "_dynamic_alembic_head", lambda: "current_head")
+ monkeypatch.setattr(ops, "_migrate_postgres", lambda *_args, **_kwargs: None)
+ monkeypatch.setattr(ops, "_verify_migration_0093", lambda _admin_url: None)
+ monkeypatch.setattr(
+ ops,
+ "_verify_postgres_store",
+ lambda *_args, **_kwargs: {
+ "counts": {"users": 1, "memories": 1},
+ "recall": "matched",
+ "embedding_signature": "current",
+ },
+ )
+ def fake_run(command, **_kwargs):
+ output = ""
+ if command[-1] == "--version":
+ output = f"{Path(command[0]).name} (PostgreSQL) 16.13"
+ return subprocess.CompletedProcess(command, 0, output, "")
+
+ monkeypatch.setattr(ops, "_run", fake_run)
+ monkeypatch.setattr(ops, "_sha256_file", lambda _path: "a" * 64)
+ monkeypatch.setattr(
+ ops,
+ "_monitoring_drill",
+ lambda: {"status": "passed"},
+ )
+ monkeypatch.setattr(
+ ops,
+ "repository_carrier_identity",
+ lambda: {
+ "source_head_commit": "a" * 40,
+ "source_head_tree": "b" * 40,
+ "carrier_state": "dirty",
+ "carrier_snapshot_sha256": "c" * 64,
+ },
+ )
+
+
+def _run_mocked_postgres_evidence(tmp_path, capsys) -> tuple[int, dict[str, object]]:
+ exit_code = ops.main(
+ [
+ "--backend",
+ "postgres",
+ "--work-dir",
+ str(tmp_path / "private"),
+ "--database-admin-url",
+ "postgresql://alicebot_admin:admin@db.example.invalid/postgres",
+ "--database-url",
+ "postgresql://alicebot_app:app@db.example.invalid/postgres",
+ ]
+ )
+ return exit_code, json.loads(capsys.readouterr().out)
+
+
+def test_postgres_toolchain_mismatch_fails_before_disposable_database_creation(
+ monkeypatch,
+ tmp_path,
+ capsys,
+) -> None:
+ _mock_successful_postgres_drill(monkeypatch, tmp_path)
+ create_called = False
+
+ def record_create(*_args, **_kwargs):
+ nonlocal create_called
+ create_called = True
+
+ monkeypatch.setattr(ops, "_postgres_client_major", lambda _path, _program: 18)
+ monkeypatch.setattr(ops, "_postgres_server_major", lambda _url: 16)
+ monkeypatch.setattr(ops, "_create_database", record_create)
+
+ exit_code, report = _run_mocked_postgres_evidence(tmp_path, capsys)
+
+ assert create_called is False
+ assert exit_code == 1
+ assert report["status"] == "failed"
+ assert report["failure_codes"] == ["postgres_client_major_mismatch"]
+
+
+def test_postgres_final_cleanup_failure_cannot_report_passed(monkeypatch, tmp_path, capsys) -> None:
+ _mock_successful_postgres_drill(monkeypatch, tmp_path)
+ drop_calls = 0
+
+ def fail_final_drop(_root_url, _database_name):
+ nonlocal drop_calls
+ drop_calls += 1
+ if drop_calls == 2:
+ raise RuntimeError("simulated final cleanup failure")
+
+ monkeypatch.setattr(ops, "_drop_database", fail_final_drop)
+
+ exit_code, report = _run_mocked_postgres_evidence(tmp_path, capsys)
+
+ assert drop_calls == 2
+ assert exit_code == 1
+ assert report["status"] == "failed"
+ assert report["failure_codes"] == ["postgres_cleanup_failed"]
+
+
+def test_postgres_create_grant_and_cleanup_failures_are_both_reported(
+ monkeypatch,
+ tmp_path,
+ capsys,
+) -> None:
+ _mock_successful_postgres_drill(monkeypatch, tmp_path)
+ drop_calls = 0
+
+ def fail_after_create(*_args, **_kwargs):
+ raise ops.EvidenceError("postgres_create_grant_failed")
+
+ def fail_cleanup(_root_url, _database_name):
+ nonlocal drop_calls
+ drop_calls += 1
+ raise RuntimeError("simulated cleanup failure")
+
+ monkeypatch.setattr(ops, "_create_database", fail_after_create)
+ monkeypatch.setattr(ops, "_drop_database", fail_cleanup)
+
+ exit_code, report = _run_mocked_postgres_evidence(tmp_path, capsys)
+
+ assert drop_calls == 1
+ assert exit_code == 1
+ assert report["status"] == "failed"
+ assert report["failure_codes"] == [
+ "postgres_create_grant_failed",
+ "postgres_cleanup_failed",
+ ]
+
+
+@pytest.mark.parametrize("backend", ["postgres", "all"])
+@pytest.mark.parametrize(
+ ("admin_url", "app_url"),
+ [
+ ("", ""),
+ ("postgresql://alicebot_admin@example.invalid/alice", ""),
+ ("", "postgresql://alicebot_app@example.invalid/alice"),
+ ],
+)
+def test_postgres_mode_fails_closed_without_both_role_separated_urls(
+ backend,
+ admin_url,
+ app_url,
+ monkeypatch,
+ tmp_path,
+ capsys,
+) -> None:
+ output = tmp_path / "failed.json"
+ monkeypatch.setattr(
+ ops,
+ "_extract_baseline",
+ lambda _work_dir: pytest.fail("baseline lookup ran before URL validation"),
+ )
+
+ exit_code = ops.main(
+ [
+ "--backend",
+ backend,
+ "--work-dir",
+ str(tmp_path / "private"),
+ "--database-admin-url",
+ admin_url,
+ "--database-url",
+ app_url,
+ "--output",
+ str(output),
+ ]
+ )
+
+ assert exit_code == 1
+ payload = json.loads(capsys.readouterr().out)
+ assert payload["status"] == "failed"
+ assert payload["failure_codes"] == ["missing_prerequisite:postgres_urls"]
+ assert json.loads(output.read_text(encoding="utf-8")) == payload
+
+
+def test_ops_workflow_has_required_triggers_full_history_and_atomic_pins() -> None:
+ workflow = WORKFLOW_PATH.read_text(encoding="utf-8")
+
+ assert re.search(r"(?m)^ pull_request:$", workflow)
+ assert re.search(r"(?m)^ push:$", workflow)
+ assert re.search(r"(?m)^ - main$", workflow)
+ assert re.search(r"(?m)^ workflow_dispatch:$", workflow)
+ assert "runs-on: ubuntu-24.04" in workflow
+ assert "fetch-depth: 0" in workflow
+ assert "postgresql-client-16" in workflow
+ assert 'echo "/usr/lib/postgresql/16/bin" >> "$GITHUB_PATH"' in workflow
+ assert (
+ "actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7" in workflow
+ )
+ assert (
+ "actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6" in workflow
+ )
+ assert (
+ "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7"
+ in workflow
+ )
+ assert (
+ "pgvector/pgvector:pg16@sha256:"
+ "1d533553fefe4f12e5d80c7b80622ba0c382abb5758856f52983d8789179f0fb"
+ in workflow
+ )
+ assert "scripts/run_phase5_ops_evidence.py" in workflow
+ assert "--backend all" in workflow
+
+
+def test_ops_docs_name_executed_commands_and_honest_boundaries() -> None:
+ disaster = (ROOT / "docs" / "runbooks" / "disaster-recovery.md").read_text(
+ encoding="utf-8"
+ )
+ monitoring = (ROOT / "docs" / "runbooks" / "health-and-monitoring.md").read_text(
+ encoding="utf-8"
+ )
+ upgrade = (ROOT / "docs" / "runbooks" / "upgrade-v0.12-to-current.md").read_text(
+ encoding="utf-8"
+ )
+ backup = (ROOT / "docs" / "alpha" / "backup-and-restore.md").read_text(
+ encoding="utf-8"
+ )
+
+ assert "run_phase5_ops_evidence.py --backend all" in disaster
+ assert "wal_checkpoint(TRUNCATE)" in disaster
+ assert "pg_dump" in disaster and "pg_restore" in disaster
+ assert "postgres_cleanup_failed" in disaster
+ assert "PGSSLROOTCERT=/run/secrets/alicebot/postgres-ca.pem" in disaster
+ assert "PostgreSQL 16 `pg_dump`" in disaster
+ assert "validates all three major versions" in disaster
+ assert "not_checked" in monitoring
+ assert "ownership_verified" in monitoring and "last_heartbeat_at" in monitoring
+ assert ops.BASELINE_COMMIT in upgrade
+ assert "git archive" in upgrade
+ assert ops.MIGRATION_0093 in upgrade
+ assert ops.MIGRATION_0094 in upgrade
+ assert "alice-memory reindex-embeddings" in backup
+ assert "Portable JSONL does not contain embedding vectors" in backup
diff --git a/tests/unit/test_providers_router_split.py b/tests/unit/test_providers_router_split.py
index 92d94e7e..1870f0cb 100644
--- a/tests/unit/test_providers_router_split.py
+++ b/tests/unit/test_providers_router_split.py
@@ -54,11 +54,14 @@
_openapi_tag_for_path _OPENAPI_EXACT_RESPONSE_CONTRACTS
_OPENAPI_CREATED_ONLY_OPERATIONS _OPENAPI_CONDITIONAL_SUCCESS_OPERATIONS
LEGACY_HTTP_OPERATION_KEYS LEGACY_SURFACES_ENABLED
- _openapi_live_operation_keys AliceFastAPI app HealthStatus ServiceStatus
+ _openapi_live_operation_keys AliceFastAPI app _alice_request_validation_error
+ HealthStatus ServiceStatus
DatabaseServicePayload RedisServicePayload ObjectStorageServicePayload
HealthServicesPayload HealthcheckPayload _rewrite_user_id_query_param
_rewrite_user_id_json_body _VNEXT_ROUTE_LOCAL_POLICY
- _VNEXT_CENTRAL_OPERATOR_ROUTES _matched_vnext_route_path
+ _VNEXT_CENTRAL_OPERATOR_ROUTES _BROWSER_CLIP_SIMPLE_CAPTURE_PATH
+ _BROWSER_CLIP_SIMPLE_BODY_MAX_BYTES _prepare_browser_clip_simple_request
+ _matched_vnext_route_path
_vnext_central_route_policy _resolve_vnext_http_auth
_vnext_protected_http_auth
build_healthcheck_payload _request_client_is_loopback _append_vary_header
@@ -70,8 +73,8 @@
EXPECTED_ROUTE_AST_SHA256 = "9e5d6c2c79cc1391688b74bb5138ccaa881033546e7b0cfd34ad92e8d98ba614"
EXPECTED_SUPPORT_AST_SHA256 = "bb694bc545e514bb81e2aa568eba1cb72ba813373015d979bac452d23d4dbd74"
-EXPECTED_CARRIER_NAMES_SHA256 = "6cd0951deb96f86d9f6b5ba4c862698b615db12e6c3933749ce39aee34c858c6"
-EXPECTED_CARRIER_AST_SHA256 = "d2533461869ee4e696ad63173b948584546ee9d9de0b59f908a8c037d086a432"
+EXPECTED_CARRIER_NAMES_SHA256 = "00f4b8aba8e03d77e9936205d45003365df5f4f3afd0b7f6eced5b0b6ab49a9b"
+EXPECTED_CARRIER_AST_SHA256 = "1465fa9c2d60479ab41c44f2a2d9dbb2bca4319b4d8368c132b4b158241cf294"
EXPECTED_ROUTE_NAME_MANIFEST_SHA256 = "1a438538e16120361f92d30375cc94679d598fe4b78ba5a58a7d8a4dda6af83c"
EXPECTED_OPERATION_MANIFEST_SHA256 = "8b79ceaf996b8c51b5bb2f3f38a8c19a4e33796955d8b8f7a66e7ac01ea1732d"
EXPECTED_IMPORT_MANIFEST_SHA256 = "17484ccdd460e42e2ad5c82a8ca867664694feaf871118c410a134996a532358"
@@ -501,7 +504,9 @@ def test_provider_routes_preserve_mount_order_origins_and_operation_ids() -> Non
def test_provider_test_patches_follow_moved_definition_ownership() -> None:
assert _setattr_attribute_counts("tests/unit/test_main.py", "main_module") == {
- "get_settings": 4,
+ "_BROWSER_CLIP_SIMPLE_BODY_MAX_BYTES": 3,
+ "_resolve_vnext_http_auth": 1,
+ "get_settings": 5,
"ping_database": 2,
}
assert _setattr_attribute_counts("tests/unit/test_main.py", "providers_router") == {
diff --git a/tests/unit/test_runnable_docs_secret_argv.py b/tests/unit/test_runnable_docs_secret_argv.py
new file mode 100644
index 00000000..0dfb7e2f
--- /dev/null
+++ b/tests/unit/test_runnable_docs_secret_argv.py
@@ -0,0 +1,44 @@
+from __future__ import annotations
+
+import re
+from pathlib import Path
+
+
+REPO_ROOT = Path(__file__).resolve().parents[2]
+SHELL_FENCE_PATTERN = re.compile(r"```(?:bash|sh|shell|zsh)\s*\n(.*?)```", re.DOTALL)
+SHELL_LINE_CONTINUATION_PATTERN = re.compile(r"\\\r?\n[ \t]*")
+HEADER_ARGUMENT_PATTERN = re.compile(
+ r"(?:^|[ \t])(?:-H|--header(?:[ \t]*=)?)[ \t]*"
+ r"(?P\"[^\"\n]*\"|'[^'\n]*'|[^ \t\n]+)",
+)
+AGENT_KEY_EXPANSION_PATTERN = re.compile(
+ r"(?:\$ALICE_AGENT_API_KEY|\$\{ALICE_AGENT_API_KEY\})",
+)
+
+
+def _expands_agent_key_in_header_argument(block: str) -> bool:
+ logical_block = SHELL_LINE_CONTINUATION_PATTERN.sub(" ", block)
+ return any(
+ AGENT_KEY_EXPANSION_PATTERN.search(match.group("header"))
+ for match in HEADER_ARGUMENT_PATTERN.finditer(logical_block)
+ )
+
+
+def test_agent_key_header_detector_rejects_attached_and_continued_arguments() -> None:
+ fail_on_old_blocks = (
+ 'curl -H"Authorization: Bearer $ALICE_AGENT_API_KEY" https://alice.example.com',
+ 'curl -H \\\n "Authorization: Bearer ${ALICE_AGENT_API_KEY}" https://alice.example.com',
+ )
+
+ assert all(_expands_agent_key_in_header_argument(block) for block in fail_on_old_blocks)
+
+
+def test_runnable_docs_do_not_expand_agent_keys_into_header_arguments() -> None:
+ offenders: list[str] = []
+ for path in sorted((REPO_ROOT / "docs").rglob("*.md")):
+ text = path.read_text(encoding="utf-8")
+ for block in SHELL_FENCE_PATTERN.findall(text):
+ if _expands_agent_key_in_header_argument(block):
+ offenders.append(str(path.relative_to(REPO_ROOT)))
+
+ assert offenders == []
diff --git a/tests/unit/test_single_tenant_deployment.py b/tests/unit/test_single_tenant_deployment.py
new file mode 100644
index 00000000..63ba9e29
--- /dev/null
+++ b/tests/unit/test_single_tenant_deployment.py
@@ -0,0 +1,571 @@
+from __future__ import annotations
+
+import importlib.util
+import hashlib
+import json
+import os
+from pathlib import Path
+import re
+import shutil
+import subprocess
+
+import pytest
+
+
+ROOT = Path(__file__).resolve().parents[2]
+SCRIPT_PATH = ROOT / "scripts" / "run_single_tenant_deployment_smoke.py"
+_SPEC = importlib.util.spec_from_file_location("run_single_tenant_deployment_smoke", SCRIPT_PATH)
+assert _SPEC is not None and _SPEC.loader is not None
+deployment = importlib.util.module_from_spec(_SPEC)
+_SPEC.loader.exec_module(deployment)
+
+
+def _asset(path: Path) -> str:
+ return (ROOT / path).read_text(encoding="utf-8")
+
+
+def _git(repo: Path, *arguments: str) -> str:
+ return subprocess.run(
+ ["git", *arguments],
+ cwd=repo,
+ check=True,
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ ).stdout.strip()
+
+
+def _valid_env() -> dict[str, str]:
+ return deployment.parse_env_example(_asset(deployment.ENV_RELATIVE_PATH))
+
+
+def _copy_contract_tree(target: Path) -> None:
+ for relative_path in deployment.CONTRACT_INPUTS.values():
+ destination = target / relative_path
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copyfile(ROOT / relative_path, destination)
+ _git(target, "init", "-q")
+ _git(target, "config", "user.email", "phase5-deployment@example.invalid")
+ _git(target, "config", "user.name", "Phase 5 Deployment Test")
+ _git(target, "add", ".")
+ _git(target, "commit", "-q", "-m", "deployment contract baseline")
+
+
+def test_checked_in_configuration_contract_passes_without_cloud_claims() -> None:
+ report = deployment.run_smoke(root=ROOT, environment="ephemeral_ci")
+
+ assert report["status"] == "passed"
+ assert report["environment"] == "ephemeral_ci"
+ assert report["cloud_provider"] == "none"
+ assert report["public_dns"] is False
+ assert report["public_ca"] is False
+ assert report["evidence_kind"] == "configuration_contract_only"
+ assert report["real_cloud_host_exercised"] is False
+ assert report["blockers"] == ["owner_real_host_deployment_receipt"]
+ assert re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", report["source_head_commit"])
+ assert re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", report["source_head_tree"])
+ assert report["carrier_state"] in {"clean", "dirty"}
+ assert re.fullmatch(r"[0-9a-f]{64}", report["carrier_snapshot_sha256"])
+ asset_hashes = report["validated_asset_sha256"]
+ assert set(asset_hashes) == set(deployment.VALIDATED_ASSETS)
+ for logical_name, relative_path in deployment.VALIDATED_ASSETS.items():
+ assert asset_hashes[logical_name] == hashlib.sha256((ROOT / relative_path).read_bytes()).hexdigest()
+ assert set(report["checks"]) == {
+ "api_and_web_loopback",
+ "database_role_and_tls_contract",
+ "exact_https_origin",
+ "proxy_trust_boundary",
+ "web_exact_origin_trust",
+ "guide_claim_boundaries",
+ "supply_chain_pins",
+ }
+
+
+def test_deployment_smoke_pins_web_live_and_bearer_trust_to_loopback_or_exact_https_origin() -> None:
+ source = _asset(deployment.WEB_API_SOURCE_RELATIVE_PATH)
+
+ deployment.validate_web_trust_contract(source)
+
+ assert "export function isTrustedApiBaseUrl" in source
+ assert "parsed.origin === currentAliceWebOrigin()" in source
+ assert "isTrustedApiBaseUrl(config.apiBaseUrl)" in source
+ assert "isTrustedApiBaseUrl(apiBaseUrl)" in source
+
+
+@pytest.mark.parametrize(
+ ("old", "new", "failure_code"),
+ (
+ (
+ "process.env.PUBLIC_ORIGIN",
+ '"https://alice.example.com"',
+ "web_current_origin_contract_invalid",
+ ),
+ (
+ "parsed.origin === currentAliceWebOrigin()",
+ "Boolean(parsed.origin)",
+ "web_exact_origin_trust_invalid",
+ ),
+ (
+ "config.userId.trim() && isTrustedApiBaseUrl(config.apiBaseUrl)",
+ "config.userId.trim()",
+ "web_live_config_bypasses_trust",
+ ),
+ (
+ "isTrustedApiBaseUrl(apiBaseUrl) &&\n (logicalPath",
+ "true &&\n (logicalPath",
+ "web_operator_key_bypasses_trust",
+ ),
+ ),
+)
+def test_web_trust_source_contract_fails_closed_if_exact_origin_checks_are_removed(
+ old: str,
+ new: str,
+ failure_code: str,
+) -> None:
+ source = _asset(deployment.WEB_API_SOURCE_RELATIVE_PATH)
+ assert old in source
+
+ with pytest.raises(deployment.DeploymentContractError) as exc_info:
+ deployment.validate_web_trust_contract(source.replace(old, new, 1))
+
+ assert exc_info.value.code == failure_code
+
+
+@pytest.mark.parametrize(
+ ("logical_name", "relative_path", "suffix"),
+ (
+ ("caddyfile_example", deployment.CADDY_RELATIVE_PATH, b"\n# carrier mutation\n"),
+ ("deployment_guide", deployment.GUIDE_RELATIVE_PATH, b"\n\n"),
+ ("environment_example", deployment.ENV_RELATIVE_PATH, b"\n# carrier mutation\n"),
+ ("web_api_source", deployment.WEB_API_SOURCE_RELATIVE_PATH, b"\n// carrier mutation\n"),
+ ("workflow", deployment.WORKFLOW_RELATIVE_PATH, b"\n# carrier mutation\n"),
+ ),
+)
+def test_each_validated_asset_hash_and_carrier_snapshot_bind_uncommitted_bytes(
+ tmp_path,
+ logical_name: str,
+ relative_path: Path,
+ suffix: bytes,
+) -> None:
+ repo = tmp_path / "repo"
+ _copy_contract_tree(repo)
+ clean = deployment.run_smoke(root=repo, environment="ephemeral_ci")
+ assert clean["carrier_state"] == "clean"
+
+ changed_path = repo / relative_path
+ changed_path.write_bytes(changed_path.read_bytes() + suffix)
+ changed = deployment.run_smoke(root=repo, environment="ephemeral_ci")
+
+ assert changed["source_head_commit"] == clean["source_head_commit"]
+ assert changed["source_head_tree"] == clean["source_head_tree"]
+ assert changed["carrier_state"] == "dirty"
+ assert changed["validated_asset_sha256"][logical_name] != clean["validated_asset_sha256"][logical_name]
+ assert changed["carrier_snapshot_sha256"] != clean["carrier_snapshot_sha256"]
+
+
+def test_carrier_snapshot_hashes_symlink_target_without_following_external_bytes(tmp_path) -> None:
+ repo = tmp_path / "repo"
+ _copy_contract_tree(repo)
+ outside = tmp_path / "outside-secret.txt"
+ outside.write_text("external secret version one\n", encoding="utf-8")
+ os.symlink(outside, repo / "external-link")
+
+ linked = deployment.run_smoke(root=repo, environment="ephemeral_ci")
+ outside.write_text("external secret version two\n", encoding="utf-8")
+ after_external_change = deployment.run_smoke(root=repo, environment="ephemeral_ci")
+
+ assert linked["carrier_state"] == "dirty"
+ assert after_external_change == linked
+ serialized = json.dumps(linked, sort_keys=True)
+ assert "external secret" not in serialized
+ assert str(tmp_path) not in serialized
+
+
+def test_environment_example_is_production_loopback_exact_origin_and_role_separated() -> None:
+ values = _valid_env()
+ guide = _asset(deployment.GUIDE_RELATIVE_PATH)
+
+ deployment.validate_environment(values)
+ deployment.validate_role_separated_database_contract(values, guide)
+
+ assert values["APP_ENV"] == "production"
+ assert values["APP_HOST"] == values["ALICE_WEB_HOST"] == "127.0.0.1"
+ assert values["CORS_ALLOWED_ORIGINS"] == "https://alice.example.com"
+ assert values["NEXT_PUBLIC_ALICEBOT_API_BASE_URL"] == values["CORS_ALLOWED_ORIGINS"]
+ assert values["TRUST_PROXY_HEADERS"] == "true"
+ assert values["TRUSTED_PROXY_IPS"] == "127.0.0.1"
+ assert values["ALICE_LEGACY_SURFACES"] == "0"
+ assert values["LEGACY_V0_ENABLED_OUTSIDE_DEV"] == "false"
+ assert values["ALICEBOT_AUTH_USER_ID"] == "00000000-0000-0000-0000-000000000001"
+ assert values["NEXT_PUBLIC_ALICEBOT_USER_ID"] == values["ALICEBOT_AUTH_USER_ID"]
+ assert "alicebot_app:${ALICEBOT_DB_APP_PASSWORD}" in values["DATABASE_URL"]
+ assert "DATABASE_ADMIN_URL" not in values
+ assert "alicebot_admin:${ALICEBOT_DB_ADMIN_PASSWORD}" in guide
+ assert "sslmode=verify-full" in values["DATABASE_URL"]
+ assert "sslrootcert=/run/secrets/alicebot/postgres-ca.pem" in values["DATABASE_URL"]
+
+
+@pytest.mark.parametrize(
+ ("key", "value", "failure_code"),
+ (
+ ("APP_ENV", "development", "app_env_not_production"),
+ ("APP_HOST", "0.0.0.0", "api_bind_not_loopback"),
+ ("ALICE_WEB_HOST", "0.0.0.0", "web_bind_not_loopback"),
+ ("CORS_ALLOWED_ORIGINS", "*", "cors_origin_invalid"),
+ ("CORS_ALLOWED_ORIGINS", "http://alice.example.com", "cors_origin_invalid"),
+ ("CORS_ALLOWED_ORIGINS", "https://alice.example.com,https://evil.example", "cors_origin_invalid"),
+ ("TRUSTED_PROXY_IPS", "0.0.0.0/0", "trusted_proxy_invalid"),
+ ("TRUST_PROXY_HEADERS", "false", "proxy_headers_not_enabled"),
+ ("LEGACY_V0_ENABLED_OUTSIDE_DEV", "true", "legacy_v0_production_gate_enabled"),
+ ),
+)
+def test_environment_validation_fails_closed_on_network_boundary_drift(
+ key: str,
+ value: str,
+ failure_code: str,
+) -> None:
+ values = _valid_env()
+ values[key] = value
+ if key == "CORS_ALLOWED_ORIGINS":
+ values["PUBLIC_ORIGIN"] = value
+ values["NEXT_PUBLIC_ALICEBOT_API_BASE_URL"] = value
+
+ with pytest.raises(deployment.DeploymentContractError) as exc_info:
+ deployment.validate_environment(values)
+
+ assert exc_info.value.code == failure_code
+
+
+@pytest.mark.parametrize(
+ ("target", "old", "new", "failure_code"),
+ (
+ (
+ "runtime",
+ "sslmode=verify-full",
+ "sslmode=require",
+ "database_tls_invalid",
+ ),
+ (
+ "migration",
+ "sslrootcert=/run/secrets/alicebot/postgres-ca.pem",
+ "sslrootcert=/tmp/untrusted-ca.pem",
+ "database_ca_path_invalid",
+ ),
+ (
+ "runtime",
+ "${ALICEBOT_DB_APP_PASSWORD}",
+ "literal-password",
+ "database_secret_placeholder_invalid",
+ ),
+ (
+ "migration",
+ "alicebot_admin:${ALICEBOT_DB_ADMIN_PASSWORD}",
+ "alicebot_app:${ALICEBOT_DB_ADMIN_PASSWORD}",
+ "database_role_invalid",
+ ),
+ (
+ "runtime",
+ "postgresql://alicebot_app:${ALICEBOT_DB_APP_PASSWORD}@db.alice.internal",
+ "postgresql://[invalid",
+ "database_url_invalid",
+ ),
+ (
+ "migration",
+ "db.alice.internal:5432",
+ "db.alice.internal:5433",
+ "database_endpoints_mismatch",
+ ),
+ ),
+)
+def test_database_contract_rejects_weak_tls_paths_literal_secrets_and_shared_roles(
+ target: str,
+ old: str,
+ new: str,
+ failure_code: str,
+) -> None:
+ values = _valid_env()
+ guide = _asset(deployment.GUIDE_RELATIVE_PATH)
+ if target == "runtime":
+ values["DATABASE_URL"] = values["DATABASE_URL"].replace(old, new)
+ else:
+ guide = guide.replace(old, new, 1)
+
+ with pytest.raises(deployment.DeploymentContractError) as exc_info:
+ deployment.validate_environment(values)
+ deployment.validate_role_separated_database_contract(values, guide)
+
+ assert exc_info.value.code == failure_code
+
+
+def test_runtime_environment_rejects_migration_admin_dsn() -> None:
+ values = _valid_env()
+ values["DATABASE_ADMIN_URL"] = (
+ "postgresql://alicebot_admin:${ALICEBOT_DB_ADMIN_PASSWORD}@db.alice.internal:5432/"
+ "alicebot?sslmode=verify-full&sslrootcert=/run/secrets/alicebot/postgres-ca.pem"
+ )
+
+ with pytest.raises(deployment.DeploymentContractError) as exc_info:
+ deployment.validate_environment(values)
+
+ assert exc_info.value.code == "deprecated_or_inline_secret_setting_present"
+
+
+def test_migration_entrypoint_fails_clearly_without_admin_dsn_or_repo_venv(tmp_path: Path) -> None:
+ repo = tmp_path / "repo"
+ scripts = repo / "scripts"
+ scripts.mkdir(parents=True)
+ shutil.copyfile(ROOT / "scripts" / "migrate.sh", scripts / "migrate.sh")
+ assert not (repo / ".venv").exists()
+
+ environment = os.environ.copy()
+ environment["DATABASE_ADMIN_URL"] = ""
+
+ completed = subprocess.run(
+ ["bash", str(scripts / "migrate.sh")],
+ cwd=repo,
+ env=environment,
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ check=False,
+ )
+
+ assert completed.returncode == 1
+ assert "DATABASE_ADMIN_URL is required for migrations" in completed.stderr
+ assert "Run 'make setup'" not in completed.stderr
+
+
+def test_caddy_example_requires_mtls_and_preserves_real_client_ip() -> None:
+ caddyfile = _asset(deployment.CADDY_RELATIVE_PATH)
+
+ deployment.validate_caddyfile(caddyfile)
+
+ assert "client_auth" in caddyfile
+ assert "mode require_and_verify" in caddyfile
+ assert "trust_pool file /run/secrets/alicebot/client-ca.pem" in caddyfile
+ assert "strict_sni_host on" in caddyfile
+ assert "header_up X-Forwarded-For" not in caddyfile
+ assert "/v0/vnext /v0/vnext/*" in caddyfile
+ assert "/v0/*" not in caddyfile
+ assert "/v1/*" not in caddyfile
+ assert 'Strict-Transport-Security "max-age=31536000; includeSubDomains"' in caddyfile
+ assert 'Content-Security-Policy "frame-ancestors \'none\'"' in caddyfile
+ assert 'X-Frame-Options "DENY"' in caddyfile
+
+
+@pytest.mark.parametrize(
+ ("mutation", "failure_code"),
+ (
+ (
+ lambda text: text.replace("client_auth {", "authentication_disabled {"),
+ "caddy_authentication_missing",
+ ),
+ (
+ lambda text: text.replace("alice.example.com {", "alice.example.com.attacker.invalid {"),
+ "caddy_public_host_missing",
+ ),
+ (
+ lambda text: text.replace("mode require_and_verify", "mode request"),
+ "caddy_mtls_not_fail_closed",
+ ),
+ (
+ lambda text: text.replace(
+ "reverse_proxy 127.0.0.1:8000",
+ "header_up X-Forwarded-For 127.0.0.1\n\t\treverse_proxy 127.0.0.1:8000",
+ ),
+ "caddy_forwarded_client_overridden",
+ ),
+ (
+ lambda text: text.replace("reverse_proxy 127.0.0.1:8000", "reverse_proxy 0.0.0.0:8000"),
+ "caddy_api_upstream_invalid",
+ ),
+ (
+ lambda text: text.replace(
+ "reverse_proxy 127.0.0.1:8000",
+ "reverse_proxy 127.0.0.1:8000.attacker.example",
+ ),
+ "caddy_api_upstream_invalid",
+ ),
+ (
+ lambda text: text.replace(
+ "reverse_proxy 127.0.0.1:3000",
+ "reverse_proxy 127.0.0.1:3000 attacker.example:3000",
+ ),
+ "caddy_web_upstream_invalid",
+ ),
+ (
+ lambda text: text.replace("tls {", "tls internal {", 1),
+ "caddy_public_ca_disabled",
+ ),
+ (
+ lambda text: text.replace("/v0/vnext /v0/vnext/*", "/v0/*"),
+ "caddy_public_api_routes_invalid",
+ ),
+ (
+ lambda text: text.replace("/v0/vnext /v0/vnext/*", "/v0/vnext /v0/vnext/* /v1/*"),
+ "caddy_public_api_routes_invalid",
+ ),
+ (
+ lambda text: text.replace("/v0/vnext /v0/vnext/*", "/v0/vnextish*"),
+ "caddy_public_api_routes_invalid",
+ ),
+ (
+ lambda text: text.replace(
+ 'Strict-Transport-Security "max-age=31536000; includeSubDomains"',
+ 'Strict-Transport-Security "max-age=0"',
+ ),
+ "caddy_hsts_missing",
+ ),
+ (
+ lambda text: text.replace("frame-ancestors 'none'", "frame-ancestors *"),
+ "caddy_clickjacking_defense_missing",
+ ),
+ ),
+)
+def test_caddy_validation_rejects_missing_auth_xff_spoof_and_public_upstreams(
+ mutation,
+ failure_code: str,
+) -> None:
+ caddyfile = mutation(_asset(deployment.CADDY_RELATIVE_PATH))
+
+ with pytest.raises(deployment.DeploymentContractError) as exc_info:
+ deployment.validate_caddyfile(caddyfile)
+
+ assert exc_info.value.code == failure_code
+
+
+def test_workflow_uses_full_action_shas_and_no_unpinned_images() -> None:
+ workflow = _asset(deployment.WORKFLOW_RELATIVE_PATH)
+
+ pins = deployment.validate_supply_chain_pins(workflow)
+
+ assert pins == {"actions": 5, "images": 0}
+ with pytest.raises(deployment.DeploymentContractError) as action_error:
+ deployment.validate_supply_chain_pins(workflow.replace("actions/checkout@9c091", "actions/checkout@v7 # 9c091"))
+ assert action_error.value.code == "workflow_action_unpinned"
+
+ workflow_with_floating_image = workflow.replace(
+ " runs-on: ubuntu-latest",
+ " runs-on: ubuntu-latest\n container:\n image: caddy:2.10",
+ )
+ with pytest.raises(deployment.DeploymentContractError) as image_error:
+ deployment.validate_supply_chain_pins(workflow_with_floating_image)
+ assert image_error.value.code == "workflow_image_unpinned"
+
+
+@pytest.mark.parametrize(
+ "unsafe_report",
+ (
+ {"status": "failed", "detail": "postgresql://alice:secret@db/alice"},
+ {"status": "failed", "detail": "/Users/operator/private/alice.env"},
+ {"status": "failed", "detail": "/tmp/alice-secret"},
+ {"status": "failed", "detail": "alice_sk_example"},
+ ),
+)
+def test_receipt_sanitizer_rejects_secret_and_path_markers(unsafe_report) -> None:
+ with pytest.raises(deployment.DeploymentContractError):
+ deployment._assert_report_safe(unsafe_report)
+
+
+def test_cli_failure_receipt_uses_stable_code_without_secret_or_path(tmp_path, capsys) -> None:
+ contract_root = tmp_path / "deployment-with-secret"
+ _copy_contract_tree(contract_root)
+ env_path = contract_root / deployment.ENV_RELATIVE_PATH
+ env_path.write_text(
+ env_path.read_text(encoding="utf-8").replace("${ALICEBOT_DB_APP_PASSWORD}", "literal-password"),
+ encoding="utf-8",
+ )
+ output = tmp_path / "receipt.json"
+
+ exit_code = deployment.main(
+ [
+ "--root",
+ str(contract_root),
+ "--environment",
+ "ephemeral_ci",
+ "--output",
+ str(output),
+ ]
+ )
+
+ assert exit_code == 1
+ payload = json.loads(capsys.readouterr().out)
+ assert payload["failure_codes"] == ["database_secret_placeholder_invalid"]
+ assert payload["environment"] == "ephemeral_ci"
+ assert payload["cloud_provider"] == "none"
+ assert payload["source_head_commit"] == _git(contract_root, "rev-parse", "HEAD")
+ assert payload["source_head_tree"] == _git(contract_root, "rev-parse", "HEAD^{tree}")
+ assert payload["carrier_state"] == "dirty"
+ assert set(payload["validated_asset_sha256"]) == set(deployment.VALIDATED_ASSETS)
+ serialized = json.dumps(payload)
+ assert "literal-password" not in serialized
+ assert str(tmp_path) not in serialized
+ assert json.loads(output.read_text(encoding="utf-8")) == payload
+ assert output.stat().st_mode & 0o777 == 0o600
+
+
+def test_guide_names_operational_probes_backup_restore_upgrade_and_claim_limits() -> None:
+ guide = _asset(deployment.GUIDE_RELATIVE_PATH)
+
+ deployment.validate_guide(guide)
+
+ assert "A keyless request to the operator workspace must return 401" in guide
+ assert 'ALICEBOT_AUTH_USER_ID}")" = 401' in guide
+ assert "must return 403" not in guide
+ normalized_guide = re.sub(r"\s+", " ", guide)
+ for required in (
+ "Keyless equals local-machine-owner trust",
+ "`/healthz` checks PostgreSQL only",
+ "alicebot --version",
+ "percent-encode each database password as URL userinfo",
+ "keyless request",
+ "external scheduler",
+ "off-host encrypted copy",
+ "disposable restore",
+ "no in-place schema downgrade",
+ "multi-tenant isolation",
+ "SLA",
+ "high availability",
+ "managed backup",
+ "managed alert",
+ "owner_real_host_deployment_receipt",
+ "carrier_snapshot_sha256",
+ "validated_asset_sha256",
+ "/vnext is the only live authenticated browser console",
+ "future BFF or client-side refactor",
+ "Remote /v1 is unsupported",
+ "no-client-certificate rejection",
+ "untrusted-client-certificate rejection",
+ "revoked-client-certificate rejection",
+ "HSTS and clickjacking response headers",
+ "DATABASE_ADMIN_URL is absent from the API runtime environment",
+ "DATABASE_ADMIN_URL is required for migrations",
+ "EnvironmentFile=/run/secrets/alicebot/backup-restore.env",
+ "BindReadOnlyPaths=/run/secrets/alicebot/postgres-ca.pem",
+ "run_phase5_ops_evidence.py --backend postgres",
+ "runtime DB role=alicebot_app",
+ "admin DSN absent from API service environment",
+ "remote-v1-not-api",
+ "remote-non-vnext-v0-not-api",
+ "remote-vnext-lookalike-not-api",
+ ):
+ assert required in normalized_guide
+ assert "--database-admin-url" not in guide
+ assert "--database-url" not in guide
+ assert "systemctl show --property Environment" not in guide
+ assert '"SELECT session_user, current_user"' in guide
+ assert 'session_role != "alicebot_app" or effective_role != "alicebot_app"' in guide
+
+
+def test_workflow_records_ephemeral_non_cloud_truth_and_uploads_only_sanitized_receipt() -> None:
+ workflow = _asset(deployment.WORKFLOW_RELATIVE_PATH)
+
+ assert "--environment ephemeral_ci" in workflow
+ assert "scripts/run_single_tenant_deployment_smoke.py" in workflow
+ assert "tests/unit/test_single_tenant_deployment.py" in workflow
+ assert "pnpm --dir apps/web test -- lib/api.test.ts" in workflow
+ assert "single-tenant-deployment-smoke.json" in workflow
+ assert "if-no-files-found: error" in workflow
+ assert "cloud_provider" not in workflow # The signed JSON report owns this field.
diff --git a/tests/unit/test_sqlite_store.py b/tests/unit/test_sqlite_store.py
index f7e067ab..10714534 100644
--- a/tests/unit/test_sqlite_store.py
+++ b/tests/unit/test_sqlite_store.py
@@ -93,6 +93,7 @@ def test_bootstrap_sqlite_schema_is_idempotent() -> None:
"event_log",
"agent_identities",
"agent_api_keys",
+ "browser_clip_capabilities",
"memories_fts",
"source_chunks_fts",
):
diff --git a/tests/unit/test_stage_a_vnext_auth_surface.py b/tests/unit/test_stage_a_vnext_auth_surface.py
new file mode 100644
index 00000000..249d3155
--- /dev/null
+++ b/tests/unit/test_stage_a_vnext_auth_surface.py
@@ -0,0 +1,258 @@
+from __future__ import annotations
+
+import json
+import logging
+import re
+from typing import Any
+from urllib.parse import urlencode
+from uuid import UUID, uuid4
+
+import anyio
+import pytest
+
+import alicebot_api.main as main_module
+from alicebot_api.config import Settings
+from alicebot_api.vnext_agent_keys import resolve_protected_agent_identity
+
+
+_AUTHENTICATION_FAILED = {
+ "detail": {
+ "code": "authentication_failed",
+ "message": "Authentication failed",
+ }
+}
+
+# Every locally-authorized route must have a deliberate target/scope source.
+# The central middleware inventory alone cannot catch accidentally moving a
+# new endpoint into the local-policy set without deciding where its policy
+# inputs come from.
+_LOCAL_POLICY_ENFORCEMENT_FAMILIES = {
+ "request_scope": {
+ ("POST", "/v0/vnext/sources"),
+ ("POST", "/v0/vnext/agents/ingest-output"),
+ ("POST", "/v0/vnext/context-packs"),
+ ("POST", "/v0/vnext/memory-proposals"),
+ ("POST", "/v0/vnext/memories/commit"),
+ ("POST", "/v0/vnext/artifacts/generate/daily-brief"),
+ ("POST", "/v0/vnext/artifacts/generate/weekly-synthesis"),
+ ("POST", "/v0/vnext/artifacts/generate/connections"),
+ ("POST", "/v0/vnext/artifacts/generate/contradictions"),
+ ("POST", "/v0/vnext/queue/tasks"),
+ ("POST", "/v0/vnext/projects/update-candidates"),
+ ("POST", "/v0/vnext/open-loops"),
+ },
+ "persisted_memory_scope": {
+ ("POST", "/v0/vnext/memories/{memory_id}/review"),
+ ("POST", "/v0/vnext/memories/confirm"),
+ ("POST", "/v0/vnext/memories/undo"),
+ ("POST", "/v0/vnext/memories/correct"),
+ ("POST", "/v0/vnext/memories/forget"),
+ ("POST", "/v0/vnext/memories/expire"),
+ ("POST", "/v0/vnext/memories/unexpire"),
+ ("POST", "/v0/vnext/memories/accept-consolidation"),
+ ("POST", "/v0/vnext/memories/redact"),
+ },
+ "persisted_artifact_scope": {
+ ("POST", "/v0/vnext/artifacts/{artifact_id}/insight-feedback"),
+ ("GET", "/v0/vnext/artifacts/{artifact_id}"),
+ ("GET", "/v0/vnext/traces/artifacts/{artifact_id}"),
+ ("POST", "/v0/vnext/artifacts/{artifact_id}/export"),
+ ("POST", "/v0/vnext/artifacts/{artifact_id}/review"),
+ ("POST", "/v0/vnext/artifacts/{artifact_id}/quality-ratings"),
+ },
+ "persisted_open_loop_scope": {
+ ("POST", "/v0/vnext/open-loops/{loop_id}/review"),
+ },
+ "scheduler_or_global_control": {
+ ("PATCH", "/v0/vnext/scheduler/workflows/{workflow_type}"),
+ ("POST", "/v0/vnext/scheduler/workflows/{workflow_type}/run-now"),
+ ("POST", "/v0/vnext/scheduler/run-due"),
+ ("POST", "/v0/vnext/scheduler/pause"),
+ ("POST", "/v0/vnext/scheduler/resume"),
+ },
+}
+
+
+class _ActiveKeyStore:
+ """Small authentication store with one active key and no valid candidates."""
+
+ def __init__(self) -> None:
+ self.events: list[dict[str, object]] = []
+
+ def count_active_agent_api_keys(self) -> int:
+ return 1
+
+ def get_agent_api_key_by_hash(self, _key_hash: str) -> None:
+ return None
+
+ def touch_agent_api_key(self, *, key_id: str) -> dict[str, object]:
+ raise AssertionError(f"unexpected key touch: {key_id}")
+
+ def append_event(self, event: dict[str, object]) -> dict[str, object]:
+ self.events.append(event)
+ return event
+
+
+def _registered_vnext_routes() -> set[tuple[str, str]]:
+ routes: list[object] = []
+ for route in main_module.app.router.routes:
+ effective_route_contexts = getattr(route, "effective_route_contexts", None)
+ routes.extend(effective_route_contexts() if callable(effective_route_contexts) else (route,))
+ return {
+ (method, str(getattr(route, "path")))
+ for route in routes
+ if str(getattr(route, "path", "")).startswith("/v0/vnext")
+ for method in (getattr(route, "methods", None) or set())
+ if method != "OPTIONS"
+ }
+
+
+def _materialize_path(path_template: str) -> str:
+ named_values = {
+ "connector_name": "telegram",
+ "workflow_type": "daily_brief",
+ }
+
+ def replacement(match: re.Match[str]) -> str:
+ return named_values.get(match.group(1), str(uuid4()))
+
+ return re.sub(r"\{([^{}]+)\}", replacement, path_template)
+
+
+def _invoke_vnext_request(
+ method: str,
+ path: str,
+ *,
+ user_id: UUID,
+ authorization: str | None = None,
+) -> tuple[int, dict[str, Any]]:
+ messages: list[dict[str, object]] = []
+ payload = {} if method in {"GET", "HEAD"} else {"user_id": str(user_id)}
+ body = b"" if not payload else json.dumps(payload).encode("utf-8")
+ query = {"user_id": str(user_id)} if method in {"GET", "HEAD"} else {}
+ received = False
+
+ async def receive() -> dict[str, object]:
+ nonlocal received
+ if received:
+ return {"type": "http.disconnect"}
+ received = True
+ return {"type": "http.request", "body": body, "more_body": False}
+
+ async def send(message: dict[str, object]) -> None:
+ messages.append(message)
+
+ headers: list[tuple[bytes, bytes]] = [(b"content-type", b"application/json")]
+ if authorization is not None:
+ headers.append((b"authorization", authorization.encode("utf-8")))
+ scope = {
+ "type": "http",
+ "asgi": {"version": "3.0"},
+ "http_version": "1.1",
+ "method": method,
+ "scheme": "http",
+ "path": path,
+ "raw_path": path.encode("utf-8"),
+ "query_string": urlencode(query).encode("ascii"),
+ "headers": headers,
+ "client": ("stage-a-auth-sweep", 50000),
+ "server": ("testserver", 80),
+ "root_path": "",
+ }
+ anyio.run(main_module.app, scope, receive, send)
+
+ start = next(message for message in messages if message["type"] == "http.response.start")
+ response_body = b"".join(
+ message.get("body", b"")
+ for message in messages
+ if message["type"] == "http.response.body"
+ )
+ return int(start["status"]), json.loads(response_body)
+
+
+@pytest.fixture
+def active_key_auth_resolver(monkeypatch: pytest.MonkeyPatch) -> _ActiveKeyStore:
+ store = _ActiveKeyStore()
+
+ def resolve_auth(
+ *,
+ settings: object,
+ user_id: UUID,
+ raw_key: str | None,
+ payload: dict[str, object],
+ method: str,
+ route_path: str,
+ ) -> tuple[object, object]:
+ del settings
+ identity = resolve_protected_agent_identity(
+ store,
+ user_id=user_id,
+ raw_key=raw_key,
+ payload=payload,
+ )
+ return identity, main_module._vnext_central_route_policy(
+ identity=identity,
+ method=method,
+ route_path=route_path,
+ )
+
+ monkeypatch.setattr(
+ main_module,
+ "get_settings",
+ lambda: Settings(app_env="test", database_url="postgresql://stage-a-unused"),
+ )
+ monkeypatch.setattr(main_module, "_resolve_vnext_http_auth", resolve_auth)
+ return store
+
+
+def test_every_registered_vnext_route_rejects_keyless_requests_after_key_provisioning(
+ active_key_auth_resolver: _ActiveKeyStore,
+ caplog: pytest.LogCaptureFixture,
+) -> None:
+ del active_key_auth_resolver
+ caplog.set_level(logging.CRITICAL)
+ registered = _registered_vnext_routes()
+ classified = main_module._VNEXT_ROUTE_LOCAL_POLICY | main_module._VNEXT_CENTRAL_OPERATOR_ROUTES
+
+ assert registered == classified
+ assert not (main_module._VNEXT_ROUTE_LOCAL_POLICY & main_module._VNEXT_CENTRAL_OPERATOR_ROUTES)
+
+ user_id = uuid4()
+ for method, path_template in sorted(registered):
+ status, payload = _invoke_vnext_request(
+ method,
+ _materialize_path(path_template),
+ user_id=user_id,
+ )
+ assert (status, payload) == (401, _AUTHENTICATION_FAILED), (method, path_template)
+
+
+def test_every_local_policy_route_has_one_declared_scope_enforcement_family() -> None:
+ family_routes = [
+ route
+ for routes in _LOCAL_POLICY_ENFORCEMENT_FAMILIES.values()
+ for route in routes
+ ]
+
+ assert len(family_routes) == len(set(family_routes))
+ assert set(family_routes) == set(main_module._VNEXT_ROUTE_LOCAL_POLICY)
+
+
+def test_invalid_agent_key_is_absent_from_public_error_logs_and_audit_events(
+ active_key_auth_resolver: _ActiveKeyStore,
+ caplog: pytest.LogCaptureFixture,
+) -> None:
+ raw_key = f"alice_sk_STAGE_A_AGENT_SECRET_{uuid4().hex}"
+
+ with caplog.at_level(logging.ERROR, logger="alicebot_api.public_errors"):
+ status, payload = _invoke_vnext_request(
+ "GET",
+ "/v0/vnext/projects",
+ user_id=uuid4(),
+ authorization=f"Bearer {raw_key}",
+ )
+
+ assert (status, payload) == (401, _AUTHENTICATION_FAILED)
+ assert raw_key not in json.dumps(payload, sort_keys=True)
+ assert raw_key not in caplog.text
+ assert raw_key not in json.dumps(active_key_auth_resolver.events, sort_keys=True)
diff --git a/tests/unit/test_store_events_revisions_split.py b/tests/unit/test_store_events_revisions_split.py
index 479beded..78e2cfc7 100644
--- a/tests/unit/test_store_events_revisions_split.py
+++ b/tests/unit/test_store_events_revisions_split.py
@@ -139,8 +139,9 @@
},
}
EXPECTED_CLASS_KEY_SHA256 = {
- "postgres": "cc60702b43d219abe4938d798376370ffa5b986959c6d0ce63ae8530bb237aeb",
- "sqlite": "0744bbc5dfdc2906da0c650585945e47482a59e5c0b50c71bf0bd30dd92eba15",
+ # Re-minted for the paired browser-clip capability façade methods.
+ "postgres": "88174a48507e75d260fa597319e8baec273a663b0036899d70c9550138bc6046",
+ "sqlite": "25cc77d828edc52ce8a1bea7ecc0964b9b9ad9a6656b6e0c37f4a012a03f2d8f",
}
EXPECTED_SUPPORT_AST_SHA256 = {
"postgres_columns": {
diff --git a/tests/unit/test_store_graph_open_loops_split.py b/tests/unit/test_store_graph_open_loops_split.py
index 7ca7b4de..7ff3adb4 100644
--- a/tests/unit/test_store_graph_open_loops_split.py
+++ b/tests/unit/test_store_graph_open_loops_split.py
@@ -122,8 +122,9 @@
SQLITE_CARRIER_PATH: (1, "8f448801a348111594f3d0f33c9e82756981958985c270d45a8716914892d71b"),
}
EXPECTED_CLASS_ORDERS = {
- "PostgresVNextStore": (168, "00e1d9bab613f90665fb2f7666bfff3be1cdc3d96f925614b0bf7e04cecce638"),
- "SQLiteVNextStore": (121, "2c0b5694a015fe7939e91bb5572145b8bf82598edfcbb579e51b4175c392e2e5"),
+ # Two paired browser-clip capability methods extend both façades.
+ "PostgresVNextStore": (170, "5f28f1a17670a0c8b7b373acd0c314637c58e6a10ccf52053481a8a028bb3c09"),
+ "SQLiteVNextStore": (123, "72cbbffacc5fee804508f7e9517450c955f2c85235bd403d4f5759ee86c103e3"),
}
EXPECTED_COLUMN_AST = {
POSTGRES_COLUMNS_PATH: {
diff --git a/tests/unit/test_store_memory_access_split.py b/tests/unit/test_store_memory_access_split.py
index 5b58a357..55a712cf 100644
--- a/tests/unit/test_store_memory_access_split.py
+++ b/tests/unit/test_store_memory_access_split.py
@@ -157,8 +157,9 @@
)
EXPECTED_CLASS_ORDERS = {
- "PostgresVNextStore": (168, "00e1d9bab613f90665fb2f7666bfff3be1cdc3d96f925614b0bf7e04cecce638"),
- "SQLiteVNextStore": (121, "2c0b5694a015fe7939e91bb5572145b8bf82598edfcbb579e51b4175c392e2e5"),
+ # Two paired browser-clip capability methods extend both façades.
+ "PostgresVNextStore": (170, "5f28f1a17670a0c8b7b373acd0c314637c58e6a10ccf52053481a8a028bb3c09"),
+ "SQLiteVNextStore": (123, "72cbbffacc5fee804508f7e9517450c955f2c85235bd403d4f5759ee86c103e3"),
}
diff --git a/tests/unit/test_store_memory_lifecycle_split.py b/tests/unit/test_store_memory_lifecycle_split.py
index ae7ef21b..3f519c1e 100644
--- a/tests/unit/test_store_memory_lifecycle_split.py
+++ b/tests/unit/test_store_memory_lifecycle_split.py
@@ -88,8 +88,9 @@
"sqlite": "9a5a4a9f0ae533652250a9e9854cd34a068392d71ffde98b012c9c620134d2c4",
}
EXPECTED_CLASS_ORDERS = {
- "PostgresVNextStore": (168, "00e1d9bab613f90665fb2f7666bfff3be1cdc3d96f925614b0bf7e04cecce638"),
- "SQLiteVNextStore": (121, "2c0b5694a015fe7939e91bb5572145b8bf82598edfcbb579e51b4175c392e2e5"),
+ # Two paired browser-clip capability methods extend both façades.
+ "PostgresVNextStore": (170, "5f28f1a17670a0c8b7b373acd0c314637c58e6a10ccf52053481a8a028bb3c09"),
+ "SQLiteVNextStore": (123, "72cbbffacc5fee804508f7e9517450c955f2c85235bd403d4f5759ee86c103e3"),
}
EXPECTED_FACADE_COMMENT_DIGESTS = {
POSTGRES_FACADE_PATH: "d8599a46ee26dc35a3ae52c1a98a416509add9ae4a42ece780c5c5ed7e132b93",
diff --git a/tests/unit/test_surface_gates.py b/tests/unit/test_surface_gates.py
index 7218ab14..e23a1288 100644
--- a/tests/unit/test_surface_gates.py
+++ b/tests/unit/test_surface_gates.py
@@ -94,7 +94,7 @@ def _isolated_proxy_execution_posture(flag_value: str | None) -> dict[str, objec
@pytest.mark.parametrize("flag_value", [None, "", "0", "true", "yes", "on", "01", " 1"])
def test_http_legacy_surface_gate_fails_closed_for_every_non_exact_value(flag_value: str | None) -> None:
assert _isolated_http_inventory(flag_value) == {
- "count": 182,
+ "count": 183,
"legacy_count": 0,
"removed_count": 0,
"runtime_invoke_count": 1,
@@ -103,7 +103,7 @@ def test_http_legacy_surface_gate_fails_closed_for_every_non_exact_value(flag_va
def test_http_legacy_surface_gate_mounts_exact_inventory_only_for_one() -> None:
assert _isolated_http_inventory("1") == {
- "count": 231,
+ "count": 232,
"legacy_count": 49,
"removed_count": 0,
"runtime_invoke_count": 1,
@@ -161,7 +161,7 @@ def inventory():
text=True,
)
- expected_inventory = {"count": 182, "legacy_count": 0}
+ expected_inventory = {"count": 183, "legacy_count": 0}
assert json.loads(completed.stdout) == {
"before": expected_inventory,
"after": expected_inventory,
diff --git a/tests/unit/test_vnext_agent_keys.py b/tests/unit/test_vnext_agent_keys.py
index c1f4f539..0e8fd995 100644
--- a/tests/unit/test_vnext_agent_keys.py
+++ b/tests/unit/test_vnext_agent_keys.py
@@ -166,19 +166,31 @@ def test_resolve_agent_identity_allows_profile_downgrade() -> None:
assert identity.permission_profile == "read_only_agent"
-def test_resolve_agent_identity_rejects_profile_escalation_and_audits() -> None:
+@pytest.mark.parametrize("identity_namespace", [None, "agent", "agent_identity"])
+def test_resolve_agent_identity_rejects_profile_escalation_and_audits(
+ identity_namespace: str | None,
+) -> None:
store = FakeAgentKeyStore()
user_id = uuid4()
_record, raw_key = create_agent_key(
store, user_id=user_id, agent_id="openclaw", permission_profile="project_scoped_agent"
)
+ claimed_identity = {
+ "agent_id": "openclaw",
+ "permission_profile": "admin_agent",
+ }
+ payload = (
+ claimed_identity
+ if identity_namespace is None
+ else {identity_namespace: claimed_identity}
+ )
with pytest.raises(AgentKeyAuthenticationError) as exc_info:
resolve_agent_identity(
store,
user_id=user_id,
raw_key=raw_key,
- payload={"agent_id": "openclaw", "permission_profile": "admin_agent"},
+ payload=payload,
)
assert exc_info.value.status_code == 403
diff --git a/tests/unit/test_vnext_connectors.py b/tests/unit/test_vnext_connectors.py
index fe343009..825acfe3 100644
--- a/tests/unit/test_vnext_connectors.py
+++ b/tests/unit/test_vnext_connectors.py
@@ -494,6 +494,42 @@ def test_browser_clip_capture_token_is_redacted_and_enforced() -> None:
assert "clip-token" not in str(store.events)
+def test_browser_clip_capability_value_cannot_be_smuggled_into_persisted_content(caplog) -> None:
+ store = InMemoryConnectorSettingsStore()
+ capability = f"alice_clip_{'S' * 43}"
+
+ result = VNextConnectorService(store).capture_browser_clip(
+ {
+ "url": f"https://example.test/article?capability={capability}",
+ "title": f"Title {capability}",
+ "selected_text": f"Selected {capability}",
+ "page_text": f"Page {capability}",
+ "user_note": f"Note {capability}",
+ "capture_capability": capability,
+ "domain": "professional",
+ "sensitivity": "private",
+ },
+ capability_authorized=True,
+ )
+
+ persisted_and_returned = {
+ "sources": store.sources,
+ "chunks": store.chunks,
+ "memories": store.memories,
+ "events": store.events,
+ "state": store.connector_state,
+ "result": result.to_record(),
+ "logs": caplog.text,
+ }
+ serialized = str(persisted_and_returned)
+ assert capability not in serialized
+ assert "Selected ***" in serialized
+ assert "Page ***" in serialized
+ assert "Note ***" in serialized
+ assert "Title ***" in serialized
+ assert "capability=***" in serialized
+
+
def test_agent_output_ingestion_creates_review_only_artifact_and_memory_proposal() -> None:
store = InMemoryVNextConnectorStore()
diff --git a/tests/unit/test_vnext_main.py b/tests/unit/test_vnext_main.py
index 4fe10bcf..792e8b84 100644
--- a/tests/unit/test_vnext_main.py
+++ b/tests/unit/test_vnext_main.py
@@ -2,6 +2,7 @@
from contextlib import contextmanager
from copy import deepcopy
+from datetime import UTC, datetime, timedelta
import json
from pathlib import Path
import sqlite3
@@ -51,8 +52,40 @@ def __init__(self, _conn) -> None:
self.projects: dict[str, dict[str, object]] = {}
self.agent_identities: dict[str, dict[str, object]] = {}
self.agent_api_keys: list[dict[str, object]] = []
+ self.browser_clip_capabilities: dict[str, dict[str, object]] = {}
self.revisions: list[dict[str, object]] = []
+ def create_browser_clip_capability(
+ self,
+ *,
+ capability_hash: str,
+ origin: str,
+ ttl_seconds: int,
+ ) -> dict[str, object]:
+ row = {
+ "id": str(uuid4()),
+ "origin": origin,
+ "expires_at": datetime.now(UTC) + timedelta(seconds=ttl_seconds),
+ "consumed_at": None,
+ }
+ self.browser_clip_capabilities[capability_hash] = row
+ return dict(row)
+
+ def consume_browser_clip_capability(
+ self,
+ *,
+ capability_hash: str,
+ origin: str,
+ ) -> dict[str, object] | None:
+ row = self.browser_clip_capabilities.get(capability_hash)
+ if row is None or row["origin"] != origin or row["consumed_at"] is not None:
+ return None
+ expires_at = row["expires_at"]
+ if not isinstance(expires_at, datetime) or expires_at <= datetime.now(UTC):
+ return None
+ row["consumed_at"] = datetime.now(UTC)
+ return dict(row)
+
def append_event(self, event: dict[str, object]) -> dict[str, object]:
self.events.append(event)
return event
@@ -872,6 +905,8 @@ def _invoke_vnext_request(
query: dict[str, str] | None = None,
payload: dict[str, object] | None = None,
authorization: str | None = None,
+ content_type: str = "application/json",
+ origin: str | None = None,
) -> tuple[int, dict[str, object]]:
messages: list[dict[str, object]] = []
body = b"" if payload is None else json.dumps(payload).encode()
@@ -887,9 +922,11 @@ async def receive() -> dict[str, object]:
async def send(message: dict[str, object]) -> None:
messages.append(message)
- headers = [(b"content-type", b"application/json")]
+ headers = [(b"content-type", content_type.encode())]
if authorization is not None:
headers.append((b"authorization", authorization.encode()))
+ if origin is not None:
+ headers.append((b"origin", origin.encode()))
scope = {
"type": "http",
"asgi": {"version": "3.0"},
@@ -912,6 +949,58 @@ async def send(message: dict[str, object]) -> None:
return int(start["status"]), json.loads(response_body)
+@pytest.mark.parametrize("content_type", ("application/json", "text/plain;charset=UTF-8"))
+@pytest.mark.parametrize(
+ "payload_factory",
+ (
+ lambda user_id, capability: {
+ "user_id": user_id,
+ "capture_capability": capability,
+ },
+ lambda user_id, _capability: {
+ "user_id": user_id,
+ "url": "https://example.test/article",
+ "capture_capability": f"alice_clip_{'S' * 191}",
+ },
+ lambda user_id, capability: {
+ "user_id": user_id,
+ "url": {"unexpected": "object"},
+ "capture_capability": capability,
+ },
+ ),
+)
+def test_browser_clip_validation_errors_never_echo_capability(
+ monkeypatch,
+ content_type: str,
+ payload_factory,
+) -> None:
+ _install_fake_vnext_store(monkeypatch, FakeVNextStore(None))
+ capability = f"alice_clip_{'S' * 43}"
+ payload = payload_factory(str(uuid4()), capability)
+
+ status, response_payload = _invoke_vnext_request(
+ "POST",
+ "/v0/vnext/connectors/browser-clipper/capture",
+ payload=payload,
+ content_type=content_type,
+ origin="https://example.test",
+ )
+
+ serialized_response = json.dumps(response_payload, sort_keys=True)
+ assert status == 422
+ assert response_payload == {
+ "detail": [
+ {
+ "type": "value_error",
+ "loc": ["body"],
+ "msg": "Input validation failed",
+ }
+ ]
+ }
+ assert "alice_clip_" not in serialized_response
+ assert "S" * 43 not in serialized_response
+
+
def test_vnext_source_http_rejects_invalid_domain_and_sensitivity_enums(monkeypatch) -> None:
_install_fake_vnext_store(monkeypatch, FakeVNextStore(None))
user_id = str(uuid4())
@@ -1108,7 +1197,7 @@ def test_vnext_route_inventory_fails_closed_without_route_local_policy() -> None
}
assert not (main_module._VNEXT_ROUTE_LOCAL_POLICY & main_module._VNEXT_CENTRAL_OPERATOR_ROUTES)
assert (main_module._VNEXT_ROUTE_LOCAL_POLICY | main_module._VNEXT_CENTRAL_OPERATOR_ROUTES) == registered
- assert len(registered) == 70
+ assert len(registered) == 71
project_bound = main_module.AgentIdentity(
agent_id="project-reader",
@@ -1246,6 +1335,7 @@ def test_vnext_memories_router_partitions_preserve_global_route_sequence() -> No
("POST", "/v0/vnext/connectors/telegram/sync"),
("POST", "/v0/vnext/connectors/local-folder/sync"),
("POST", "/v0/vnext/connectors/browser-clipper/capture"),
+ ("POST", "/v0/vnext/connectors/browser-clipper/capabilities"),
("POST", "/v0/vnext/agents/ingest-output"),
("GET", "/v0/vnext/dogfooding"),
("GET", "/v0/vnext/doctor"),
@@ -3316,6 +3406,84 @@ def test_live_capture_connector_api_endpoints(monkeypatch) -> None:
assert any(item["connector_name"] == "telegram" for item in health_payload["items"])
+def test_browser_clip_capability_is_one_time_origin_bound_and_bypasses_only_capture_auth(monkeypatch) -> None:
+ from alicebot_api.vnext_agent_keys import create_agent_key
+
+ store = FakeVNextStore(None)
+ _install_fake_vnext_store(monkeypatch, store)
+ user_id = uuid4()
+ _key_record, raw_agent_key = create_agent_key(
+ store,
+ user_id=user_id,
+ agent_id="browser-clip-issuer",
+ permission_profile="trusted_local_agent",
+ )
+ issue_payload = {"user_id": str(user_id), "origin": "https://example.test"}
+
+ assert (
+ _invoke_vnext_request(
+ "POST",
+ "/v0/vnext/connectors/browser-clipper/capabilities",
+ payload=issue_payload,
+ )[0]
+ == 401
+ )
+ issued_status, issued_payload = _invoke_vnext_request(
+ "POST",
+ "/v0/vnext/connectors/browser-clipper/capabilities",
+ payload=issue_payload,
+ authorization=f"Bearer {raw_agent_key}",
+ )
+ assert issued_status == 201
+ capability = str(issued_payload["capability"])
+ assert capability.startswith("alice_clip_")
+ assert capability not in repr(store.browser_clip_capabilities)
+
+ clip_payload = {
+ "user_id": str(user_id),
+ "url": "https://example.test/article",
+ "selected_text": "Fact: one-time browser capabilities are narrow.",
+ "capture_capability": capability,
+ }
+ captured_status, captured_payload = _invoke_vnext_request(
+ "POST",
+ "/v0/vnext/connectors/browser-clipper/capture",
+ payload=clip_payload,
+ content_type="text/plain;charset=UTF-8",
+ origin="https://example.test",
+ )
+ replay_status, _replay_payload = _invoke_vnext_request(
+ "POST",
+ "/v0/vnext/connectors/browser-clipper/capture",
+ payload=clip_payload,
+ content_type="text/plain;charset=UTF-8",
+ origin="https://example.test",
+ )
+
+ assert captured_status == 201, captured_payload
+ assert captured_payload["imported_count"] == 1
+ assert replay_status == 400
+ assert len(store.sources) == 1
+ assert capability not in json.dumps(store.sources, default=str)
+ assert capability not in json.dumps(store.events, default=str)
+
+
+def test_browser_clip_capability_response_is_not_cacheable(monkeypatch) -> None:
+ store = FakeVNextStore(None)
+ _install_fake_vnext_store(monkeypatch, store)
+
+ response = vnext_memories_router.create_vnext_browser_clip_capability(
+ vnext_memories_router.VNextBrowserClipperCapabilityRequest(
+ user_id=uuid4(),
+ origin="https://example.test",
+ )
+ )
+
+ assert response.status_code == 201
+ assert response.headers["cache-control"] == "no-store"
+ assert response.headers["pragma"] == "no-cache"
+
+
def test_local_folder_external_io_runs_without_database_connection(monkeypatch, tmp_path) -> None:
store = FakeVNextStore(None)
_install_fake_vnext_store(monkeypatch, store)
diff --git a/tests/unit/test_vnext_production_proxy_auth.py b/tests/unit/test_vnext_production_proxy_auth.py
new file mode 100644
index 00000000..d34057a1
--- /dev/null
+++ b/tests/unit/test_vnext_production_proxy_auth.py
@@ -0,0 +1,261 @@
+from __future__ import annotations
+
+import asyncio
+from contextlib import contextmanager
+import json
+from urllib.parse import urlencode
+from uuid import UUID, uuid4
+
+import pytest
+from fastapi import Request, Response
+
+import alicebot_api.main as main_module
+from alicebot_api.config import Settings
+from alicebot_api.vnext_agent_keys import hash_agent_key
+
+
+REMOTE_CLIENT = "203.0.113.10"
+TRUSTED_PROXY = "127.0.0.1"
+
+
+def _request(
+ path: str,
+ *,
+ query: dict[str, str] | None = None,
+ authorization: str | None = None,
+) -> Request:
+ headers = [(b"x-forwarded-for", REMOTE_CLIENT.encode("ascii"))]
+ if authorization is not None:
+ headers.append((b"authorization", authorization.encode("ascii")))
+
+ async def receive() -> dict[str, object]:
+ return {"type": "http.request", "body": b"", "more_body": False}
+
+ return Request(
+ {
+ "type": "http",
+ "asgi": {"version": "3.0"},
+ "http_version": "1.1",
+ "method": "GET",
+ "scheme": "https",
+ "path": path,
+ "raw_path": path.encode("ascii"),
+ "query_string": urlencode(query or {}).encode("ascii"),
+ "headers": headers,
+ "client": (TRUSTED_PROXY, 43120),
+ "server": ("alice.example.test", 443),
+ "root_path": "",
+ },
+ receive,
+ )
+
+
+def _settings(
+ user_id: UUID,
+ *,
+ legacy_v0_enabled_outside_dev: bool,
+) -> Settings:
+ return Settings(
+ app_env="production",
+ database_url="postgresql://db",
+ auth_user_id=str(user_id),
+ legacy_v0_enabled_outside_dev=legacy_v0_enabled_outside_dev,
+ trust_proxy_headers=True,
+ trusted_proxy_ips=(TRUSTED_PROXY,),
+ )
+
+
+@pytest.mark.parametrize("path", ["/v0/vnext", "/v0/vnext/", "/v0/vnext/projects"])
+@pytest.mark.parametrize("legacy_v0_enabled_outside_dev", [False, True])
+def test_remote_production_vnext_skips_only_legacy_network_gate_and_injects_user(
+ monkeypatch: pytest.MonkeyPatch,
+ path: str,
+ legacy_v0_enabled_outside_dev: bool,
+) -> None:
+ user_id = uuid4()
+ monkeypatch.setattr(
+ main_module,
+ "get_settings",
+ lambda: _settings(
+ user_id,
+ legacy_v0_enabled_outside_dev=legacy_v0_enabled_outside_dev,
+ ),
+ )
+ observed: dict[str, object] = {}
+
+ async def call_next(request: Request) -> Response:
+ observed["user_id"] = request.query_params.get("user_id")
+ observed["state_user_id"] = request.state.authenticated_user_id
+ return Response(status_code=204)
+
+ response = asyncio.run(main_module.enforce_authenticated_user_identity(_request(path), call_next))
+
+ assert response.status_code == 204
+ assert observed == {
+ "user_id": str(user_id),
+ "state_user_id": str(user_id),
+ }
+
+
+def test_remote_production_vnext_still_rejects_configured_user_mismatch(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ user_id = uuid4()
+ monkeypatch.setattr(
+ main_module,
+ "get_settings",
+ lambda: _settings(user_id, legacy_v0_enabled_outside_dev=False),
+ )
+ downstream_called = False
+
+ async def call_next(_: Request) -> Response:
+ nonlocal downstream_called
+ downstream_called = True
+ return Response(status_code=204)
+
+ response = asyncio.run(
+ main_module.enforce_authenticated_user_identity(
+ _request(
+ "/v0/vnext/projects",
+ query={"user_id": str(uuid4())},
+ ),
+ call_next,
+ )
+ )
+
+ assert response.status_code == 401
+ assert json.loads(response.body) == {
+ "detail": {
+ "code": "authentication_failed",
+ "message": "Authentication failed",
+ }
+ }
+ assert downstream_called is False
+
+
+@pytest.mark.parametrize(
+ "path",
+ [
+ "/v0/vnextish",
+ "/v0/vnext-preview/status",
+ "/v0/threads",
+ ],
+)
+def test_remote_production_vnext_lookalikes_and_legacy_paths_remain_disabled(
+ monkeypatch: pytest.MonkeyPatch,
+ path: str,
+) -> None:
+ monkeypatch.setattr(
+ main_module,
+ "get_settings",
+ lambda: _settings(uuid4(), legacy_v0_enabled_outside_dev=False),
+ )
+
+ async def call_next(_: Request) -> Response:
+ raise AssertionError("disabled legacy paths must not reach downstream")
+
+ response = asyncio.run(main_module.enforce_authenticated_user_identity(_request(path), call_next))
+
+ assert response.status_code == 404
+ assert json.loads(response.body) == {"detail": "legacy v0 API is disabled outside development and test"}
+
+
+@pytest.mark.parametrize("path", ["/v0/vnextish", "/v0/threads"])
+def test_remote_production_legacy_paths_remain_loopback_only_when_enabled(
+ monkeypatch: pytest.MonkeyPatch,
+ path: str,
+) -> None:
+ monkeypatch.setattr(
+ main_module,
+ "get_settings",
+ lambda: _settings(uuid4(), legacy_v0_enabled_outside_dev=True),
+ )
+
+ async def call_next(_: Request) -> Response:
+ raise AssertionError("remote legacy paths must not reach downstream")
+
+ response = asyncio.run(main_module.enforce_authenticated_user_identity(_request(path), call_next))
+
+ assert response.status_code == 403
+ assert json.loads(response.body) == {"detail": "legacy v0 API is restricted to loopback clients"}
+
+
+class _ActiveKeyStore:
+ def __init__(self, *, user_id: UUID, raw_key: str) -> None:
+ self.record: dict[str, object] = {
+ "id": str(uuid4()),
+ "user_id": str(user_id),
+ "agent_id": "remote-operator",
+ "permission_profile": "trusted_local_agent",
+ "project_scope": None,
+ "key_hash": hash_agent_key(raw_key),
+ "key_prefix": raw_key[:12],
+ "revoked_at": None,
+ }
+
+ def count_active_agent_api_keys(self) -> int:
+ return 1
+
+ def get_agent_api_key_by_hash(self, key_hash: str) -> dict[str, object] | None:
+ if key_hash == self.record["key_hash"]:
+ return self.record
+ return None
+
+ def touch_agent_api_key(self, *, key_id: str) -> dict[str, object]:
+ assert key_id == self.record["id"]
+ return self.record
+
+
+def test_remote_production_vnext_still_requires_valid_bearer_after_user_injection(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ user_id = uuid4()
+ raw_key = "alice_sk_remote-production-key"
+ store = _ActiveKeyStore(user_id=user_id, raw_key=raw_key)
+ settings = _settings(user_id, legacy_v0_enabled_outside_dev=False)
+ monkeypatch.setattr(main_module, "get_settings", lambda: settings)
+
+ @contextmanager
+ def fake_user_connection(database_url: str, current_user_id: UUID):
+ assert database_url == settings.database_url
+ assert current_user_id == user_id
+ yield object()
+
+ monkeypatch.setattr(main_module, "user_connection", fake_user_connection)
+ monkeypatch.setattr(main_module, "PostgresVNextStore", lambda _conn: store)
+
+ downstream_calls = 0
+
+ async def downstream(_: Request) -> Response:
+ nonlocal downstream_calls
+ downstream_calls += 1
+ return Response(status_code=204)
+
+ async def through_vnext_auth(request: Request) -> Response:
+ return await main_module._vnext_protected_http_auth(request, downstream)
+
+ keyless = asyncio.run(
+ main_module.enforce_authenticated_user_identity(
+ _request("/v0/vnext/projects"),
+ through_vnext_auth,
+ )
+ )
+ keyed = asyncio.run(
+ main_module.enforce_authenticated_user_identity(
+ _request(
+ "/v0/vnext/projects",
+ authorization=f"Bearer {raw_key}",
+ ),
+ through_vnext_auth,
+ )
+ )
+
+ assert keyless.status_code == 401
+ assert json.loads(keyless.body) == {
+ "detail": {
+ "code": "authentication_failed",
+ "message": "Authentication failed",
+ }
+ }
+ assert keyed.status_code == 204
+ assert downstream_calls == 1
diff --git a/tests/unit/test_vnext_release_polish.py b/tests/unit/test_vnext_release_polish.py
index 807b3e61..b3e2c6a4 100644
--- a/tests/unit/test_vnext_release_polish.py
+++ b/tests/unit/test_vnext_release_polish.py
@@ -476,7 +476,7 @@ def test_ci_action_dependency_carrier_uses_exact_atomic_pins() -> None:
assert checkout_refs == [
"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0"
- ] * 16
+ ] * 18
assert codeql_refs == [
"99df26d4f13ea111d4ec1a7dddef6063f76b97e9"
] * 3
diff --git a/tests/unit/test_vnext_secrets.py b/tests/unit/test_vnext_secrets.py
index 9f7ddee7..0faa8c2e 100644
--- a/tests/unit/test_vnext_secrets.py
+++ b/tests/unit/test_vnext_secrets.py
@@ -7,6 +7,7 @@
InMemorySecretProvider,
LocalEncryptedFileSecretProvider,
redact_secret_fields,
+ redact_secret_value,
)
@@ -55,3 +56,24 @@ def test_secret_redaction_recurses_through_payloads() -> None:
"items": [{"api_key": "***"}],
"safe": "visible",
}
+
+
+def test_secret_value_redaction_removes_nested_values_keys_and_substrings() -> None:
+ secret = "alice_clip_secret-value"
+
+ redacted = redact_secret_value(
+ {
+ "url": f"https://example.test/article?cap={secret}",
+ "nested": [{"title": f"before {secret} after"}],
+ f"key-{secret}": secret,
+ "safe": "visible",
+ },
+ secret,
+ )
+
+ assert redacted == {
+ "url": "https://example.test/article?cap=***",
+ "nested": [{"title": "before *** after"}],
+ "key-***": "***",
+ "safe": "visible",
+ }
diff --git a/tests/unit/test_workspaces_router_split.py b/tests/unit/test_workspaces_router_split.py
index 7a7693c7..fb9319c3 100644
--- a/tests/unit/test_workspaces_router_split.py
+++ b/tests/unit/test_workspaces_router_split.py
@@ -34,11 +34,14 @@
_openapi_tag_for_path _OPENAPI_EXACT_RESPONSE_CONTRACTS
_OPENAPI_CREATED_ONLY_OPERATIONS _OPENAPI_CONDITIONAL_SUCCESS_OPERATIONS
LEGACY_HTTP_OPERATION_KEYS LEGACY_SURFACES_ENABLED
- _openapi_live_operation_keys AliceFastAPI app HealthStatus ServiceStatus
+ _openapi_live_operation_keys AliceFastAPI app _alice_request_validation_error
+ HealthStatus ServiceStatus
DatabaseServicePayload RedisServicePayload ObjectStorageServicePayload
HealthServicesPayload HealthcheckPayload _rewrite_user_id_query_param
_rewrite_user_id_json_body _VNEXT_ROUTE_LOCAL_POLICY
- _VNEXT_CENTRAL_OPERATOR_ROUTES _matched_vnext_route_path
+ _VNEXT_CENTRAL_OPERATOR_ROUTES _BROWSER_CLIP_SIMPLE_CAPTURE_PATH
+ _BROWSER_CLIP_SIMPLE_BODY_MAX_BYTES _prepare_browser_clip_simple_request
+ _matched_vnext_route_path
_vnext_central_route_policy _resolve_vnext_http_auth
_vnext_protected_http_auth build_healthcheck_payload
_request_client_is_loopback _append_vary_header _cors_origin_allowed
@@ -75,8 +78,8 @@
EXPECTED_ROUTE_NAME_MANIFEST_SHA256 = "225c57c08bd8314156c56352dd1c53ffed3f556ce285c666dd6fca125115d0b4"
EXPECTED_OPERATION_MANIFEST_SHA256 = "c320979b62d7ee8de244fe38bde5bf3761a4f9d76f76bf3cd8576c30fce9857e"
EXPECTED_IMPORT_MANIFEST_SHA256 = "8d9669a4024ea5258cd50f92ac290c2a040ff224dd0a67b5c60faed5ae722517"
-EXPECTED_CARRIER_NAMES_SHA256 = "6cd0951deb96f86d9f6b5ba4c862698b615db12e6c3933749ce39aee34c858c6"
-EXPECTED_CARRIER_AST_SHA256 = "d2533461869ee4e696ad63173b948584546ee9d9de0b59f908a8c037d086a432"
+EXPECTED_CARRIER_NAMES_SHA256 = "00f4b8aba8e03d77e9936205d45003365df5f4f3afd0b7f6eced5b0b6ab49a9b"
+EXPECTED_CARRIER_AST_SHA256 = "1465fa9c2d60479ab41c44f2a2d9dbb2bca4319b4d8368c132b4b158241cf294"
EXPECTED_ROUTE_NODE_SHA256 = {
"get_vnext_workspace": "6c2151bf38b1b1311f016c00d14394afc7077a6ea219f7ce3dcfd9b701474ae7",
"bootstrap_v1_workspace": "07b1fe2a4cd03a5ba69abe76e258a457e85e92b0bfba592520ee02d01d759c4b",
@@ -539,7 +542,7 @@ def test_workspace_routes_preserve_mount_order_origins_and_operation_ids() -> No
for method in sorted(getattr(route, "methods", None) or set())
if method in {"GET", "POST", "PUT", "PATCH", "DELETE"}
]
- expected_indices = (84, 223, 224) if main_module.LEGACY_SURFACES_ENABLED else (38, 174, 175)
+ expected_indices = (84, 224, 225) if main_module.LEGACY_SURFACES_ENABLED else (38, 175, 176)
assert all(effective_pairs.count((method, path)) == 1 for method, path, _name in EXPECTED_ROUTE_MANIFEST)
observed_indices = tuple(
effective_pairs.index((method, path))