diff --git a/Makefile b/Makefile index c135c719..eb897e97 100644 --- a/Makefile +++ b/Makefile @@ -5,6 +5,8 @@ WEB_DIR ?= apps/web DIST_DIR ?= dist REPRO_DIST_DIR ?= $(DIST_DIR)-reproducibility-check PROJECT_VERSION = $(shell $(PYTHON) -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])') +# Reload is off unless a developer opts in: APP_RELOAD=true make dev +APP_RELOAD ?= false ALICE_WEB_HOST ?= 127.0.0.1 ALICE_WEB_PORT ?= 3000 @@ -39,14 +41,14 @@ migrate: ./scripts/dev_up.sh api: - APP_RELOAD=false ./scripts/api_dev.sh + APP_RELOAD=$(APP_RELOAD) ./scripts/api_dev.sh doctor: $(ALICEBOT) vnext doctor --fix-safe --ci dev: ./scripts/dev_up.sh - APP_RELOAD=false ./scripts/api_dev.sh & \ + APP_RELOAD=$(APP_RELOAD) ./scripts/api_dev.sh & \ api_pid=$$!; \ $(PNPM) --dir $(WEB_DIR) dev & \ web_pid=$$!; \ @@ -56,7 +58,7 @@ dev: runtime: ./scripts/dev_up.sh $(PNPM) --dir $(WEB_DIR) build - APP_RELOAD=false ./scripts/api_dev.sh & \ + APP_RELOAD=$(APP_RELOAD) ./scripts/api_dev.sh & \ api_pid=$$!; \ $(PNPM) --dir $(WEB_DIR) start --hostname $(ALICE_WEB_HOST) --port $(ALICE_WEB_PORT) & \ web_pid=$$!; \ diff --git a/apps/api/src/alicebot_api/chatgpt_import.py b/apps/api/src/alicebot_api/chatgpt_import.py index 7760060b..303ed2a7 100644 --- a/apps/api/src/alicebot_api/chatgpt_import.py +++ b/apps/api/src/alicebot_api/chatgpt_import.py @@ -21,6 +21,12 @@ parse_optional_confidence, parse_optional_status, ) +from alicebot_api.importer_paths import ( + ImportSourceFile, + contained_source_files, + read_contained_source_text, + snapshot_source_files, +) from alicebot_api.importers.common import ImportPersistenceConfig, import_normalized_batch from alicebot_api.store import ContinuityStore, JsonObject, JsonValue @@ -63,9 +69,9 @@ def _build_raw_content(*, object_type: str, text: str) -> str: return f"{prefix}: {text}" -def _read_json(path: Path) -> object: +def _parse_json(path: Path, raw_text: str) -> object: try: - return json.loads(path.read_text(encoding="utf-8")) + return json.loads(raw_text) except json.JSONDecodeError as exc: raise ChatGPTImportValidationError( f"invalid JSON at {path}: {exc.msg}" @@ -77,16 +83,41 @@ def _read_chatgpt_source_files(source: str | Path) -> tuple[Path, list[Path]]: if not source_path.exists(): raise ChatGPTImportValidationError(f"ChatGPT source path does not exist: {source_path}") - source_files = [source_path] if source_path.is_file() else sorted(source_path.rglob("*.json")) + source_files = ( + [source_path] + if source_path.is_file() + else contained_source_files( + source_path, + suffixes=(".json",), + recursive=True, + error_factory=ChatGPTImportValidationError, + ) + ) if not source_files: raise ChatGPTImportValidationError("no ChatGPT JSON files were found at the source path") return source_path, source_files -def _relative_source_file(source_root: Path, file_path: Path) -> str: - if source_root.is_dir(): - return str(file_path.relative_to(source_root)) - return file_path.name +def _snapshot_chatgpt_source(source: str | Path) -> tuple[Path, list[ImportSourceFile]]: + """Select the ChatGPT export files and read each of them exactly once.""" + + source_path, source_files = _read_chatgpt_source_files(source) + if source_path.is_file(): + return source_path, [ + ImportSourceFile( + path=source_path, + relative_path=source_path.name, + text=read_contained_source_text( + source_path, + error_factory=ChatGPTImportValidationError, + ), + ) + ] + return source_path, snapshot_source_files( + source_path, + source_files, + error_factory=ChatGPTImportValidationError, + ) def _normalize_message_text(value: object) -> str | None: @@ -261,16 +292,22 @@ def _message_text(message: JsonObject) -> str | None: def load_chatgpt_payload(source: str | Path) -> ImporterNormalizedBatch: - source_path, source_files = _read_chatgpt_source_files(source) + source_path, snapshot = _snapshot_chatgpt_source(source) + return _load_chatgpt_batch(source_path, snapshot) + +def _load_chatgpt_batch( + source_path: Path, + snapshot: list[ImportSourceFile], +) -> ImporterNormalizedBatch: fixture_id: str | None = None workspace_id: str | None = None workspace_name: str | None = None items: list[ImporterNormalizedItem] = [] - for source_file in source_files: - payload = _read_json(source_file) + for source_file in snapshot: + payload = _parse_json(source_file.path, source_file.text) maybe_fixture_id, maybe_workspace_id, maybe_workspace_name = _extract_workspace_metadata(payload) if fixture_id is None: @@ -363,7 +400,7 @@ def load_chatgpt_payload(source: str | Path) -> ImporterNormalizedBatch: items.append( ImporterNormalizedItem( source_item_id=source_item_id, - source_file=_relative_source_file(source_path, source_file), + source_file=source_file.relative_path, source_locator={ "conversation_id": conversation_id, "message_id": message_id, @@ -403,7 +440,11 @@ def import_chatgpt_source( user_id: UUID, source: str | Path, ) -> JsonObject: - source_path, source_files = _read_chatgpt_source_files(source) + # One snapshot feeds both the evidence archive and the parse, so the + # archived text is the text that was imported. It is decoded text and not + # the disk bytes: the read is text mode, so CRLF arrives as LF and the + # archive will not checksum against the original file. + source_path, snapshot = _snapshot_chatgpt_source(source) archived_artifacts = archive_import_source_files( store, user_id=user_id, @@ -411,15 +452,15 @@ def import_chatgpt_source( import_source_path=str(source_path), files=[ SourceArtifactArchiveInput( - relative_path=_relative_source_file(source_path, file_path), - display_name=file_path.name, + relative_path=source_file.relative_path, + display_name=source_file.path.name, media_type="application/json", - content_text=file_path.read_text(encoding="utf-8"), + content_text=source_file.text, ) - for file_path in source_files + for source_file in snapshot ], ) - batch = load_chatgpt_payload(source_path) + batch = _load_chatgpt_batch(source_path, snapshot) return import_normalized_batch( store, user_id=user_id, diff --git a/apps/api/src/alicebot_api/importer_paths.py b/apps/api/src/alicebot_api/importer_paths.py new file mode 100644 index 00000000..ce270a6e --- /dev/null +++ b/apps/api/src/alicebot_api/importer_paths.py @@ -0,0 +1,181 @@ +"""Filesystem containment for importer source trees. + +Importers walk a root the operator selected and read whatever they find under +it. Two ordinary habits break that boundary. ``Path.rglob`` descends directory +symlinks and ``Path.is_file`` is true for a link whose target is a regular +file, so one link planted inside the root can pull in bytes from anywhere the +server process can read. And re-opening a listed path for each pass lets the +name be swapped between passes, so the bytes that get archived as evidence +need not be the bytes that were parsed. + +Everything here refuses to follow a link out of the selected root, and hands +back the exact text it read so callers parse and archive one snapshot. +""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable +from dataclasses import dataclass +import errno +import fcntl +import os +from pathlib import Path +import stat + + +ErrorFactory = Callable[[str], Exception] + +# BSD kernels report O_NOFOLLOW on a symlink as EMLINK rather than ELOOP. +_SYMLINK_OPEN_ERRNOS = frozenset({errno.ELOOP, errno.EMLINK}) + + +@dataclass(frozen=True, slots=True) +class ImportSourceFile: + """One import file opened once, with the bytes that were read.""" + + path: Path + relative_path: str + text: str + + +def _relative_source_file(source_root: Path, file_path: Path) -> str: + if source_root.is_dir(): + return str(file_path.relative_to(source_root)) + return file_path.name + + +def contained_source_files( + source_root: Path, + *, + suffixes: Iterable[str], + recursive: bool, + error_factory: ErrorFactory, +) -> list[Path]: + """List import candidates under ``source_root`` without following links. + + Walks with ``followlinks=False`` so no directory symlink is descended, and + refuses any symlinked directory or candidate file instead of silently + importing bytes from outside the root or silently skipping content the + operator expected to import. + """ + + matches: list[Path] = [] + normalized_suffixes = {suffix.casefold() for suffix in suffixes} + for raw_directory, directory_names, file_names in os.walk(source_root, followlinks=False): + current_directory = Path(raw_directory) + if recursive: + for directory_name in sorted(directory_names): + candidate_directory = current_directory / directory_name + if candidate_directory.is_symlink(): + raise error_factory( + f"import source must not contain symlinked directories: {candidate_directory}" + ) + for file_name in sorted(file_names): + candidate = current_directory / file_name + if candidate.suffix.casefold() not in normalized_suffixes: + continue + if candidate.is_symlink(): + raise error_factory(f"import source must not contain symlinked files: {candidate}") + matches.append(candidate) + if not recursive: + directory_names[:] = [] + return sorted(matches) + + +def read_contained_source_text( + file_path: Path, + *, + source_root: Path | None = None, + error_factory: ErrorFactory, +) -> str: + """Open one import file once and return the exact text that was read. + + ``O_NOFOLLOW`` fails the open when the final path component is a symlink, + so a link swapped in after the listing cannot redirect the read. + ``O_NONBLOCK`` keeps the open itself from parking forever on a FIFO that + has no writer: the descriptor is checked to be a regular file before any + bytes are consumed, and that check is worthless if the process never gets + to it. The flag is cleared once the descriptor is known to be a regular + file, for which it has no defined effect anyway. + + ``source_root``, when given, is enforced: a candidate carrying ``..`` or + otherwise resolving outside the selected root is refused rather than read. + + Known limitation: a hard link is not a reference to a file, it is the + file, so a hard link planted inside the root to content elsewhere is + indistinguishable from ordinary content and is read. Nothing at this layer + can separate the two. + """ + + if ".." in file_path.parts: + raise error_factory(f"import source path must not traverse upward: {file_path}") + if source_root is not None and not file_path.is_relative_to(source_root): + raise error_factory(f"import source path escapes the selected root: {file_path}") + + open_flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0) + try: + descriptor = os.open(file_path, open_flags) + except OSError as exc: + if exc.errno in _SYMLINK_OPEN_ERRNOS: + raise error_factory( + f"import source must not contain symlinked files: {file_path}" + ) from exc + raise error_factory(f"import source file is not readable: {file_path}") from exc + + try: + # Checked on the descriptor rather than the path, so a swap between the + # listing and the open cannot change what is measured. Without this a + # FIFO under the root imports as an empty document, and on a platform + # where the read-open parks, it hangs the import instead. + opened_status = os.fstat(descriptor) + if not stat.S_ISREG(opened_status.st_mode): + raise error_factory( + f"import source file is not a regular file: {file_path}" + ) + descriptor_flags = fcntl.fcntl(descriptor, fcntl.F_GETFL) + fcntl.fcntl(descriptor, fcntl.F_SETFL, descriptor_flags & ~os.O_NONBLOCK) + except BaseException: + os.close(descriptor) + raise + + with os.fdopen(descriptor, "r", encoding="utf-8") as stream: + try: + return stream.read() + except UnicodeDecodeError as exc: + # Decoding is part of reading the file, so a bad byte has to leave + # as the caller's own validation error naming the file. Raised bare + # it surfaces as a byte offset with no path attached, which tells an + # operator nothing about which file to go and look at. + raise error_factory( + f"import source file is not valid UTF-8 text: {file_path}" + ) from exc + + +def snapshot_source_files( + source_root: Path, + files: Iterable[Path], + *, + error_factory: ErrorFactory, +) -> list[ImportSourceFile]: + """Read every selected file once, in listing order.""" + + return [ + ImportSourceFile( + path=file_path, + relative_path=_relative_source_file(source_root, file_path), + text=read_contained_source_text( + file_path, + source_root=source_root, + error_factory=error_factory, + ), + ) + for file_path in files + ] + + +__all__ = [ + "ImportSourceFile", + "contained_source_files", + "read_contained_source_text", + "snapshot_source_files", +] diff --git a/apps/api/src/alicebot_api/local_server.py b/apps/api/src/alicebot_api/local_server.py index 01cb8312..d42b45b2 100644 --- a/apps/api/src/alicebot_api/local_server.py +++ b/apps/api/src/alicebot_api/local_server.py @@ -22,7 +22,10 @@ def main() -> int: "alicebot_api.main:app", host=settings.app_host, port=settings.app_port, - reload=_env_flag("APP_RELOAD", default=True), + # Reload watches the source tree and re-executes on change. That is a + # development convenience, never a serving posture, so it stays off + # unless APP_RELOAD explicitly turns it on. + reload=_env_flag("APP_RELOAD", default=False), access_log=settings.app_access_log, log_config=build_uvicorn_log_config(settings), ) diff --git a/apps/api/src/alicebot_api/main.py b/apps/api/src/alicebot_api/main.py index 55b98c95..2de57f16 100644 --- a/apps/api/src/alicebot_api/main.py +++ b/apps/api/src/alicebot_api/main.py @@ -965,6 +965,9 @@ async def _vnext_protected_http_auth( and isinstance(payload.get("capture_capability"), str) and bool(str(payload["capture_capability"]).strip()) ) + raw_key = agent_key_from_authorization(request.headers.get("authorization")) + if raw_key is None and not capability_capture and _keyless_request_is_off_loopback(request, settings): + return _authentication_failed_response("keyless vNext requests are restricted to loopback clients") if capability_capture: # The capability is the narrow credential for this endpoint. Its # hash/origin/user/expiry/consumption checks run atomically in the @@ -980,7 +983,7 @@ async def _vnext_protected_http_auth( _resolve_vnext_http_auth, settings=settings, user_id=user_id, - raw_key=agent_key_from_authorization(request.headers.get("authorization")), + raw_key=raw_key, payload=payload, method=request.method, route_path=route_path, @@ -1030,6 +1033,149 @@ def _request_client_is_loopback(request: Request, settings: Settings) -> bool: return client_ip.is_loopback +def _keyless_request_is_off_loopback(request: Request, settings: Settings) -> bool: + """Report whether a keyless request must be refused before dispatch. + + Unconditional by design. Deriving this from ``APP_ENV`` or ``APP_HOST`` + would trust settings that can disagree with the real bind: a process + started outside ``local_server`` on ``0.0.0.0`` still reports the default + loopback ``APP_HOST``. The peer address is the only fact that cannot lie, + and ``_request_client_is_loopback`` reads ``X-Forwarded-For`` only when the + peer is a configured trusted proxy. + """ + + return not _request_client_is_loopback(request, settings) + + +def _authentication_failed_response(reason: str) -> JSONResponse: + """Return the repository's one stable 401 body for a refused request.""" + + return public_exception_response( + AgentKeyAuthenticationError(reason, status_code=401), + status_code=401, + ) + + +def _is_v1_path(path: str) -> bool: + return path == "/v1" or path.startswith("/v1/") + + +async def _v1_request_payload(request: Request) -> dict[str, object]: + """Read the JSON body an agent-key claim could be hiding in.""" + + if request.method.upper() in {"GET", "HEAD", "OPTIONS"}: + return {} + if "application/json" not in request.headers.get("content-type", "").casefold(): + return {} + try: + candidate = await request.json() + except (json.JSONDecodeError, UnicodeDecodeError): + return {} + return candidate if isinstance(candidate, dict) else {} + + +def _v1_request_claims_other_user( + request: Request, + payload: dict[str, object], + authenticated_user_id: UUID, +) -> bool: + """Report whether the request body or query claims a different user. + + ``/v1`` handlers take their user from the server-side binding, never from + the payload. This keeps that true by construction: a payload that names + another user is refused rather than quietly ignored. + """ + + expected_user_id = str(authenticated_user_id) + query_user_id = request.query_params.get("user_id") + if query_user_id is not None and query_user_id.strip() != expected_user_id: + return True + body_user_id = payload.get("user_id") + return body_user_id is not None and str(body_user_id).strip() != expected_user_id + + +def _resolve_v1_http_auth( + *, + settings: Settings, + user_id: UUID, + raw_key: str | None, + payload: dict[str, object], +) -> AgentIdentity | None: + """Run ``/v1`` agent-key authentication off the event-loop thread.""" + + with user_connection(settings.database_url, user_id) as conn: + return resolve_protected_agent_identity( + PostgresVNextStore(conn), + user_id=user_id, + raw_key=raw_key, + payload=payload, + ) + + +async def enforce_v1_agent_authentication( + request: Request, + call_next: Callable[[Request], Awaitable[Response]], +) -> Response: + """Authenticate the complete ``/v1`` surface before any handler runs. + + ``/v1`` routes resolve their user from ``ALICEBOT_AUTH_USER_ID`` or the + identity header, which binds the data but proves nothing about the caller. + Once the bound user has provisioned an agent API key, every ``/v1`` request + must present it. While no key exists the surface stays keyless for local + callers only. + + This authenticates and does not authorize. No handler reads the resolved + identity, so ``/v1`` enforces no permission profile and records no agent on + the rows it writes: any valid key can do anything any other valid key can, + and a revoked key's ``/v1`` writes are not reachable by an agent-keyed + quarantine sweep. Keep ``/v1`` loopback-only, as + ``docs/deployment/single-tenant-self-hosted.md`` instructs. + """ + + if not _is_v1_path(request.url.path): + return await call_next(request) + if request.method.upper() == "OPTIONS": + return await call_next(request) + + settings = get_settings() + raw_key = agent_key_from_authorization(request.headers.get("authorization")) + if raw_key is None and _keyless_request_is_off_loopback(request, settings): + return _authentication_failed_response("keyless /v1 requests are restricted to loopback clients") + + try: + user_id = _resolve_authenticated_v1_user_id(settings, request) + except ValueError: + # No bound user means no privilege to grant. The route handler owns the + # stable "local identity is required" contract for that case. + return await call_next(request) + + payload = await _v1_request_payload(request) + if _v1_request_claims_other_user(request, payload, user_id): + return _authentication_failed_response("request user_id does not match the authenticated user") + + try: + identity = await run_in_threadpool( + _resolve_v1_http_auth, + settings=settings, + user_id=user_id, + raw_key=raw_key, + payload=payload, + ) + except AgentKeyAuthenticationError as exc: + return _vnext_agent_auth_error_response(exc) + except AgentIdentityValidationError as exc: + return public_exception_response(exc, status_code=400) + + request.state.v1_agent_identity = identity + return await call_next(request) + + +# Registered after the vNext middleware and before the security-posture +# middleware, so a refused /v1 request still leaves with the standard CORS and +# security headers. +app.middleware("http")(enforce_v1_agent_authentication) + + def _append_vary_header(response: Response, value: str) -> None: existing = response.headers.get("Vary", "") values = [item.strip() for item in existing.split(",") if item.strip() != ""] diff --git a/apps/api/src/alicebot_api/markdown_import.py b/apps/api/src/alicebot_api/markdown_import.py index e034ea4e..30b6795e 100644 --- a/apps/api/src/alicebot_api/markdown_import.py +++ b/apps/api/src/alicebot_api/markdown_import.py @@ -20,6 +20,12 @@ parse_optional_confidence, parse_optional_status, ) +from alicebot_api.importer_paths import ( + ImportSourceFile, + contained_source_files, + read_contained_source_text, + snapshot_source_files, +) from alicebot_api.importers.common import ImportPersistenceConfig, import_normalized_batch from alicebot_api.store import ContinuityStore, JsonObject, JsonValue @@ -111,16 +117,39 @@ def _read_markdown_source(source: str | Path) -> tuple[Path, list[Path]]: raise MarkdownImportValidationError("markdown source file must end with .md") return source_path, [source_path] - files = sorted( - path - for path in source_path.rglob("*.md") - if path.is_file() + files = contained_source_files( + source_path, + suffixes=(".md",), + recursive=True, + error_factory=MarkdownImportValidationError, ) if not files: raise MarkdownImportValidationError(f"no markdown files were found at {source_path}") return source_path, files +def _snapshot_markdown_source(source: str | Path) -> tuple[Path, list[ImportSourceFile]]: + """Select the markdown files and read each of them exactly once.""" + + source_path, markdown_files = _read_markdown_source(source) + if source_path.is_file(): + return source_path, [ + ImportSourceFile( + path=source_path, + relative_path=source_path.name, + text=read_contained_source_text( + source_path, + error_factory=MarkdownImportValidationError, + ), + ) + ] + return source_path, snapshot_source_files( + source_path, + markdown_files, + error_factory=MarkdownImportValidationError, + ) + + def _resolve_object_type_and_text(*, text: str, type_hint: str | None) -> tuple[str, str]: if type_hint is not None: return normalize_object_type(type_hint), text @@ -137,12 +166,6 @@ def _resolve_object_type_and_text(*, text: str, type_hint: str | None) -> tuple[ return "Note", text -def _relative_source_file(source_root: Path, file_path: Path) -> str: - if source_root.is_dir(): - return str(file_path.relative_to(source_root)) - return file_path.name - - def _parse_line_tags(line: str) -> tuple[str, dict[str, str]]: segments = [segment.strip() for segment in line.split("|")] text_segment = segments[0] @@ -179,8 +202,14 @@ def _merge_source_event_ids(*, existing: list[str], maybe_csv: str | None, singl def load_markdown_payload(source: str | Path) -> ImporterNormalizedBatch: - source_path, markdown_files = _read_markdown_source(source) + source_path, snapshot = _snapshot_markdown_source(source) + return _load_markdown_batch(source_path, snapshot) + +def _load_markdown_batch( + source_path: Path, + snapshot: list[ImportSourceFile], +) -> ImporterNormalizedBatch: fixture_id: str | None = None workspace_id: str | None = None workspace_name: str | None = None @@ -190,9 +219,9 @@ def load_markdown_payload(source: str | Path) -> ImporterNormalizedBatch: items: list[ImporterNormalizedItem] = [] - for file_path in markdown_files: - raw_text = file_path.read_text(encoding="utf-8") - metadata, lines = _parse_frontmatter(raw_text) + for source_file in snapshot: + file_path = source_file.path + metadata, lines = _parse_frontmatter(source_file.text) if fixture_id is None: fixture_id = normalize_optional_text(metadata.get("fixture_id")) @@ -256,11 +285,7 @@ def load_markdown_payload(source: str | Path) -> ImporterNormalizedBatch: source_provenance = merge_json_objects( default_scope, file_scope, - { - "markdown_source_relpath": str(file_path.relative_to(source_path)) - if source_path.is_dir() - else file_path.name, - }, + {"markdown_source_relpath": source_file.relative_path}, ) for key in ("thread_id", "task_id", "project", "person", "confirmation_status"): @@ -292,7 +317,7 @@ def load_markdown_payload(source: str | Path) -> ImporterNormalizedBatch: items.append( ImporterNormalizedItem( source_item_id=source_item_id, - source_file=_relative_source_file(source_path, file_path), + source_file=source_file.relative_path, source_locator={"line_number": line_number, "source_item_id": source_item_id}, source_segment_text=raw_line, source_segment_kind="markdown_line", @@ -328,7 +353,11 @@ def import_markdown_source( user_id: UUID, source: str | Path, ) -> JsonObject: - source_path, markdown_files = _read_markdown_source(source) + # One snapshot feeds both the evidence archive and the parse, so the + # archived text is the text that was imported. It is decoded text and not + # the disk bytes: the read is text mode, so CRLF arrives as LF and the + # archive will not checksum against the original file. + source_path, snapshot = _snapshot_markdown_source(source) archived_artifacts = archive_import_source_files( store, user_id=user_id, @@ -336,15 +365,15 @@ def import_markdown_source( import_source_path=str(source_path), files=[ SourceArtifactArchiveInput( - relative_path=_relative_source_file(source_path, file_path), - display_name=file_path.name, + relative_path=source_file.relative_path, + display_name=source_file.path.name, media_type="text/markdown", - content_text=file_path.read_text(encoding="utf-8"), + content_text=source_file.text, ) - for file_path in markdown_files + for source_file in snapshot ], ) - batch = load_markdown_payload(source_path) + batch = _load_markdown_batch(source_path, snapshot) return import_normalized_batch( store, user_id=user_id, diff --git a/apps/api/src/alicebot_api/openclaw_adapter.py b/apps/api/src/alicebot_api/openclaw_adapter.py index 4ab4d6fb..d7d6ef80 100644 --- a/apps/api/src/alicebot_api/openclaw_adapter.py +++ b/apps/api/src/alicebot_api/openclaw_adapter.py @@ -5,6 +5,12 @@ from pathlib import Path from typing import cast +from alicebot_api.importer_paths import ( + ImportSourceFile, + contained_source_files, + read_contained_source_text, + snapshot_source_files, +) from alicebot_api.openclaw_models import ( OpenClawAdapterValidationError, OpenClawNormalizedBatch, @@ -113,9 +119,9 @@ def _build_raw_content(*, object_type: str, text: str) -> str: return f"{prefix}: {text}" -def _read_json(path: Path) -> object: +def _parse_json(path: Path, raw_text: str) -> object: try: - return json.loads(path.read_text(encoding="utf-8")) + return json.loads(raw_text) except json.JSONDecodeError as exc: raise OpenClawAdapterValidationError( f"invalid JSON at {path}: {exc.msg}" @@ -342,51 +348,132 @@ def _extract_context( ) -def list_openclaw_source_files(source: str | Path) -> tuple[Path, list[Path]]: +def _select_openclaw_source_paths(candidates: list[Path]) -> list[Path]: + """Narrow a directory listing to the files the adapter will actually parse. + + Selection runs on the listing rather than on already-read text, for two + reasons. An unrelated neighbour is never opened or decoded, so a stray + ``.json`` file with invalid UTF-8 no longer fails an import that had no + reason to look at it. And the set that gets archived becomes the same set + the parse is handed, because both are driven by the one snapshot taken + after this narrowing, so the parse can no longer reach content that was + never archived. + + The reverse can still happen and is harmless: when a directory holds more + than one workspace-named file, every one of them is archived while the + parse consumes the first, so the archive is a superset. Nothing is imported + from outside it, which is the direction that matters. + """ + + by_name = {path.name: path for path in candidates} + named = [ + by_name[filename] + for filename in (*_SUPPORTED_WORKSPACE_FILENAMES, *_SUPPORTED_MEMORY_FILENAMES) + if filename in by_name + ] + if named: + return named + if not candidates: + raise OpenClawAdapterValidationError("no OpenClaw memory entries were found at the source path") + return list(candidates) + + +def snapshot_openclaw_source(source: str | Path) -> tuple[Path, list[ImportSourceFile]]: + """Read every JSON file the adapter will consult, exactly once. + + A directory source is read one level deep, which is the only level the + selection rules below look at, and never through a symlink. Selection is + applied to the listing first, so a file the adapter will not parse is not + read either. + """ + source_path = Path(source).expanduser().resolve() if not source_path.exists(): raise OpenClawAdapterValidationError(f"OpenClaw source path does not exist: {source_path}") if source_path.is_file(): - return source_path, [source_path] + return source_path, [ + ImportSourceFile( + path=source_path, + relative_path=source_path.name, + text=read_contained_source_text( + source_path, + error_factory=OpenClawAdapterValidationError, + ), + ) + ] + + json_files = contained_source_files( + source_path, + suffixes=(".json",), + recursive=False, + error_factory=OpenClawAdapterValidationError, + ) + return source_path, snapshot_source_files( + source_path, + _select_openclaw_source_paths(json_files), + error_factory=OpenClawAdapterValidationError, + ) - files: list[Path] = [] - for filename in (*_SUPPORTED_WORKSPACE_FILENAMES, *_SUPPORTED_MEMORY_FILENAMES): - candidate = source_path / filename - if candidate.exists(): - files.append(candidate) - if files: - return source_path, files +def select_openclaw_source_files( + source_path: Path, + snapshot: list[ImportSourceFile], +) -> list[ImportSourceFile]: + """Apply the adapter's file-selection rule to an already-read snapshot.""" - json_files = sorted(path for path in source_path.iterdir() if path.suffix == ".json") - if not json_files: + if source_path.is_file(): + return list(snapshot) + + files_by_name = {source_file.path.name: source_file for source_file in snapshot} + named = [ + files_by_name[filename] + for filename in (*_SUPPORTED_WORKSPACE_FILENAMES, *_SUPPORTED_MEMORY_FILENAMES) + if filename in files_by_name + ] + if named: + return named + if not snapshot: raise OpenClawAdapterValidationError("no OpenClaw memory entries were found at the source path") - return source_path, json_files + return list(snapshot) + + +def list_openclaw_source_files(source: str | Path) -> tuple[Path, list[Path]]: + source_path, snapshot = snapshot_openclaw_source(source) + return source_path, [ + source_file.path for source_file in select_openclaw_source_files(source_path, snapshot) + ] def load_openclaw_payload(source: str | Path) -> OpenClawNormalizedBatch: - source_path = Path(source).expanduser().resolve() - if not source_path.exists(): - raise OpenClawAdapterValidationError(f"OpenClaw source path does not exist: {source_path}") + source_path, snapshot = snapshot_openclaw_source(source) + return load_openclaw_batch_from_snapshot(source_path, snapshot) + +def load_openclaw_batch_from_snapshot( + source_path: Path, + snapshot: list[ImportSourceFile], +) -> OpenClawNormalizedBatch: entries_by_file: list[tuple[str, list[JsonObject]]] = [] workspace_payload: JsonObject | None = None fixture_id: str | None = None if source_path.is_file(): - payload = _read_json(source_path) + only_file = snapshot[0] + payload = _parse_json(only_file.path, only_file.text) parsed_workspace, entries = _extract_workspace_payloads(payload) if isinstance(payload, dict): fixture_id = normalize_optional_text(payload.get("fixture_id")) workspace_payload = parsed_workspace entries_by_file.append((source_path.name, entries)) else: + files_by_name = {candidate.path.name: candidate for candidate in snapshot} + for filename in _SUPPORTED_WORKSPACE_FILENAMES: - candidate = source_path / filename - if not candidate.exists(): + candidate = files_by_name.get(filename) + if candidate is None: continue - payload = _read_json(candidate) + payload = _parse_json(candidate.path, candidate.text) parsed_workspace, _ = _extract_workspace_payloads(payload) workspace_payload = parsed_workspace or workspace_payload if isinstance(payload, dict): @@ -394,10 +481,10 @@ def load_openclaw_payload(source: str | Path) -> OpenClawNormalizedBatch: break for filename in _SUPPORTED_MEMORY_FILENAMES: - candidate = source_path / filename - if not candidate.exists(): + candidate = files_by_name.get(filename) + if candidate is None: continue - payload = _read_json(candidate) + payload = _parse_json(candidate.path, candidate.text) parsed_workspace, entries = _extract_workspace_payloads(payload) if parsed_workspace is not None: workspace_payload = parsed_workspace @@ -406,16 +493,15 @@ def load_openclaw_payload(source: str | Path) -> OpenClawNormalizedBatch: entries_by_file.append((filename, entries)) if not entries_by_file: - json_files = sorted(path for path in source_path.iterdir() if path.suffix == ".json") - for path in json_files: - payload = _read_json(path) + for candidate in snapshot: + payload = _parse_json(candidate.path, candidate.text) parsed_workspace, entries = _extract_workspace_payloads(payload) if parsed_workspace is not None: workspace_payload = parsed_workspace if isinstance(payload, dict): fixture_id = fixture_id or normalize_optional_text(payload.get("fixture_id")) if entries: - entries_by_file.append((path.name, entries)) + entries_by_file.append((candidate.path.name, entries)) if not entries_by_file: raise OpenClawAdapterValidationError("no OpenClaw memory entries were found at the source path") @@ -450,5 +536,8 @@ def load_openclaw_payload(source: str | Path) -> OpenClawNormalizedBatch: __all__ = [ "OpenClawAdapterValidationError", "list_openclaw_source_files", + "load_openclaw_batch_from_snapshot", "load_openclaw_payload", + "select_openclaw_source_files", + "snapshot_openclaw_source", ] diff --git a/apps/api/src/alicebot_api/openclaw_import.py b/apps/api/src/alicebot_api/openclaw_import.py index e428d4b6..1645a228 100644 --- a/apps/api/src/alicebot_api/openclaw_import.py +++ b/apps/api/src/alicebot_api/openclaw_import.py @@ -9,22 +9,21 @@ ImporterNormalizedItem, ImporterWorkspaceContext, ) +from alicebot_api.importer_paths import ImportSourceFile from alicebot_api.importers.common import ImportPersistenceConfig, import_normalized_batch -from alicebot_api.openclaw_adapter import list_openclaw_source_files, load_openclaw_payload +from alicebot_api.openclaw_adapter import ( + load_openclaw_batch_from_snapshot, + select_openclaw_source_files, + snapshot_openclaw_source, +) from alicebot_api.store import ContinuityStore, JsonObject _OPENCLAW_DEDUPE_POSTURE = "workspace_and_payload_fingerprint" -def _relative_source_file(source_root: Path, file_path: Path) -> str: - if source_root.is_dir(): - return str(file_path.relative_to(source_root)) - return file_path.name - - -def _to_generic_batch(source: str | Path) -> ImporterNormalizedBatch: - batch = load_openclaw_payload(source) +def _to_generic_batch(source_path: Path, snapshot: list[ImportSourceFile]) -> ImporterNormalizedBatch: + batch = load_openclaw_batch_from_snapshot(source_path, snapshot) return ImporterNormalizedBatch( context=ImporterWorkspaceContext( fixture_id=batch.context.fixture_id, @@ -59,7 +58,11 @@ def import_openclaw_source( user_id: UUID, source: str | Path, ) -> JsonObject: - source_path, source_files = list_openclaw_source_files(source) + # One snapshot feeds both the evidence archive and the parse, over one + # selected file set, so the archived text is the text that was imported. + # It is decoded text and not the disk bytes: the read is text mode, so CRLF + # arrives as LF and the archive will not checksum against the original file. + source_path, snapshot = snapshot_openclaw_source(source) archived_artifacts = archive_import_source_files( store, user_id=user_id, @@ -67,15 +70,15 @@ def import_openclaw_source( import_source_path=str(source_path), files=[ SourceArtifactArchiveInput( - relative_path=_relative_source_file(source_path, file_path), - display_name=file_path.name, + relative_path=source_file.relative_path, + display_name=source_file.path.name, media_type="application/json", - content_text=file_path.read_text(encoding="utf-8"), + content_text=source_file.text, ) - for file_path in source_files + for source_file in select_openclaw_source_files(source_path, snapshot) ], ) - generic_batch = _to_generic_batch(source) + generic_batch = _to_generic_batch(source_path, snapshot) return import_normalized_batch( store, user_id=user_id, diff --git a/apps/web/package.json b/apps/web/package.json index 99ecb875..1a4c40fc 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -48,9 +48,12 @@ }, "pnpm": { "overrides": { - "brace-expansion@>=1.0.0 <1.1.16": ">=1.1.16 <2.0.0", - "brace-expansion@>=3.0.0 <5.0.7": ">=5.0.7", - "postcss": "8.5.18", + "brace-expansion@1": "1.1.18", + "brace-expansion@2": "2.1.4", + "brace-expansion@5": "5.0.9", + "js-yaml@4": "4.3.1", + "nanoid": "3.3.18", + "postcss": "8.5.26", "sharp": "0.35.0", "vite": "6.4.3", "ws": "8.21.0" diff --git a/apps/web/pnpm-lock.yaml b/apps/web/pnpm-lock.yaml index b7648da7..4643a25a 100644 --- a/apps/web/pnpm-lock.yaml +++ b/apps/web/pnpm-lock.yaml @@ -5,9 +5,12 @@ settings: excludeLinksFromLockfile: false overrides: - brace-expansion@>=1.0.0 <1.1.16: '>=1.1.16 <2.0.0' - brace-expansion@>=3.0.0 <5.0.7: '>=5.0.7' - postcss: 8.5.18 + brace-expansion@1: 1.1.18 + brace-expansion@2: 2.1.4 + brace-expansion@5: 5.0.9 + js-yaml@4: 4.3.1 + nanoid: 3.3.18 + postcss: 8.5.26 sharp: 0.35.0 vite: 6.4.3 ws: 8.21.0 @@ -1169,14 +1172,14 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - brace-expansion@1.1.16: - resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==} + brace-expansion@1.1.18: + resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} - brace-expansion@2.1.2: - resolution: {integrity: sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==} + brace-expansion@2.1.4: + resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==} - brace-expansion@5.0.8: - resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} engines: {node: 20 || >=22} braces@3.0.3: @@ -1872,8 +1875,8 @@ packages: js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} - js-yaml@4.3.0: - resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + js-yaml@4.3.1: + resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} hasBin: true jsdom@26.1.0: @@ -2000,8 +2003,8 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - nanoid@3.3.12: - resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -2150,8 +2153,8 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} - postcss@8.5.18: - resolution: {integrity: sha512-xdB1oSLHbz1vRWgCDalrCqEFTWzFlhqFC5tIHLMOSUIjhm3XXQ1qrFy8S/ESr1JYRRXqM3c1QFiMZUJdUTqyMQ==} + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} prelude-ls@1.2.1: @@ -2950,7 +2953,7 @@ snapshots: globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.3.0 + js-yaml: 4.3.1 minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -3667,16 +3670,16 @@ snapshots: baseline-browser-mapping@2.11.5: {} - brace-expansion@1.1.16: + brace-expansion@1.1.18: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@2.1.2: + brace-expansion@2.1.4: dependencies: balanced-match: 1.0.2 - brace-expansion@5.0.8: + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.4 @@ -3984,7 +3987,7 @@ snapshots: eslint: 9.39.5 eslint-import-resolver-node: 0.3.10 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5)(typescript@6.0.3))(eslint@9.39.5))(eslint@9.39.5) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5)(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5)(typescript@6.0.3))(eslint@9.39.5))(eslint@9.39.5))(eslint@9.39.5) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5)(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.5) eslint-plugin-react: 7.37.5(eslint@9.39.5) eslint-plugin-react-hooks: 7.1.1(eslint@9.39.5) @@ -4017,7 +4020,7 @@ snapshots: tinyglobby: 0.2.16 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5)(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5)(typescript@6.0.3))(eslint@9.39.5))(eslint@9.39.5))(eslint@9.39.5) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5)(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5) transitivePeerDependencies: - supports-color @@ -4032,7 +4035,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5)(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5)(typescript@6.0.3))(eslint@9.39.5))(eslint@9.39.5))(eslint@9.39.5): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5)(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -4550,7 +4553,7 @@ snapshots: js-tokens@9.0.1: {} - js-yaml@4.3.0: + js-yaml@4.3.1: dependencies: argparse: 2.0.1 @@ -4666,15 +4669,15 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.8 + brace-expansion: 5.0.9 minimatch@3.1.5: dependencies: - brace-expansion: 1.1.16 + brace-expansion: 1.1.18 minimatch@9.0.9: dependencies: - brace-expansion: 2.1.2 + brace-expansion: 2.1.4 minimist@1.2.8: {} @@ -4682,7 +4685,7 @@ snapshots: ms@2.1.3: {} - nanoid@3.3.12: {} + nanoid@3.3.18: {} napi-postinstall@0.3.4: {} @@ -4694,7 +4697,7 @@ snapshots: '@swc/helpers': 0.5.15 baseline-browser-mapping: 2.11.5 caniuse-lite: 1.0.30001806 - postcss: 8.5.18 + postcss: 8.5.26 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.7) @@ -4830,9 +4833,9 @@ snapshots: possible-typed-array-names@1.1.0: {} - postcss@8.5.18: + postcss@8.5.26: dependencies: - nanoid: 3.3.12 + nanoid: 3.3.18 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -5350,7 +5353,7 @@ snapshots: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - postcss: 8.5.18 + postcss: 8.5.26 rollup: 4.60.3 tinyglobby: 0.2.16 optionalDependencies: diff --git a/apps/web/security-advisory-exceptions.json b/apps/web/security-advisory-exceptions.json index 60c3693d..d3188eb6 100644 --- a/apps/web/security-advisory-exceptions.json +++ b/apps/web/security-advisory-exceptions.json @@ -8,13 +8,5 @@ "unconditionally. Add an entry only when there is no available remediation,", "and prefer fixing the dependency." ], - "exceptions": [ - { - "advisory_url": "https://github.com/advisories/GHSA-mh99-v99m-4gvg", - "package": "brace-expansion", - "expires": "2026-09-25", - "scope": "development tooling only", - "justification": "Denial of service via unbounded brace expansion, reachable only through a maliciously crafted glob pattern. No remediation is available: the advisory covers everything up to 5.0.7, the installed 1.1.16 and 2.1.2 have no patched release in their major lines, and forcing the tree to the patched 5.0.8 requires minimatch 10, which is ESM-only with no default export and cannot be loaded by eslint. Verified 2026-07-27: brace-expansion 5.0.8 alone breaks the vitest coverage provider through test-exclude and glob, and adding a minimatch 10 override fixes coverage but breaks lint. The package is absent from the production audit set and is reached only by build and test tooling whose glob patterns come from committed configuration, never from untrusted input. Revisit when eslint and glob have migrated to brace-expansion 5." - } - ] + "exceptions": [] } diff --git a/packaging/systemd/alice-api.service b/packaging/systemd/alice-api.service index 3fde1090..2ebc4ca2 100644 --- a/packaging/systemd/alice-api.service +++ b/packaging/systemd/alice-api.service @@ -11,6 +11,7 @@ WorkingDirectory=__ALICE_INSTALL_DIR__ EnvironmentFile=__ALICE_ENV_FILE__ Environment=APP_HOST=127.0.0.1 Environment=APP_PORT=8000 +Environment=APP_RELOAD=false Environment=APP_LOG_MODE=stdout Environment=APP_ACCESS_LOG=false ExecStart=__ALICE_INSTALL_DIR__/.venv/bin/python -m alicebot_api.local_server diff --git a/tests/integration/test_approval_api.py b/tests/integration/test_approval_api.py index bdf2c2e6..d1e75c58 100644 --- a/tests/integration/test_approval_api.py +++ b/tests/integration/test_approval_api.py @@ -47,7 +47,7 @@ async def send(message: dict[str, object]) -> None: "raw_path": path.encode(), "query_string": query_string, "headers": [(b"content-type", b"application/json")], - "client": ("testclient", 50000), + "client": ("127.0.0.1", 50000), "server": ("testserver", 80), "root_path": "", } diff --git a/tests/integration/test_calendar_accounts_api.py b/tests/integration/test_calendar_accounts_api.py index 4e2ba33b..cd308f95 100644 --- a/tests/integration/test_calendar_accounts_api.py +++ b/tests/integration/test_calendar_accounts_api.py @@ -50,7 +50,7 @@ async def send(message: dict[str, object]) -> None: "raw_path": path.encode(), "query_string": query_string, "headers": [(b"content-type", b"application/json")], - "client": ("testclient", 50000), + "client": ("127.0.0.1", 50000), "server": ("testserver", 80), "root_path": "", } diff --git a/tests/integration/test_context_compile.py b/tests/integration/test_context_compile.py index 15159599..be3fbc82 100644 --- a/tests/integration/test_context_compile.py +++ b/tests/integration/test_context_compile.py @@ -42,7 +42,7 @@ async def send(message: dict[str, object]) -> None: "raw_path": b"/v0/context/compile", "query_string": b"", "headers": [(b"content-type", b"application/json")], - "client": ("testclient", 50000), + "client": ("127.0.0.1", 50000), "server": ("testserver", 80), "root_path": "", } diff --git a/tests/integration/test_continuity_api.py b/tests/integration/test_continuity_api.py index 7fff6cc9..0ac5fbe2 100644 --- a/tests/integration/test_continuity_api.py +++ b/tests/integration/test_continuity_api.py @@ -49,7 +49,7 @@ async def send(message: dict[str, object]) -> None: "raw_path": path.encode(), "query_string": query_string, "headers": [(b"content-type", b"application/json")], - "client": ("testclient", 50000), + "client": ("127.0.0.1", 50000), "server": ("testserver", 80), "root_path": "", } diff --git a/tests/integration/test_continuity_brief_api.py b/tests/integration/test_continuity_brief_api.py index 636a6353..b652b662 100644 --- a/tests/integration/test_continuity_brief_api.py +++ b/tests/integration/test_continuity_brief_api.py @@ -55,7 +55,7 @@ async def send(message: dict[str, object]) -> None: "raw_path": path.encode(), "query_string": query_string, "headers": request_headers, - "client": ("testclient", 50000), + "client": ("127.0.0.1", 50000), "server": ("testserver", 80), "root_path": "", } diff --git a/tests/integration/test_continuity_capture_api.py b/tests/integration/test_continuity_capture_api.py index 5f63a988..40524bfb 100644 --- a/tests/integration/test_continuity_capture_api.py +++ b/tests/integration/test_continuity_capture_api.py @@ -47,7 +47,7 @@ async def send(message: dict[str, object]) -> None: "raw_path": path.encode(), "query_string": query_string, "headers": [(b"content-type", b"application/json")], - "client": ("testclient", 50000), + "client": ("127.0.0.1", 50000), "server": ("testserver", 80), "root_path": "", } diff --git a/tests/integration/test_continuity_daily_weekly_review_api.py b/tests/integration/test_continuity_daily_weekly_review_api.py index eb9c5e3a..cbbb9bd2 100644 --- a/tests/integration/test_continuity_daily_weekly_review_api.py +++ b/tests/integration/test_continuity_daily_weekly_review_api.py @@ -49,7 +49,7 @@ async def send(message: dict[str, object]) -> None: "raw_path": path.encode(), "query_string": query_string, "headers": [(b"content-type", b"application/json")], - "client": ("testclient", 50000), + "client": ("127.0.0.1", 50000), "server": ("testserver", 80), "root_path": "", } diff --git a/tests/integration/test_continuity_open_loops_api.py b/tests/integration/test_continuity_open_loops_api.py index de87c1ce..dd7acccf 100644 --- a/tests/integration/test_continuity_open_loops_api.py +++ b/tests/integration/test_continuity_open_loops_api.py @@ -49,7 +49,7 @@ async def send(message: dict[str, object]) -> None: "raw_path": path.encode(), "query_string": query_string, "headers": [(b"content-type", b"application/json")], - "client": ("testclient", 50000), + "client": ("127.0.0.1", 50000), "server": ("testserver", 80), "root_path": "", } diff --git a/tests/integration/test_continuity_recall_api.py b/tests/integration/test_continuity_recall_api.py index 1c761717..b930dacc 100644 --- a/tests/integration/test_continuity_recall_api.py +++ b/tests/integration/test_continuity_recall_api.py @@ -49,7 +49,7 @@ async def send(message: dict[str, object]) -> None: "raw_path": path.encode(), "query_string": query_string, "headers": [(b"content-type", b"application/json")], - "client": ("testclient", 50000), + "client": ("127.0.0.1", 50000), "server": ("testserver", 80), "root_path": "", } diff --git a/tests/integration/test_continuity_resumption_api.py b/tests/integration/test_continuity_resumption_api.py index dd37ef03..293e3575 100644 --- a/tests/integration/test_continuity_resumption_api.py +++ b/tests/integration/test_continuity_resumption_api.py @@ -49,7 +49,7 @@ async def send(message: dict[str, object]) -> None: "raw_path": path.encode(), "query_string": query_string, "headers": [(b"content-type", b"application/json")], - "client": ("testclient", 50000), + "client": ("127.0.0.1", 50000), "server": ("testserver", 80), "root_path": "", } diff --git a/tests/integration/test_continuity_review_api.py b/tests/integration/test_continuity_review_api.py index a36ef8a1..4fe83bc4 100644 --- a/tests/integration/test_continuity_review_api.py +++ b/tests/integration/test_continuity_review_api.py @@ -47,7 +47,7 @@ async def send(message: dict[str, object]) -> None: "raw_path": path.encode(), "query_string": query_string, "headers": [(b"content-type", b"application/json")], - "client": ("testclient", 50000), + "client": ("127.0.0.1", 50000), "server": ("testserver", 80), "root_path": "", } diff --git a/tests/integration/test_contradictions_api.py b/tests/integration/test_contradictions_api.py index f6e8b763..d30bf751 100644 --- a/tests/integration/test_contradictions_api.py +++ b/tests/integration/test_contradictions_api.py @@ -53,7 +53,7 @@ async def send(message: dict[str, object]) -> None: "raw_path": path.encode(), "query_string": query_string, "headers": request_headers, - "client": ("testclient", 50000), + "client": ("127.0.0.1", 50000), "server": ("testserver", 80), "root_path": "", } diff --git a/tests/integration/test_default_surface_integration.py b/tests/integration/test_default_surface_integration.py index 48daa28f..d3f5dfe7 100644 --- a/tests/integration/test_default_surface_integration.py +++ b/tests/integration/test_default_surface_integration.py @@ -79,7 +79,9 @@ async def send(message: dict[str, object]) -> None: (b"content-type", b"application/json"), (b"x-alicebot-user-id", str(user_id).encode("ascii")), ], - "client": ("default-surface-smoke", 50000), + # An in-process caller is a loopback peer; a synthetic host would be + # refused by the keyless loopback gate before reaching the route. + "client": ("127.0.0.1", 50000), "server": ("testserver", 80), "root_path": "", } diff --git a/tests/integration/test_embeddings_api.py b/tests/integration/test_embeddings_api.py index 789ab7ed..0c4cb6bf 100644 --- a/tests/integration/test_embeddings_api.py +++ b/tests/integration/test_embeddings_api.py @@ -49,7 +49,7 @@ async def send(message: dict[str, object]) -> None: "raw_path": path.encode(), "query_string": query_string, "headers": [(b"content-type", b"application/json")], - "client": ("testclient", 50000), + "client": ("127.0.0.1", 50000), "server": ("testserver", 80), "root_path": "", } diff --git a/tests/integration/test_entities_api.py b/tests/integration/test_entities_api.py index 2a13cb53..a68a27f4 100644 --- a/tests/integration/test_entities_api.py +++ b/tests/integration/test_entities_api.py @@ -49,7 +49,7 @@ async def send(message: dict[str, object]) -> None: "raw_path": path.encode(), "query_string": query_string, "headers": [(b"content-type", b"application/json")], - "client": ("testclient", 50000), + "client": ("127.0.0.1", 50000), "server": ("testserver", 80), "root_path": "", } diff --git a/tests/integration/test_entity_edges_api.py b/tests/integration/test_entity_edges_api.py index ee1f8f91..6a35ddcc 100644 --- a/tests/integration/test_entity_edges_api.py +++ b/tests/integration/test_entity_edges_api.py @@ -49,7 +49,7 @@ async def send(message: dict[str, object]) -> None: "raw_path": path.encode(), "query_string": query_string, "headers": [(b"content-type", b"application/json")], - "client": ("testclient", 50000), + "client": ("127.0.0.1", 50000), "server": ("testserver", 80), "root_path": "", } diff --git a/tests/integration/test_execution_budgets_api.py b/tests/integration/test_execution_budgets_api.py index 87a36340..af1be095 100644 --- a/tests/integration/test_execution_budgets_api.py +++ b/tests/integration/test_execution_budgets_api.py @@ -50,7 +50,7 @@ async def send(message: dict[str, object]) -> None: "raw_path": path.encode(), "query_string": query_string, "headers": [(b"content-type", b"application/json")], - "client": ("testclient", 50000), + "client": ("127.0.0.1", 50000), "server": ("testserver", 80), "root_path": "", } diff --git a/tests/integration/test_explicit_commitments_api.py b/tests/integration/test_explicit_commitments_api.py index de861944..b09469c9 100644 --- a/tests/integration/test_explicit_commitments_api.py +++ b/tests/integration/test_explicit_commitments_api.py @@ -48,7 +48,7 @@ async def send(message: dict[str, object]) -> None: "raw_path": path.encode(), "query_string": query_string, "headers": [(b"content-type", b"application/json")], - "client": ("testclient", 50000), + "client": ("127.0.0.1", 50000), "server": ("testserver", 80), "root_path": "", } diff --git a/tests/integration/test_explicit_preferences_api.py b/tests/integration/test_explicit_preferences_api.py index 83de4f7a..d2485a42 100644 --- a/tests/integration/test_explicit_preferences_api.py +++ b/tests/integration/test_explicit_preferences_api.py @@ -39,7 +39,7 @@ async def send(message: dict[str, object]) -> None: "raw_path": b"/v0/memories/extract-explicit-preferences", "query_string": b"", "headers": [(b"content-type", b"application/json")], - "client": ("testclient", 50000), + "client": ("127.0.0.1", 50000), "server": ("testserver", 80), "root_path": "", } diff --git a/tests/integration/test_explicit_signal_capture_api.py b/tests/integration/test_explicit_signal_capture_api.py index 8c230ecd..e2662962 100644 --- a/tests/integration/test_explicit_signal_capture_api.py +++ b/tests/integration/test_explicit_signal_capture_api.py @@ -49,7 +49,7 @@ async def send(message: dict[str, object]) -> None: "raw_path": path.encode(), "query_string": query_string, "headers": [(b"content-type", b"application/json")], - "client": ("testclient", 50000), + "client": ("127.0.0.1", 50000), "server": ("testserver", 80), "root_path": "", } diff --git a/tests/integration/test_gmail_accounts_api.py b/tests/integration/test_gmail_accounts_api.py index c6867eb2..3bab1a44 100644 --- a/tests/integration/test_gmail_accounts_api.py +++ b/tests/integration/test_gmail_accounts_api.py @@ -51,7 +51,7 @@ async def send(message: dict[str, object]) -> None: "raw_path": path.encode(), "query_string": query_string, "headers": [(b"content-type", b"application/json")], - "client": ("testclient", 50000), + "client": ("127.0.0.1", 50000), "server": ("testserver", 80), "root_path": "", } diff --git a/tests/integration/test_healthcheck.py b/tests/integration/test_healthcheck.py index e2c7cfbf..8053a28b 100644 --- a/tests/integration/test_healthcheck.py +++ b/tests/integration/test_healthcheck.py @@ -36,7 +36,7 @@ async def send(message: dict[str, object]) -> None: "raw_path": b"/healthz", "query_string": b"", "headers": [], - "client": ("testclient", 50000), + "client": ("127.0.0.1", 50000), "server": ("testserver", 80), "root_path": "", } diff --git a/tests/integration/test_http_security_posture.py b/tests/integration/test_http_security_posture.py index 1d064286..92a0d438 100644 --- a/tests/integration/test_http_security_posture.py +++ b/tests/integration/test_http_security_posture.py @@ -42,7 +42,7 @@ async def send(message: dict[str, object]) -> None: "raw_path": path.encode("utf-8"), "query_string": b"", "headers": request_headers, - "client": ("testclient", 50000), + "client": ("127.0.0.1", 50000), "server": ("testserver", 80), "root_path": "", } diff --git a/tests/integration/test_local_workspace_bootstrap_api.py b/tests/integration/test_local_workspace_bootstrap_api.py index 34fbb201..36092562 100644 --- a/tests/integration/test_local_workspace_bootstrap_api.py +++ b/tests/integration/test_local_workspace_bootstrap_api.py @@ -63,7 +63,7 @@ async def send(message: dict[str, object]) -> None: "raw_path": path.encode(), "query_string": b"", "headers": request_headers, - "client": ("testclient", 50000), + "client": ("127.0.0.1", 50000), "server": ("testserver", 80), "root_path": "", } diff --git a/tests/integration/test_memory_admission.py b/tests/integration/test_memory_admission.py index 14abe60b..a8795440 100644 --- a/tests/integration/test_memory_admission.py +++ b/tests/integration/test_memory_admission.py @@ -41,7 +41,7 @@ async def send(message: dict[str, object]) -> None: "raw_path": b"/v0/memories/admit", "query_string": b"", "headers": [(b"content-type", b"application/json")], - "client": ("testclient", 50000), + "client": ("127.0.0.1", 50000), "server": ("testserver", 80), "root_path": "", } diff --git a/tests/integration/test_memory_mutations_api.py b/tests/integration/test_memory_mutations_api.py index a0ad8ad1..6b513752 100644 --- a/tests/integration/test_memory_mutations_api.py +++ b/tests/integration/test_memory_mutations_api.py @@ -51,7 +51,7 @@ async def send(message: dict[str, object]) -> None: "raw_path": path.encode(), "query_string": query_string, "headers": request_headers, - "client": ("testclient", 50000), + "client": ("127.0.0.1", 50000), "server": ("testserver", 80), "root_path": "", } diff --git a/tests/integration/test_memory_quality_gate_api.py b/tests/integration/test_memory_quality_gate_api.py index b0a6d989..826cecfd 100644 --- a/tests/integration/test_memory_quality_gate_api.py +++ b/tests/integration/test_memory_quality_gate_api.py @@ -52,7 +52,7 @@ async def send(message: dict[str, object]) -> None: "raw_path": path.encode(), "query_string": query_string, "headers": [(b"content-type", b"application/json")], - "client": ("testclient", 50000), + "client": ("127.0.0.1", 50000), "server": ("testserver", 80), "root_path": "", } diff --git a/tests/integration/test_memory_review_api.py b/tests/integration/test_memory_review_api.py index 23b22fe4..ddd39a4f 100644 --- a/tests/integration/test_memory_review_api.py +++ b/tests/integration/test_memory_review_api.py @@ -50,7 +50,7 @@ async def send(message: dict[str, object]) -> None: "raw_path": path.encode(), "query_string": query_string, "headers": [(b"content-type", b"application/json")], - "client": ("testclient", 50000), + "client": ("127.0.0.1", 50000), "server": ("testserver", 80), "root_path": "", } diff --git a/tests/integration/test_memory_review_labels_api.py b/tests/integration/test_memory_review_labels_api.py index 8e95f5b0..2ac593b8 100644 --- a/tests/integration/test_memory_review_labels_api.py +++ b/tests/integration/test_memory_review_labels_api.py @@ -51,7 +51,7 @@ async def send(message: dict[str, object]) -> None: "raw_path": path.encode(), "query_string": query_string, "headers": [(b"content-type", b"application/json")], - "client": ("testclient", 50000), + "client": ("127.0.0.1", 50000), "server": ("testserver", 80), "root_path": "", } diff --git a/tests/integration/test_mvp_magnesium_reorder_flow.py b/tests/integration/test_mvp_magnesium_reorder_flow.py index 2d30f657..90015917 100644 --- a/tests/integration/test_mvp_magnesium_reorder_flow.py +++ b/tests/integration/test_mvp_magnesium_reorder_flow.py @@ -48,7 +48,7 @@ async def send(message: dict[str, object]) -> None: "raw_path": path.encode(), "query_string": query_string, "headers": [(b"content-type", b"application/json")], - "client": ("testclient", 50000), + "client": ("127.0.0.1", 50000), "server": ("testserver", 80), "root_path": "", } diff --git a/tests/integration/test_open_loops_api.py b/tests/integration/test_open_loops_api.py index 992bc54f..7b956415 100644 --- a/tests/integration/test_open_loops_api.py +++ b/tests/integration/test_open_loops_api.py @@ -50,7 +50,7 @@ async def send(message: dict[str, object]) -> None: "raw_path": path.encode(), "query_string": query_string, "headers": [(b"content-type", b"application/json")], - "client": ("testclient", 50000), + "client": ("127.0.0.1", 50000), "server": ("testserver", 80), "root_path": "", } diff --git a/tests/integration/test_policy_api.py b/tests/integration/test_policy_api.py index a6b9178f..b18fcbc4 100644 --- a/tests/integration/test_policy_api.py +++ b/tests/integration/test_policy_api.py @@ -47,7 +47,7 @@ async def send(message: dict[str, object]) -> None: "raw_path": path.encode(), "query_string": query_string, "headers": [(b"content-type", b"application/json")], - "client": ("testclient", 50000), + "client": ("127.0.0.1", 50000), "server": ("testserver", 80), "root_path": "", } diff --git a/tests/integration/test_provider_runtime_api.py b/tests/integration/test_provider_runtime_api.py index 72849279..abfb7087 100644 --- a/tests/integration/test_provider_runtime_api.py +++ b/tests/integration/test_provider_runtime_api.py @@ -66,7 +66,7 @@ async def send(message: dict[str, object]) -> None: "raw_path": path.encode(), "query_string": query_string, "headers": request_headers, - "client": ("testclient", 50000), + "client": ("127.0.0.1", 50000), "server": ("testserver", 80), "root_path": "", } diff --git a/tests/integration/test_proxy_execution_api.py b/tests/integration/test_proxy_execution_api.py index cc868fe6..72bf850b 100644 --- a/tests/integration/test_proxy_execution_api.py +++ b/tests/integration/test_proxy_execution_api.py @@ -48,7 +48,7 @@ async def send(message: dict[str, object]) -> None: "raw_path": path.encode(), "query_string": query_string, "headers": [(b"content-type", b"application/json")], - "client": ("testclient", 50000), + "client": ("127.0.0.1", 50000), "server": ("testserver", 80), "root_path": "", } diff --git a/tests/integration/test_public_evals_api.py b/tests/integration/test_public_evals_api.py index 3c83027e..f458868e 100644 --- a/tests/integration/test_public_evals_api.py +++ b/tests/integration/test_public_evals_api.py @@ -51,7 +51,7 @@ async def send(message: dict[str, object]) -> None: "raw_path": path.encode(), "query_string": query_string, "headers": request_headers, - "client": ("testclient", 50000), + "client": ("127.0.0.1", 50000), "server": ("testserver", 80), "root_path": "", } diff --git a/tests/integration/test_retrieval_evaluation_api.py b/tests/integration/test_retrieval_evaluation_api.py index be812d25..8f16b6bb 100644 --- a/tests/integration/test_retrieval_evaluation_api.py +++ b/tests/integration/test_retrieval_evaluation_api.py @@ -47,7 +47,7 @@ async def send(message: dict[str, object]) -> None: "raw_path": path.encode(), "query_string": query_string, "headers": [(b"content-type", b"application/json")], - "client": ("testclient", 50000), + "client": ("127.0.0.1", 50000), "server": ("testserver", 80), "root_path": "", } diff --git a/tests/integration/test_review_dashboard_demo.py b/tests/integration/test_review_dashboard_demo.py index 1e24eab7..ae5fd3f6 100644 --- a/tests/integration/test_review_dashboard_demo.py +++ b/tests/integration/test_review_dashboard_demo.py @@ -59,7 +59,7 @@ async def send(message: dict[str, object]) -> None: "raw_path": path.encode(), "query_string": urlencode(query_params or {}).encode(), "headers": headers, - "client": ("testclient", 50000), + "client": ("127.0.0.1", 50000), "server": ("testserver", 80), "root_path": "", } diff --git a/tests/integration/test_semantic_artifact_chunk_retrieval_api.py b/tests/integration/test_semantic_artifact_chunk_retrieval_api.py index 69b58b49..0aeea15c 100644 --- a/tests/integration/test_semantic_artifact_chunk_retrieval_api.py +++ b/tests/integration/test_semantic_artifact_chunk_retrieval_api.py @@ -49,7 +49,7 @@ async def send(message: dict[str, object]) -> None: "raw_path": path.encode(), "query_string": query_string, "headers": [(b"content-type", b"application/json")], - "client": ("testclient", 50000), + "client": ("127.0.0.1", 50000), "server": ("testserver", 80), "root_path": "", } diff --git a/tests/integration/test_source_review_identity_api.py b/tests/integration/test_source_review_identity_api.py index cbcb9adb..3f9aaeb3 100644 --- a/tests/integration/test_source_review_identity_api.py +++ b/tests/integration/test_source_review_identity_api.py @@ -52,7 +52,7 @@ async def send(message: dict[str, object]) -> None: "raw_path": path.encode(), "query_string": urlencode({}).encode(), "headers": [(b"content-type", b"application/json")], - "client": ("testclient", 50000), + "client": ("127.0.0.1", 50000), "server": ("testserver", 80), "root_path": "", } diff --git a/tests/integration/test_stage_a_agent_key_isolation.py b/tests/integration/test_stage_a_agent_key_isolation.py index e9a4526c..196c0fcb 100644 --- a/tests/integration/test_stage_a_agent_key_isolation.py +++ b/tests/integration/test_stage_a_agent_key_isolation.py @@ -48,7 +48,10 @@ async def send(message: dict[str, object]) -> None: "raw_path": path.encode("utf-8"), "query_string": urlencode({"user_id": str(user_id)}).encode("ascii"), "headers": headers, - "client": ("stage-a-key-isolation", 50000), + # An in-process caller is a loopback peer. Anything else is refused by + # the keyless loopback gate before key resolution runs, which would + # make the per-user assertions below pass for the wrong reason. + "client": ("127.0.0.1", 50000), "server": ("testserver", 80), "root_path": "", } diff --git a/tests/integration/test_task_artifact_chunk_embeddings_api.py b/tests/integration/test_task_artifact_chunk_embeddings_api.py index 499fef7f..979c3946 100644 --- a/tests/integration/test_task_artifact_chunk_embeddings_api.py +++ b/tests/integration/test_task_artifact_chunk_embeddings_api.py @@ -47,7 +47,7 @@ async def send(message: dict[str, object]) -> None: "raw_path": path.encode(), "query_string": query_string, "headers": [(b"content-type", b"application/json")], - "client": ("testclient", 50000), + "client": ("127.0.0.1", 50000), "server": ("testserver", 80), "root_path": "", } diff --git a/tests/integration/test_task_artifacts_api.py b/tests/integration/test_task_artifacts_api.py index fa81396b..bcb3d9c8 100644 --- a/tests/integration/test_task_artifacts_api.py +++ b/tests/integration/test_task_artifacts_api.py @@ -292,7 +292,7 @@ async def send(message: dict[str, object]) -> None: "raw_path": path.encode(), "query_string": query_string, "headers": [(b"content-type", b"application/json")], - "client": ("testclient", 50000), + "client": ("127.0.0.1", 50000), "server": ("testserver", 80), "root_path": "", } diff --git a/tests/integration/test_task_briefing_api.py b/tests/integration/test_task_briefing_api.py index 1d04bf9c..bc691537 100644 --- a/tests/integration/test_task_briefing_api.py +++ b/tests/integration/test_task_briefing_api.py @@ -49,7 +49,7 @@ async def send(message: dict[str, object]) -> None: "raw_path": path.encode(), "query_string": query_string, "headers": [(b"content-type", b"application/json")], - "client": ("testclient", 50000), + "client": ("127.0.0.1", 50000), "server": ("testserver", 80), "root_path": "", } diff --git a/tests/integration/test_task_runs_api.py b/tests/integration/test_task_runs_api.py index 3694d082..faed512d 100644 --- a/tests/integration/test_task_runs_api.py +++ b/tests/integration/test_task_runs_api.py @@ -47,7 +47,7 @@ async def send(message: dict[str, object]) -> None: "raw_path": path.encode(), "query_string": query_string, "headers": [(b"content-type", b"application/json")], - "client": ("testclient", 50000), + "client": ("127.0.0.1", 50000), "server": ("testserver", 80), "root_path": "", } diff --git a/tests/integration/test_task_workspaces_api.py b/tests/integration/test_task_workspaces_api.py index d743a5ea..036fcad5 100644 --- a/tests/integration/test_task_workspaces_api.py +++ b/tests/integration/test_task_workspaces_api.py @@ -48,7 +48,7 @@ async def send(message: dict[str, object]) -> None: "raw_path": path.encode(), "query_string": query_string, "headers": [(b"content-type", b"application/json")], - "client": ("testclient", 50000), + "client": ("127.0.0.1", 50000), "server": ("testserver", 80), "root_path": "", } diff --git a/tests/integration/test_tasks_api.py b/tests/integration/test_tasks_api.py index 0d2ef8d2..2433f559 100644 --- a/tests/integration/test_tasks_api.py +++ b/tests/integration/test_tasks_api.py @@ -47,7 +47,7 @@ async def send(message: dict[str, object]) -> None: "raw_path": path.encode(), "query_string": query_string, "headers": [(b"content-type", b"application/json")], - "client": ("testclient", 50000), + "client": ("127.0.0.1", 50000), "server": ("testserver", 80), "root_path": "", } diff --git a/tests/integration/test_temporal_state_api.py b/tests/integration/test_temporal_state_api.py index 9180aee4..8721f518 100644 --- a/tests/integration/test_temporal_state_api.py +++ b/tests/integration/test_temporal_state_api.py @@ -51,7 +51,7 @@ async def send(message: dict[str, object]) -> None: "raw_path": path.encode(), "query_string": query_string, "headers": [(b"content-type", b"application/json")], - "client": ("testclient", 50000), + "client": ("127.0.0.1", 50000), "server": ("testserver", 80), "root_path": "", } diff --git a/tests/integration/test_tool_api.py b/tests/integration/test_tool_api.py index 430a2598..04f217ac 100644 --- a/tests/integration/test_tool_api.py +++ b/tests/integration/test_tool_api.py @@ -47,7 +47,7 @@ async def send(message: dict[str, object]) -> None: "raw_path": path.encode(), "query_string": query_string, "headers": [(b"content-type", b"application/json")], - "client": ("testclient", 50000), + "client": ("127.0.0.1", 50000), "server": ("testserver", 80), "root_path": "", } diff --git a/tests/integration/test_traces_api.py b/tests/integration/test_traces_api.py index 51a00308..fecc468a 100644 --- a/tests/integration/test_traces_api.py +++ b/tests/integration/test_traces_api.py @@ -47,7 +47,7 @@ async def send(message: dict[str, object]) -> None: "raw_path": path.encode(), "query_string": query_string, "headers": [(b"content-type", b"application/json")], - "client": ("testclient", 50000), + "client": ("127.0.0.1", 50000), "server": ("testserver", 80), "root_path": "", } diff --git a/tests/integration/test_trusted_fact_promotions_api.py b/tests/integration/test_trusted_fact_promotions_api.py index 071ef2d5..ad3f1116 100644 --- a/tests/integration/test_trusted_fact_promotions_api.py +++ b/tests/integration/test_trusted_fact_promotions_api.py @@ -46,7 +46,7 @@ async def send(message: dict[str, object]) -> None: "raw_path": path.encode(), "query_string": query_string, "headers": [(b"content-type", b"application/json")], - "client": ("testclient", 50000), + "client": ("127.0.0.1", 50000), "server": ("testserver", 80), "root_path": "", } diff --git a/tests/integration/test_vnext_live_workspace_api.py b/tests/integration/test_vnext_live_workspace_api.py index 6234919d..061d1923 100644 --- a/tests/integration/test_vnext_live_workspace_api.py +++ b/tests/integration/test_vnext_live_workspace_api.py @@ -69,7 +69,7 @@ async def send(message: dict[str, object]) -> None: "raw_path": path.encode(), "query_string": query_string, "headers": headers, - "client": ("testclient", 50000), + "client": ("127.0.0.1", 50000), "server": ("testserver", 80), "root_path": "", } diff --git a/tests/unit/test_app_reload_posture.py b/tests/unit/test_app_reload_posture.py new file mode 100644 index 00000000..c678d291 --- /dev/null +++ b/tests/unit/test_app_reload_posture.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest + +from alicebot_api import local_server +from alicebot_api.config import Settings + + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def _run_local_server(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: + captured: dict[str, Any] = {} + + def fake_run(app: str, **kwargs: Any) -> None: + captured["app"] = app + captured.update(kwargs) + + monkeypatch.setattr(local_server, "get_settings", lambda: Settings(app_env="test")) + monkeypatch.setattr(local_server.uvicorn, "run", fake_run) + assert local_server.main() == 0 + return captured + + +def test_local_server_does_not_reload_unless_asked(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("APP_RELOAD", raising=False) + + captured = _run_local_server(monkeypatch) + + assert captured["app"] == "alicebot_api.main:app" + assert captured["reload"] is False + + +@pytest.mark.parametrize("raw_value", ("false", "0", "no", "off", "", " ")) +def test_local_server_reload_stays_off_for_negative_values( + monkeypatch: pytest.MonkeyPatch, + raw_value: str, +) -> None: + monkeypatch.setenv("APP_RELOAD", raw_value) + + assert _run_local_server(monkeypatch)["reload"] is False + + +@pytest.mark.parametrize("raw_value", ("true", "1", "yes", "on", "TRUE")) +def test_local_server_reload_can_be_opted_into( + monkeypatch: pytest.MonkeyPatch, + raw_value: str, +) -> None: + monkeypatch.setenv("APP_RELOAD", raw_value) + + assert _run_local_server(monkeypatch)["reload"] is True + + +def test_systemd_unit_pins_reload_off() -> None: + unit = (REPO_ROOT / "packaging" / "systemd" / "alice-api.service").read_text(encoding="utf-8") + + assert "Environment=APP_RELOAD=false" in unit + assert "-m alicebot_api.local_server" in unit + + +def test_makefile_defaults_reload_off_and_still_allows_opting_in() -> None: + makefile = (REPO_ROOT / "Makefile").read_text(encoding="utf-8") + + assert "APP_RELOAD ?= false" in makefile + assert "APP_RELOAD=false ./scripts/api_dev.sh" not in makefile + assert makefile.count("APP_RELOAD=$(APP_RELOAD) ./scripts/api_dev.sh") == 3 diff --git a/tests/unit/test_importer_path_containment.py b/tests/unit/test_importer_path_containment.py new file mode 100644 index 00000000..ae5f840b --- /dev/null +++ b/tests/unit/test_importer_path_containment.py @@ -0,0 +1,392 @@ +from __future__ import annotations + +import builtins +import importlib +import json +import os +from pathlib import Path +import signal +from typing import Any +from uuid import uuid4 + +import pytest + +from alicebot_api.chatgpt_import import ChatGPTImportValidationError, load_chatgpt_payload +from alicebot_api.importer_paths import read_contained_source_text +from alicebot_api.markdown_import import MarkdownImportValidationError, load_markdown_payload +from alicebot_api.openclaw_adapter import ( + OpenClawAdapterValidationError, + list_openclaw_source_files, + load_openclaw_payload, +) + + +_MARKDOWN_BODY = """--- +fixture_id: containment-fixture +workspace_id: containment-workspace +--- +- Decision: Keep the importer inside the selected root. +""" + +_OUTSIDE_MARKER = "outside-the-import-root" + +_CHATGPT_BODY: dict[str, object] = { + "fixture_id": "containment-fixture", + "workspace_id": "containment-workspace", + "conversations": [ + { + "id": "conversation-1", + "title": "Containment", + "messages": [{"role": "user", "text": "Decision: stay inside the root."}], + } + ], +} + +_OPENCLAW_BODY: dict[str, object] = { + "fixture_id": "containment-fixture", + "id": "containment-workspace", + "memories": [{"type": "decision", "text": "Stay inside the selected root."}], +} + + +def _raise_timeout(_signum: int, _frame: object) -> None: + raise AssertionError("the importer blocked instead of refusing the source") + + +def _outside_tree(tmp_path: Path, filename: str, body: str) -> Path: + outside = tmp_path / "outside" + outside.mkdir() + secret = outside / filename + secret.write_text(body, encoding="utf-8") + return secret + + +def _markdown_root(tmp_path: Path) -> Path: + root = tmp_path / "root" + root.mkdir() + (root / "notes.md").write_text(_MARKDOWN_BODY, encoding="utf-8") + return root + + +def _chatgpt_root(tmp_path: Path) -> Path: + root = tmp_path / "root" + root.mkdir() + (root / "export.json").write_text(json.dumps(_CHATGPT_BODY), encoding="utf-8") + return root + + +def _openclaw_root(tmp_path: Path) -> Path: + root = tmp_path / "root" + root.mkdir() + (root / "memories.json").write_text(json.dumps(_OPENCLAW_BODY), encoding="utf-8") + return root + + +def test_markdown_import_rejects_a_file_symlink_escaping_the_root(tmp_path: Path) -> None: + secret = _outside_tree( + tmp_path, + "secret.md", + f"- Note: {_OUTSIDE_MARKER}\n", + ) + root = _markdown_root(tmp_path) + (root / "linked.md").symlink_to(secret) + + with pytest.raises(MarkdownImportValidationError, match="symlinked files"): + load_markdown_payload(root) + + +def test_markdown_import_rejects_a_directory_symlink_escaping_the_root(tmp_path: Path) -> None: + _outside_tree(tmp_path, "secret.md", f"- Note: {_OUTSIDE_MARKER}\n") + root = _markdown_root(tmp_path) + (root / "linked_dir").symlink_to(tmp_path / "outside", target_is_directory=True) + + with pytest.raises(MarkdownImportValidationError, match="symlinked directories"): + load_markdown_payload(root) + + +def test_markdown_import_still_loads_a_root_without_symlinks(tmp_path: Path) -> None: + _outside_tree(tmp_path, "secret.md", f"- Note: {_OUTSIDE_MARKER}\n") + root = _markdown_root(tmp_path) + + batch = load_markdown_payload(root) + + assert [item.source_file for item in batch.items] == ["notes.md"] + assert _OUTSIDE_MARKER not in json.dumps([item.raw_content for item in batch.items]) + + +def test_chatgpt_import_rejects_a_file_symlink_escaping_the_root(tmp_path: Path) -> None: + secret = _outside_tree(tmp_path, "secret.json", json.dumps({"marker": _OUTSIDE_MARKER})) + root = _chatgpt_root(tmp_path) + (root / "linked.json").symlink_to(secret) + + with pytest.raises(ChatGPTImportValidationError, match="symlinked files"): + load_chatgpt_payload(root) + + +def test_chatgpt_import_rejects_a_directory_symlink_escaping_the_root(tmp_path: Path) -> None: + _outside_tree(tmp_path, "secret.json", json.dumps({"marker": _OUTSIDE_MARKER})) + root = _chatgpt_root(tmp_path) + (root / "linked_dir").symlink_to(tmp_path / "outside", target_is_directory=True) + + with pytest.raises(ChatGPTImportValidationError, match="symlinked directories"): + load_chatgpt_payload(root) + + +def test_chatgpt_import_still_loads_a_root_without_symlinks(tmp_path: Path) -> None: + _outside_tree(tmp_path, "secret.json", json.dumps({"marker": _OUTSIDE_MARKER})) + root = _chatgpt_root(tmp_path) + + batch = load_chatgpt_payload(root) + + assert [item.source_file for item in batch.items] == ["export.json"] + + +def test_openclaw_import_rejects_a_file_symlink_escaping_the_root(tmp_path: Path) -> None: + secret = _outside_tree(tmp_path, "secret.json", json.dumps(_OPENCLAW_BODY)) + root = _openclaw_root(tmp_path) + (root / "openclaw_memories.json").symlink_to(secret) + + with pytest.raises(OpenClawAdapterValidationError, match="symlinked files"): + load_openclaw_payload(root) + + with pytest.raises(OpenClawAdapterValidationError, match="symlinked files"): + list_openclaw_source_files(root) + + +def test_openclaw_import_ignores_a_symlinked_subdirectory(tmp_path: Path) -> None: + _outside_tree(tmp_path, "secret.json", json.dumps({"marker": _OUTSIDE_MARKER})) + root = _openclaw_root(tmp_path) + (root / "linked_dir").symlink_to(tmp_path / "outside", target_is_directory=True) + + _source_path, selected = list_openclaw_source_files(root) + + assert [path.name for path in selected] == ["memories.json"] + + +def test_openclaw_import_still_loads_a_root_without_symlinks(tmp_path: Path) -> None: + root = _openclaw_root(tmp_path) + + batch = load_openclaw_payload(root) + + assert batch.context.fixture_id == "containment-fixture" + assert [item.source_file for item in batch.items] == ["memories.json"] + + +def test_a_symlinked_file_is_refused_even_when_it_targets_the_same_root(tmp_path: Path) -> None: + root = _markdown_root(tmp_path) + (root / "alias.md").symlink_to(root / "notes.md") + + with pytest.raises(MarkdownImportValidationError, match="symlinked files"): + load_markdown_payload(root) + + +def test_read_contained_source_text_refuses_a_symlink_swapped_in_after_listing(tmp_path: Path) -> None: + secret = _outside_tree(tmp_path, "secret.md", f"- Note: {_OUTSIDE_MARKER}\n") + root = _markdown_root(tmp_path) + listed = root / "notes.md" + listed.unlink() + listed.symlink_to(secret) + + with pytest.raises(MarkdownImportValidationError, match="symlinked files"): + read_contained_source_text(listed, error_factory=MarkdownImportValidationError) + + +def test_read_contained_source_text_returns_the_text_it_opened(tmp_path: Path) -> None: + root = _markdown_root(tmp_path) + + assert read_contained_source_text( + root / "notes.md", + error_factory=MarkdownImportValidationError, + ) == _MARKDOWN_BODY + + +def test_read_contained_source_text_refuses_upward_traversal(tmp_path: Path) -> None: + secret = _outside_tree(tmp_path, "secret.md", f"- Note: {_OUTSIDE_MARKER}\n") + root = _markdown_root(tmp_path) + + with pytest.raises(MarkdownImportValidationError, match="traverse upward"): + read_contained_source_text( + root / ".." / "outside" / "secret.md", + error_factory=MarkdownImportValidationError, + ) + + assert secret.read_text(encoding="utf-8").strip().endswith(_OUTSIDE_MARKER) + + +def test_read_contained_source_text_refuses_a_path_outside_the_declared_root(tmp_path: Path) -> None: + secret = _outside_tree(tmp_path, "secret.md", f"- Note: {_OUTSIDE_MARKER}\n") + root = _markdown_root(tmp_path) + + with pytest.raises(MarkdownImportValidationError, match="escapes the selected root"): + read_contained_source_text( + secret, + source_root=root, + error_factory=MarkdownImportValidationError, + ) + + +def test_a_fifo_is_refused_instead_of_blocking_the_import(tmp_path: Path) -> None: + """A FIFO with no writer parks a blocking open forever. + + The regular-file check runs after the open, so without O_NONBLOCK the + importer never reaches it. Guard the whole call with an alarm so a + regression shows up as a failure rather than a hung suite. + """ + + root = _markdown_root(tmp_path) + os.mkfifo(root / "pipe.md") + + previous = signal.signal(signal.SIGALRM, _raise_timeout) + signal.alarm(10) + try: + with pytest.raises(MarkdownImportValidationError, match="not a regular file"): + load_markdown_payload(root) + finally: + signal.alarm(0) + signal.signal(signal.SIGALRM, previous) + + +def test_a_character_device_is_refused_as_a_source_file() -> None: + with pytest.raises(MarkdownImportValidationError, match="not a regular file"): + read_contained_source_text( + Path("/dev/zero"), + error_factory=MarkdownImportValidationError, + ) + + +def test_a_hardlink_into_the_root_is_a_documented_limitation(tmp_path: Path) -> None: + """Pin the one attack containment cannot stop, so it cannot drift silently. + + A hard link is not a reference to a file, it is the file: same inode, same + content, no owning directory to compare against. Refusing every file with + a link count above one would reject ordinary content, so the importer reads + it. The boundary that matters is the operator choosing the root. + """ + + secret = _outside_tree(tmp_path, "secret.md", f"- Note: {_OUTSIDE_MARKER}\n") + root = _markdown_root(tmp_path) + os.link(secret, root / "hardlinked.md") + + batch = load_markdown_payload(root) + + assert _OUTSIDE_MARKER in json.dumps([item.raw_content for item in batch.items]) + + +@pytest.mark.parametrize( + ("module_name", "filename", "body", "importer"), + ( + ("alicebot_api.markdown_import", "notes.md", _MARKDOWN_BODY, "import_markdown_source"), + ( + "alicebot_api.chatgpt_import", + "export.json", + json.dumps(_CHATGPT_BODY), + "import_chatgpt_source", + ), + ( + "alicebot_api.openclaw_import", + "memories.json", + json.dumps(_OPENCLAW_BODY), + "import_openclaw_source", + ), + ), +) +def test_each_importer_opens_a_source_file_once_and_archives_what_it_parsed( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + module_name: str, + filename: str, + body: str, + importer: str, +) -> None: + """Prove the open-once claim by instrumentation, not by reading the code.""" + + module = importlib.import_module(module_name) + root = tmp_path / "root" + root.mkdir() + source = root / filename + source.write_text(body, encoding="utf-8") + + opened: list[str] = [] + real_os_open = os.open + real_builtin_open = builtins.open + + def counting_os_open(path: Any, flags: int, *args: Any, **kwargs: Any) -> int: + if str(path) == str(source): + opened.append("os.open") + return real_os_open(path, flags, *args, **kwargs) + + def counting_builtin_open(file: Any, *args: Any, **kwargs: Any) -> Any: + if isinstance(file, (str, os.PathLike)) and str(file) == str(source): + opened.append("builtins.open") + return real_builtin_open(file, *args, **kwargs) + + archived: dict[str, Any] = {} + parsed: dict[str, Any] = {} + + def fake_archive(_store: object, **kwargs: Any) -> list[object]: + archived["files"] = kwargs["files"] + return [] + + def fake_import(_store: object, **kwargs: Any) -> dict[str, object]: + parsed["batch"] = kwargs["batch"] + return {} + + monkeypatch.setattr(module, "archive_import_source_files", fake_archive) + monkeypatch.setattr(module, "import_normalized_batch", fake_import) + monkeypatch.setattr(os, "open", counting_os_open) + monkeypatch.setattr(builtins, "open", counting_builtin_open) + try: + getattr(module, importer)(object(), user_id=uuid4(), source=root) + finally: + monkeypatch.setattr(os, "open", real_os_open) + monkeypatch.setattr(builtins, "open", real_builtin_open) + + assert opened == ["os.open"], f"source opened {len(opened)} times: {opened}" + + archived_files = archived["files"] + assert len(archived_files) == 1 + # The archived evidence is the decoded text the parse consumed, taken from + # the same in-memory snapshot rather than a second read. It is text and not + # the disk bytes: the read applies universal newlines, so a CRLF source + # archives as LF and will not checksum against the original file. + assert archived_files[0].content_text == body + assert parsed["batch"].items + + +def test_openclaw_snapshot_skips_neighbours_the_selection_rule_excludes(tmp_path: Path) -> None: + """A neighbour that will not be parsed must never be opened either. + + Selection used to run after every top-level ``.json`` had been read, so one + unrelated file with bytes that are not UTF-8 failed an import that had no + reason to look at it. + """ + + root = _openclaw_root(tmp_path) + (root / "workspace.json").write_text(json.dumps(_OPENCLAW_BODY), encoding="utf-8") + (root / "unrelated.json").write_bytes(b'{"note": "\xff\xfe not utf-8"}') + + _source_path, selected = list_openclaw_source_files(root) + assert [path.name for path in selected] == ["workspace.json", "memories.json"] + + batch = load_openclaw_payload(root) + assert batch.items + assert "unrelated.json" not in {item.source_file for item in batch.items} + + +def test_read_contained_source_text_names_the_file_that_is_not_utf8(tmp_path: Path) -> None: + """A selected file with undecodable bytes leaves as the caller's own error. + + Raised bare, a ``UnicodeDecodeError`` reports a byte offset and no path, + which does not tell an operator which file to go and look at. + """ + + root = _markdown_root(tmp_path) + broken = root / "broken.md" + broken.write_bytes(b"- Note: \xff\xfe not utf-8\n") + + with pytest.raises(MarkdownImportValidationError, match="not valid UTF-8 text") as caught: + read_contained_source_text(broken, error_factory=MarkdownImportValidationError) + + assert broken.name in str(caught.value) + # The offset is still recoverable for anyone debugging the source file. + assert isinstance(caught.value.__cause__, UnicodeDecodeError) diff --git a/tests/unit/test_providers_router_split.py b/tests/unit/test_providers_router_split.py index 1870f0cb..e06b9223 100644 --- a/tests/unit/test_providers_router_split.py +++ b/tests/unit/test_providers_router_split.py @@ -64,7 +64,12 @@ _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 + build_healthcheck_payload _request_client_is_loopback + _keyless_request_is_off_loopback + _authentication_failed_response _is_v1_path _v1_request_payload + _v1_request_claims_other_user _resolve_v1_http_auth + enforce_v1_agent_authentication + _append_vary_header _cors_origin_allowed _resolve_cors_allow_origin_value _apply_cors_headers _apply_security_headers apply_http_security_posture enforce_authenticated_user_identity healthcheck _apply_legacy_surface_mount_policy @@ -73,8 +78,8 @@ EXPECTED_ROUTE_AST_SHA256 = "9e5d6c2c79cc1391688b74bb5138ccaa881033546e7b0cfd34ad92e8d98ba614" EXPECTED_SUPPORT_AST_SHA256 = "bb694bc545e514bb81e2aa568eba1cb72ba813373015d979bac452d23d4dbd74" -EXPECTED_CARRIER_NAMES_SHA256 = "00f4b8aba8e03d77e9936205d45003365df5f4f3afd0b7f6eced5b0b6ab49a9b" -EXPECTED_CARRIER_AST_SHA256 = "1465fa9c2d60479ab41c44f2a2d9dbb2bca4319b4d8368c132b4b158241cf294" +EXPECTED_CARRIER_NAMES_SHA256 = "2c109fc234a05dd8f44e4c34bee49e797fbb5e49e92413391541a7e504da328b" +EXPECTED_CARRIER_AST_SHA256 = "387d8b6ce15fe473a690da845117fe9a51fb1c3b98bf08f3761556b8689caa4b" EXPECTED_ROUTE_NAME_MANIFEST_SHA256 = "1a438538e16120361f92d30375cc94679d598fe4b78ba5a58a7d8a4dda6af83c" EXPECTED_OPERATION_MANIFEST_SHA256 = "8b79ceaf996b8c51b5bb2f3f38a8c19a4e33796955d8b8f7a66e7ac01ea1732d" EXPECTED_IMPORT_MANIFEST_SHA256 = "17484ccdd460e42e2ad5c82a8ca867664694feaf871118c410a134996a532358" @@ -319,10 +324,11 @@ def test_provider_import_direction_pruning_and_runtime_identities_are_exact() -> for node in ast.walk(main_tree): if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load): main_load_counts[node.id] = main_load_counts.get(node.id, 0) + 1 + # /v1 agent-key authentication resolves the bound user in main, so + # _resolve_authenticated_v1_user_id is no longer a re-export-only binding. assert {name for name in shared_imports if main_load_counts.get(name, 0) == 0} == { "LOGGER", "_json_object", - "_resolve_authenticated_v1_user_id", } assert providers_router.LOGGER is _api_shared.LOGGER @@ -616,10 +622,10 @@ def test_public_error_and_coverage_controls_include_provider_router_once() -> No ) for path in paths } - assert call_counts["apps/api/src/alicebot_api/main.py"] == 2 + assert call_counts["apps/api/src/alicebot_api/main.py"] == 4 assert call_counts["apps/api/src/alicebot_api/routers/providers.py"] == 59 assert call_counts["apps/api/src/alicebot_api/routers/workspaces.py"] == 4 - assert sum(call_counts.values()) == 298 + assert sum(call_counts.values()) == 300 provider_path = "apps/api/src/alicebot_api/routers/providers.py" for relative_path in ( diff --git a/tests/unit/test_public_errors.py b/tests/unit/test_public_errors.py index e70dc59b..69f53162 100644 --- a/tests/unit/test_public_errors.py +++ b/tests/unit/test_public_errors.py @@ -20,7 +20,7 @@ MAIN_PATH = ROOT / "apps/api/src/alicebot_api/main.py" ROUTERS_PATH = ROOT / "apps/api/src/alicebot_api/routers" PUBLIC_EXCEPTION_RESPONSE_CALL_MANIFEST = { - "apps/api/src/alicebot_api/main.py": 2, + "apps/api/src/alicebot_api/main.py": 4, "apps/api/src/alicebot_api/routers/__init__.py": 0, "apps/api/src/alicebot_api/routers/_api_shared.py": 0, "apps/api/src/alicebot_api/routers/_vnext_automation.py": 0, @@ -188,7 +188,7 @@ def test_http_modules_have_no_exception_text_to_public_response_conversion() -> for relative_path, source in sources.items() } assert call_counts == PUBLIC_EXCEPTION_RESPONSE_CALL_MANIFEST - assert sum(call_counts.values()) == 298 + assert sum(call_counts.values()) == 300 source = "\n".join(sources.values()) assert 'content={"detail": f"thread {thread_id} was not found"}' not in source diff --git a/tests/unit/test_stage_a_vnext_auth_surface.py b/tests/unit/test_stage_a_vnext_auth_surface.py index 249d3155..d8be0318 100644 --- a/tests/unit/test_stage_a_vnext_auth_surface.py +++ b/tests/unit/test_stage_a_vnext_auth_surface.py @@ -155,7 +155,10 @@ async def send(message: dict[str, object]) -> None: "raw_path": path.encode("utf-8"), "query_string": urlencode(query).encode("ascii"), "headers": headers, - "client": ("stage-a-auth-sweep", 50000), + # Loopback, so the unconditional keyless off-loopback gate cannot answer + # first. This sweep is about the agent-key requirement, and a synthetic + # non-loopback host made it pass whether or not that requirement held. + "client": ("127.0.0.1", 50000), "server": ("testserver", 80), "root_path": "", } diff --git a/tests/unit/test_v1_agent_key_auth.py b/tests/unit/test_v1_agent_key_auth.py new file mode 100644 index 00000000..0612bc7b --- /dev/null +++ b/tests/unit/test_v1_agent_key_auth.py @@ -0,0 +1,662 @@ +from __future__ import annotations + +import asyncio +from contextlib import contextmanager +import json +import logging +from typing import Any, Iterator +from urllib.parse import urlencode +from uuid import UUID, uuid4 + +import anyio +import pytest +from fastapi import Request, Response + +import alicebot_api.main as main_module +from alicebot_api.config import Settings +from alicebot_api.routers import continuity as continuity_router +from alicebot_api.vnext_agent_keys import hash_agent_key + + +_AUTHENTICATION_FAILED = { + "detail": { + "code": "authentication_failed", + "message": "Authentication failed", + } +} + +# A credential-shaped literal is the point of these tests: the fixture proves +# that presenting a key changes the outcome. +_FIXTURE_AGENT_KEY = "alice_sk_v1_auth_fixture_key_000000000000" # gitleaks:allow + +_LOOPBACK_CLIENT = "127.0.0.1" +_REMOTE_CLIENT = "203.0.113.10" + +# One representative operation per /v1 router family named in the hotfix. +_V1_OPERATIONS: tuple[tuple[str, str], ...] = ( + ("GET", "/v1/providers"), + ("POST", "/v1/providers"), + ("POST", "/v1/runtime/invoke"), + ("GET", "/v1/memory/operations"), + ("POST", "/v1/memory/operations/commit"), + ("POST", "/v1/workspaces/bootstrap"), + ("GET", "/v1/workspaces/bootstrap/status"), +) + + +class _AgentKeyStore: + """Minimal agent-key store: one active key, nothing else resolvable.""" + + def __init__(self, *, user_id: UUID, active_key_count: int = 1) -> None: + self.user_id = user_id + self.active_key_count = active_key_count + self.events: list[dict[str, object]] = [] + self.touched: list[str] = [] + self.record: dict[str, object] = { + "id": str(uuid4()), + "user_id": str(user_id), + "agent_id": "v1-fixture-agent", + "permission_profile": "read_only_agent", + "project_scope": None, + "key_hash": hash_agent_key(_FIXTURE_AGENT_KEY), + "key_prefix": _FIXTURE_AGENT_KEY[:12], + "revoked_at": None, + } + + def count_active_agent_api_keys(self) -> int: + return self.active_key_count + + def get_agent_api_key_by_hash(self, key_hash: str) -> dict[str, object] | None: + if self.active_key_count > 0 and key_hash == self.record["key_hash"]: + return dict(self.record) + return None + + def touch_agent_api_key(self, *, key_id: str) -> dict[str, object]: + self.touched.append(key_id) + return dict(self.record) + + def append_event(self, event: dict[str, object]) -> dict[str, object]: + self.events.append(event) + return event + + +def _install_agent_key_store( + monkeypatch: pytest.MonkeyPatch, + store: _AgentKeyStore, + *, + settings: Settings, +) -> None: + @contextmanager + def fake_user_connection(database_url: str, current_user_id: object) -> Iterator[object]: + assert database_url == settings.database_url + assert current_user_id is not None + yield object() + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "user_connection", fake_user_connection) + monkeypatch.setattr(main_module, "PostgresVNextStore", lambda _conn: store) + + +def _settings( + user_id: UUID, + *, + app_env: str = "test", + app_host: str = "127.0.0.1", + trust_proxy_headers: bool = False, + trusted_proxy_ips: tuple[str, ...] = (), +) -> Settings: + return Settings( + app_env=app_env, + app_host=app_host, + database_url="postgresql://alice-v1-auth-unreachable", + auth_user_id=str(user_id), + trust_proxy_headers=trust_proxy_headers, + trusted_proxy_ips=trusted_proxy_ips, + ) + + +def _scope( + method: str, + path: str, + *, + query: dict[str, str] | None = None, + authorization: str | None = None, + client_host: str = _LOOPBACK_CLIENT, + forwarded_for: str | None = None, + content_type: str = "application/json", +) -> dict[str, object]: + headers: list[tuple[bytes, bytes]] = [(b"content-type", content_type.encode("utf-8"))] + if authorization is not None: + headers.append((b"authorization", authorization.encode("utf-8"))) + if forwarded_for is not None: + headers.append((b"x-forwarded-for", forwarded_for.encode("utf-8"))) + return { + "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 or {}).encode("ascii"), + "headers": headers, + "client": (client_host, 50000), + "server": ("testserver", 80), + "root_path": "", + } + + +def _invoke_app( + method: str, + path: str, + *, + query: dict[str, str] | None = None, + body: dict[str, object] | None = None, + authorization: str | None = None, + client_host: str = _LOOPBACK_CLIENT, + content_type: str = "application/json", +) -> tuple[int, Any]: + """Drive the assembled ASGI app so every middleware runs in order.""" + + messages: list[dict[str, object]] = [] + encoded_body = b"" if body is None else json.dumps(body).encode("utf-8") + received = False + + async def receive() -> dict[str, object]: + nonlocal received + if received: + return {"type": "http.disconnect"} + received = True + return {"type": "http.request", "body": encoded_body, "more_body": False} + + async def send(message: dict[str, object]) -> None: + messages.append(message) + + anyio.run( + main_module.app, + _scope( + method, + path, + query=query, + authorization=authorization, + client_host=client_host, + content_type=content_type, + ), + 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 _build_request( + method: str, + path: str, + *, + query: dict[str, str] | None = None, + body: dict[str, object] | None = None, + authorization: str | None = None, + client_host: str = _LOOPBACK_CLIENT, + forwarded_for: str | None = None, +) -> Request: + encoded_body = b"" if body is None else json.dumps(body).encode("utf-8") + + async def receive() -> dict[str, object]: + return {"type": "http.request", "body": encoded_body, "more_body": False} + + return Request( + _scope( + method, + path, + query=query, + authorization=authorization, + client_host=client_host, + forwarded_for=forwarded_for, + ), + receive, + ) + + +def _run_v1_middleware(request: Request) -> tuple[Response, list[Request]]: + reached: list[Request] = [] + + async def call_next(inner_request: Request) -> Response: + reached.append(inner_request) + return Response(status_code=204) + + response = asyncio.run(main_module.enforce_v1_agent_authentication(request, call_next)) + return response, reached + + +@pytest.mark.parametrize(("method", "path"), _V1_OPERATIONS) +def test_v1_routes_reject_keyless_requests_once_an_agent_key_exists( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + method: str, + path: str, +) -> None: + caplog.set_level(logging.CRITICAL) + user_id = uuid4() + settings = _settings(user_id) + _install_agent_key_store(monkeypatch, _AgentKeyStore(user_id=user_id), settings=settings) + + # The routers keep their own database handle, so any handler that ran would + # fail on the unreachable URL instead of returning a clean 401. + status, payload = _invoke_app(method, path, body=None if method == "GET" else {}) + + assert (status, payload) == (401, _AUTHENTICATION_FAILED) + + +@pytest.mark.parametrize(("method", "path"), _V1_OPERATIONS) +def test_v1_routes_reject_an_unknown_bearer_key( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + method: str, + path: str, +) -> None: + caplog.set_level(logging.CRITICAL) + user_id = uuid4() + settings = _settings(user_id) + _install_agent_key_store(monkeypatch, _AgentKeyStore(user_id=user_id), settings=settings) + + status, payload = _invoke_app( + method, + path, + body=None if method == "GET" else {}, + authorization="Bearer alice_sk_not_the_provisioned_key_0000", # gitleaks:allow + ) + + assert (status, payload) == (401, _AUTHENTICATION_FAILED) + + +def test_v1_request_with_a_valid_key_resolves_that_keys_identity(monkeypatch: pytest.MonkeyPatch) -> None: + user_id = uuid4() + settings = _settings(user_id) + store = _AgentKeyStore(user_id=user_id) + _install_agent_key_store(monkeypatch, store, settings=settings) + + response, reached = _run_v1_middleware( + _build_request( + "GET", + "/v1/providers", + authorization=f"Bearer {_FIXTURE_AGENT_KEY}", + ) + ) + + assert response.status_code == 204 + assert len(reached) == 1 + identity = reached[0].state.v1_agent_identity + assert identity is not None + assert identity.agent_id == "v1-fixture-agent" + assert identity.permission_profile == "read_only_agent" + assert identity.auth == "agent_api_key" + assert store.touched == [str(store.record["id"])] + + +def test_v1_payload_user_id_cannot_widen_privilege( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + caplog.set_level(logging.CRITICAL) + user_id = uuid4() + settings = _settings(user_id) + _install_agent_key_store( + monkeypatch, + _AgentKeyStore(user_id=user_id, active_key_count=0), + settings=settings, + ) + + response, reached = _run_v1_middleware( + _build_request("POST", "/v1/workspaces/bootstrap", body={"user_id": str(uuid4())}) + ) + + assert response.status_code == 401 + assert reached == [] + + +def test_v1_query_user_id_cannot_widen_privilege( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + caplog.set_level(logging.CRITICAL) + user_id = uuid4() + settings = _settings(user_id) + _install_agent_key_store( + monkeypatch, + _AgentKeyStore(user_id=user_id, active_key_count=0), + settings=settings, + ) + + response, reached = _run_v1_middleware( + _build_request("GET", "/v1/providers", query={"user_id": str(uuid4())}) + ) + + assert response.status_code == 401 + assert reached == [] + + +def test_v1_keyless_loopback_request_is_still_served_while_no_key_exists( + monkeypatch: pytest.MonkeyPatch, +) -> None: + user_id = uuid4() + settings = _settings(user_id) + _install_agent_key_store( + monkeypatch, + _AgentKeyStore(user_id=user_id, active_key_count=0), + settings=settings, + ) + + response, reached = _run_v1_middleware(_build_request("GET", "/v1/providers")) + + assert response.status_code == 204 + assert len(reached) == 1 + assert reached[0].state.v1_agent_identity is None + + +@pytest.mark.parametrize( + ("app_env", "app_host"), + ( + # The gate reads the peer address only. Settings that would once have + # switched it off are pinned here so none of them can switch it off + # again. The third case is the hole this hotfix closes: default env, + # default bind, and a process actually reachable from the network. + ("development", "127.0.0.1"), + ("test", "127.0.0.1"), + ("development", ""), + ("test", "0.0.0.0"), + ("production", "127.0.0.1"), + ("production", "0.0.0.0"), + ), +) +def test_v1_keyless_request_off_loopback_is_refused_before_dispatch( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + app_env: str, + app_host: str, +) -> None: + caplog.set_level(logging.CRITICAL) + user_id = uuid4() + settings = _settings(user_id, app_env=app_env, app_host=app_host) + + def unreachable_store(_conn: object) -> object: + raise AssertionError("off-loopback keyless requests must not reach the key store") + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "PostgresVNextStore", unreachable_store) + + response, reached = _run_v1_middleware( + _build_request("GET", "/v1/providers", client_host=_REMOTE_CLIENT) + ) + + assert response.status_code == 401 + assert reached == [] + + +def test_v1_keyless_request_from_loopback_is_served_on_an_exposed_bind( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A wide bind is fine as long as the peer that arrived is local.""" + + user_id = uuid4() + settings = _settings(user_id, app_host="0.0.0.0") + _install_agent_key_store( + monkeypatch, + _AgentKeyStore(user_id=user_id, active_key_count=0), + settings=settings, + ) + + response, reached = _run_v1_middleware( + _build_request("GET", "/v1/providers", client_host=_LOOPBACK_CLIENT) + ) + + assert response.status_code == 204 + assert len(reached) == 1 + + +def test_v1_forwarded_for_is_ignored_unless_the_peer_is_a_trusted_proxy( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A spoofed forwarded header must not turn a local peer into a remote one.""" + + user_id = uuid4() + settings = _settings(user_id) + _install_agent_key_store( + monkeypatch, + _AgentKeyStore(user_id=user_id, active_key_count=0), + settings=settings, + ) + + response, reached = _run_v1_middleware( + _build_request( + "GET", + "/v1/providers", + client_host=_LOOPBACK_CLIENT, + forwarded_for=_REMOTE_CLIENT, + ) + ) + + assert response.status_code == 204 + assert len(reached) == 1 + + +def test_v1_forwarded_for_from_a_trusted_proxy_refuses_the_remote_client( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + caplog.set_level(logging.CRITICAL) + user_id = uuid4() + settings = _settings( + user_id, + trust_proxy_headers=True, + trusted_proxy_ips=(_LOOPBACK_CLIENT,), + ) + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + + response, reached = _run_v1_middleware( + _build_request( + "GET", + "/v1/providers", + client_host=_LOOPBACK_CLIENT, + forwarded_for=_REMOTE_CLIENT, + ) + ) + + assert response.status_code == 401 + assert reached == [] + + +def test_v1_post_body_still_reaches_the_route_after_middleware_reads_it( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The middleware inspects the JSON body; the handler must still get it.""" + + user_id = uuid4() + candidate_id = uuid4() + settings = _settings(user_id) + _install_agent_key_store( + monkeypatch, + _AgentKeyStore(user_id=user_id, active_key_count=0), + settings=settings, + ) + + @contextmanager + def fake_user_connection(_database_url: str, _current_user_id: object) -> Iterator[object]: + yield object() + + observed: dict[str, object] = {} + + def fake_commit(_store: object, *, user_id: UUID, request: object) -> dict[str, object]: + observed["user_id"] = str(user_id) + observed["candidate_ids"] = [str(value) for value in getattr(request, "candidate_ids")] + observed["include_review_required"] = getattr(request, "include_review_required") + return {"committed": observed["candidate_ids"]} + + monkeypatch.setattr(continuity_router, "get_settings", lambda: settings) + monkeypatch.setattr(continuity_router, "user_connection", fake_user_connection) + monkeypatch.setattr(continuity_router, "ContinuityStore", lambda _conn: object()) + monkeypatch.setattr(continuity_router, "commit_memory_operations", fake_commit) + + status, payload = _invoke_app( + "POST", + "/v1/memory/operations/commit", + body={"candidate_ids": [str(candidate_id)], "include_review_required": True}, + ) + + assert (status, payload) == (200, {"committed": [str(candidate_id)]}) + assert observed == { + "user_id": str(user_id), + "candidate_ids": [str(candidate_id)], + "include_review_required": True, + } + + +def test_every_v1_route_taking_a_body_pins_strict_content_type(monkeypatch: pytest.MonkeyPatch) -> None: + """The widening check reads JSON bodies only; FastAPI must reject the rest. + + ``_v1_request_payload`` returns ``{}`` unless the content type is JSON, so + a foreign ``user_id`` sent as ``text/plain`` is never inspected by the + middleware. That is safe only because every ``/v1`` route that binds a body + model carries ``strict_content_type=True`` and so refuses a non-JSON body + before the handler runs. Relaxing that on any route silently opens a + cross-user path, so pin it here rather than relying on the alignment. + """ + + del monkeypatch + relaxed: list[tuple[str, str]] = [] + for route in main_module.app.router.routes: + contexts = getattr(route, "effective_route_contexts", None) + for context in contexts() if callable(contexts) else (route,): + path = str(getattr(context, "path", "")) + if not path.startswith("/v1"): + continue + dependant = getattr(context, "dependant", None) + if not (getattr(dependant, "body_params", None) or []): + continue + if getattr(context, "strict_content_type", False) is not True: + for method in sorted((getattr(context, "methods", None) or set()) - {"HEAD", "OPTIONS"}): + relaxed.append((method, path)) + + assert relaxed == [] + + +def test_v1_foreign_user_id_under_a_non_json_content_type_never_reaches_the_route( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A body the middleware cannot inspect must not reach a handler either.""" + + user_id = uuid4() + settings = _settings(user_id) + _install_agent_key_store( + monkeypatch, + _AgentKeyStore(user_id=user_id, active_key_count=0), + settings=settings, + ) + monkeypatch.setattr( + continuity_router, + "commit_memory_operations", + lambda *_args, **_kwargs: pytest.fail("a non-JSON body must not reach the route"), + ) + + status, _payload = _invoke_app( + "POST", + "/v1/memory/operations/commit", + body={"user_id": str(uuid4()), "candidate_ids": []}, + content_type="text/plain", + ) + + assert status != 200 + + +def test_vnext_route_rejects_a_keyless_loopback_request_once_a_key_exists( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """Cover the keyed /v0/vnext refusal from a client the gate would accept. + + The repository's existing route sweep drives /v0/vnext from a synthetic, + non-loopback client host, so the unconditional loopback gate now answers + it before the key check runs. Pin the key requirement over HTTP from a + loopback peer, where only the key check can produce the 401. + """ + + caplog.set_level(logging.CRITICAL) + user_id = uuid4() + settings = _settings(user_id) + _install_agent_key_store(monkeypatch, _AgentKeyStore(user_id=user_id), settings=settings) + + status, payload = _invoke_app( + "GET", + "/v0/vnext/projects", + query={"user_id": str(user_id)}, + client_host=_LOOPBACK_CLIENT, + ) + + assert (status, payload) == (401, _AUTHENTICATION_FAILED) + + +def test_vnext_keyless_request_off_loopback_is_refused( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + caplog.set_level(logging.CRITICAL) + user_id = uuid4() + # Default environment and default loopback bind: the configuration in + # which /v0/vnext was previously reachable by any remote client. + settings = _settings(user_id, app_env="development", app_host="127.0.0.1") + + def unreachable_auth(**_kwargs: object) -> tuple[object, object]: + raise AssertionError("off-loopback keyless vNext requests must not reach the key store") + + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr(main_module, "_resolve_vnext_http_auth", unreachable_auth) + + reached: list[Request] = [] + + async def call_next(inner_request: Request) -> Response: + reached.append(inner_request) + return Response(status_code=204) + + request = _build_request( + "GET", + "/v0/vnext/projects", + query={"user_id": str(user_id)}, + client_host=_REMOTE_CLIENT, + ) + response = asyncio.run(main_module._vnext_protected_http_auth(request, call_next)) + + assert response.status_code == 401 + assert reached == [] + + +def test_vnext_keyless_request_on_loopback_still_reaches_the_route( + monkeypatch: pytest.MonkeyPatch, +) -> None: + user_id = uuid4() + settings = _settings(user_id, app_env="development", app_host="127.0.0.1") + monkeypatch.setattr(main_module, "get_settings", lambda: settings) + monkeypatch.setattr( + main_module, + "_resolve_vnext_http_auth", + lambda **_kwargs: (None, None), + ) + + reached: list[Request] = [] + + async def call_next(inner_request: Request) -> Response: + reached.append(inner_request) + return Response(status_code=204) + + request = _build_request( + "GET", + "/v0/vnext/projects", + query={"user_id": str(user_id)}, + client_host=_LOOPBACK_CLIENT, + ) + response = asyncio.run(main_module._vnext_protected_http_auth(request, call_next)) + + assert response.status_code == 204 + assert len(reached) == 1 diff --git a/tests/unit/test_vnext_main.py b/tests/unit/test_vnext_main.py index 792e8b84..ab2c2d47 100644 --- a/tests/unit/test_vnext_main.py +++ b/tests/unit/test_vnext_main.py @@ -937,7 +937,7 @@ async def send(message: dict[str, object]) -> None: "raw_path": path.encode(), "query_string": urlencode(query or {}).encode(), "headers": headers, - "client": ("testclient", 50000), + "client": ("127.0.0.1", 50000), "server": ("testserver", 80), "root_path": "", } diff --git a/tests/unit/test_workspaces_router_split.py b/tests/unit/test_workspaces_router_split.py index fb9319c3..799f3d5d 100644 --- a/tests/unit/test_workspaces_router_split.py +++ b/tests/unit/test_workspaces_router_split.py @@ -44,7 +44,12 @@ _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 + _request_client_is_loopback + _keyless_request_is_off_loopback + _authentication_failed_response _is_v1_path _v1_request_payload + _v1_request_claims_other_user _resolve_v1_http_auth + enforce_v1_agent_authentication + _append_vary_header _cors_origin_allowed _resolve_cors_allow_origin_value _apply_cors_headers _apply_security_headers apply_http_security_posture enforce_authenticated_user_identity healthcheck _apply_legacy_surface_mount_policy @@ -78,8 +83,8 @@ EXPECTED_ROUTE_NAME_MANIFEST_SHA256 = "225c57c08bd8314156c56352dd1c53ffed3f556ce285c666dd6fca125115d0b4" EXPECTED_OPERATION_MANIFEST_SHA256 = "c320979b62d7ee8de244fe38bde5bf3761a4f9d76f76bf3cd8576c30fce9857e" EXPECTED_IMPORT_MANIFEST_SHA256 = "8d9669a4024ea5258cd50f92ac290c2a040ff224dd0a67b5c60faed5ae722517" -EXPECTED_CARRIER_NAMES_SHA256 = "00f4b8aba8e03d77e9936205d45003365df5f4f3afd0b7f6eced5b0b6ab49a9b" -EXPECTED_CARRIER_AST_SHA256 = "1465fa9c2d60479ab41c44f2a2d9dbb2bca4319b4d8368c132b4b158241cf294" +EXPECTED_CARRIER_NAMES_SHA256 = "2c109fc234a05dd8f44e4c34bee49e797fbb5e49e92413391541a7e504da328b" +EXPECTED_CARRIER_AST_SHA256 = "387d8b6ce15fe473a690da845117fe9a51fb1c3b98bf08f3761556b8689caa4b" EXPECTED_ROUTE_NODE_SHA256 = { "get_vnext_workspace": "6c2151bf38b1b1311f016c00d14394afc7077a6ea219f7ce3dcfd9b701474ae7", "bootstrap_v1_workspace": "07b1fe2a4cd03a5ba69abe76e258a457e85e92b0bfba592520ee02d01d759c4b", @@ -475,7 +480,9 @@ def test_workspace_import_direction_pruning_timing_and_runtime_identities_are_ex main_loads = { node.id for node in ast.walk(main_tree) if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load) } - assert (main_imports & router_import_bindings) - main_loads == {"_resolve_authenticated_v1_user_id"} + # /v1 agent-key authentication resolves the bound user in main, so no + # shared binding is a re-export-only import any more. + assert (main_imports & router_import_bindings) - main_loads == set() provider_module_imports = [ node @@ -706,9 +713,9 @@ def test_workspace_test_patches_and_release_controls_follow_moved_ownership() -> ) for path in paths } - assert call_counts["apps/api/src/alicebot_api/main.py"] == 2 + assert call_counts["apps/api/src/alicebot_api/main.py"] == 4 assert call_counts[workspace_path] == 4 - assert sum(call_counts.values()) == 298 + assert sum(call_counts.values()) == 300 def test_workspace_split_receipts_fail_on_old_or_mutated_carriers() -> None: