diff --git a/anton/__init__.py b/anton/__init__.py index ee54a771..c7d4cf3e 100644 --- a/anton/__init__.py +++ b/anton/__init__.py @@ -1 +1 @@ -__version__ = "2.26.6.22.1" +__version__ = "2.26.6.30.1" diff --git a/anton/analytics.py b/anton/analytics.py index f87fe556..57a87aca 100644 --- a/anton/analytics.py +++ b/anton/analytics.py @@ -35,6 +35,7 @@ from __future__ import annotations import hashlib +import os import threading import time import urllib.parse @@ -51,6 +52,34 @@ # a process, so computing it once is sufficient. _cached_aid: str | None = None +# Cached CI detection. Env-derived (no PII), consistent with this module's +# anonymous design. CI/automation traffic is dropped entirely (see send_event) +# rather than tagged, so it can't pollute the product funnel. Driven by an +# explicit Anton-owned signal (ANTON_IS_CI) with known provider markers as a +# convenience fallback; the bare ``CI`` var is intentionally not consulted — +# it's frequently set to "false" or leaks into local dev shells (ENG-385). +_cached_is_ci: bool | None = None + + +def _env_true(name: str) -> bool: + return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "on"} + + +def _is_ci() -> bool: + """Return True for Anton automation/CI traffic (cached, env-only).""" + global _cached_is_ci + if _cached_is_ci is None: + _cached_is_ci = ( + _env_true("ANTON_IS_CI") + or _env_true("GITHUB_ACTIONS") + or _env_true("GITLAB_CI") + or _env_true("BUILDKITE") + or _env_true("CIRCLECI") + or _env_true("TF_BUILD") + or bool(os.environ.get("JENKINS_URL")) + ) + return _cached_is_ci + def get_installation_id() -> str: """Return a deterministic, anonymous machine fingerprint. @@ -97,10 +126,17 @@ def send_event(settings: "AntonSettings", action: str, **extra: str) -> None: settings: Resolved AntonSettings (checked for analytics_enabled / analytics_url). action: Event name, e.g. ``"anton_started"``. **extra: Additional key=value pairs appended as query parameters. + + CI/automation traffic (ANTON_IS_CI, or a known CI provider) is dropped + rather than sent, so it can't pollute the product funnel (no PII either way). """ try: if not settings.analytics_enabled: return + # Drop CI/automation traffic entirely — no value in product analytics + # from CI runs, and dropping avoids a per-query exclusion filter. + if _is_ci(): + return url = settings.analytics_url if not url: return diff --git a/anton/config/settings.py b/anton/config/settings.py index 7b0acdd8..4dc49871 100644 --- a/anton/config/settings.py +++ b/anton/config/settings.py @@ -8,7 +8,10 @@ def _build_env_files() -> list[str]: - """Build .env loading chain: cwd/.env -> .anton/.env -> ~/.anton/.env""" + """Build .env loading chain: cwd/.env -> .anton/.env -> ~/.anton/.env + -> ~/.cowork/.env. Later files win, so the consolidated ~/.cowork/.env + takes precedence; ~/.anton/.env stays as a fallback for installs that + haven't migrated yet.""" files: list[str] = [".env"] local_env = Path.cwd() / ".anton" / ".env" if local_env.is_file(): @@ -16,6 +19,9 @@ def _build_env_files() -> list[str]: user_env = Path("~/.anton/.env").expanduser() if user_env.is_file(): files.append(str(user_env)) + cowork_env = Path("~/.cowork/.env").expanduser() + if cowork_env.is_file(): + files.append(str(cowork_env)) return files @@ -112,7 +118,7 @@ class AntonSettings(CoreSettings): minds_ssl_verify: bool = True # Publish service - publish_url: str = "https://4nton.ai" + publish_url: str = "https://view.mindshub.ai" backend: str = "local" # local | remote @@ -139,7 +145,18 @@ def model_post_init(self, __context) -> None: ): self.openai_api_key = self.minds_api_key if not self.openai_base_url: - self.openai_base_url = f"{self.minds_url.rstrip('/')}/api/v1" + # Host-aware base URL: api.mindshub.ai serves the + # OpenAI-compatible API at /v1, the legacy mdb.ai host at + # /api/v1. The previous hardcoded /api/v1 was correct only + # for mdb.ai and produced a wrong endpoint for mindshub + # (ENG-436). Mirrors cowork-server's minds_chat_base_url. + base = self.minds_url.rstrip("/") + if base.endswith("/v1"): + self.openai_base_url = base + elif "mdb.ai" in base: + self.openai_base_url = f"{base}/api/v1" + else: + self.openai_base_url = f"{base}/v1" _workspace: Path = PrivateAttr(default=None) diff --git a/anton/core/backends/local.py b/anton/core/backends/local.py index 411d26b1..91beff5c 100644 --- a/anton/core/backends/local.py +++ b/anton/core/backends/local.py @@ -352,7 +352,17 @@ async def start(self) -> None: and "ANTON_MINDS_URL" in env and self._coding_provider == "openai-compatible" ): - env["OPENAI_BASE_URL"] = f"{env['ANTON_MINDS_URL'].rstrip('/')}/api/v1" + # Host-aware (ENG-436): api.mindshub.ai serves /v1, legacy + # mdb.ai serves /api/v1. The previous hardcoded /api/v1 was + # wrong for mindshub. Mirrors config/settings.py + + # cowork-server minds_chat_base_url. + _minds_base = env["ANTON_MINDS_URL"].rstrip("/") + if _minds_base.endswith("/v1"): + env["OPENAI_BASE_URL"] = _minds_base + elif "mdb.ai" in _minds_base: + env["OPENAI_BASE_URL"] = f"{_minds_base}/api/v1" + else: + env["OPENAI_BASE_URL"] = f"{_minds_base}/v1" if self._coding_api_key: sdk_key = { "anthropic": "ANTHROPIC_API_KEY", diff --git a/anton/core/backends/scratchpad_boot.py b/anton/core/backends/scratchpad_boot.py index 6bb371aa..76aa5097 100644 --- a/anton/core/backends/scratchpad_boot.py +++ b/anton/core/backends/scratchpad_boot.py @@ -80,23 +80,26 @@ def _dump_namespace(ns: dict) -> str | None: "ANTON_OPENAI_API_KEY" ) _llm_api_version = os.environ.get("ANTON_OPENAI_API_VERSION") or None - # Mirror LLMClient.from_settings: openai-compatible endpoints - # (Minds/MindsHub) expect image blocks in Anthropic native format, - # not OpenAI image_url data-URLs. _llm_provider_kwargs: dict = { "api_key": _llm_api_key or None, "base_url": _llm_base_url or None, "ssl_verify": _llm_ssl_verify, "api_version": _llm_api_version, } - # Endpoints that proxy to Minds / MindsHub / MDB.AI - # speak Anthropic content format over the OpenAI HTTP envelope, - # so image blocks must be Anthropic-shaped, not OpenAI image_url. + # Only Minds / MindsHub / MDB.AI proxy Anthropic over the OpenAI HTTP + # envelope, so ONLY they need image blocks in Anthropic native format. + # Gate on the host, NOT on "openai-compatible" generally: other + # OpenAI-compatible endpoints (e.g. Gemini at + # generativelanguage.googleapis.com, or a generic proxy) expect + # standard OpenAI image_url blocks. Forcing anthropic format for all + # openai-compatible mangled images for Gemini. OpenAIProvider already + # defaults to supports_vision=True / vision_format="openai", so the + # non-proxy case needs no override. _llm_url_lower = (_llm_base_url or "").lower() _anthropic_proxy = any( host in _llm_url_lower for host in ("mdb.ai", "mindshub.ai") ) - if _scratchpad_provider_name == "openai-compatible" or _anthropic_proxy: + if _anthropic_proxy: _llm_provider_kwargs["supports_vision"] = True _llm_provider_kwargs["vision_format"] = "anthropic" # Resolve the OpenAI "flavor" so the injected web_search() helper can diff --git a/anton/core/datasources/datasources.md b/anton/core/datasources/datasources.md index f046668f..1477c7f0 100644 --- a/anton/core/datasources/datasources.md +++ b/anton/core/datasources/datasources.md @@ -720,6 +720,111 @@ password if your provider requires it. --- +## HasData + +Single API key for 25+ structured web data APIs: Google SERP/Maps/Trends/News/Shopping/Flights/Hotels, +Amazon, Airbnb, Booking.com, Zillow, Redfin, YouTube, Instagram, Indeed, Glassdoor, Yelp, +Yellow Pages, Shopify, Bing, and general web scraping. + +Base URL: `https://api.hasdata.com` · Auth: `x-api-key` header. + +**⚠️ Two-step async pattern — ALWAYS follow this:** +1. `GET /scrape/?` → response contains `requestMetadata.resultUrl` (a CDN URL) +2. Wait 2–3 s, then `GET ` → actual JSON results + +Do not try to read results from the first response body — it only contains metadata. +Do not hit the API root (`/`) — there is no handler there. + +```python +# Canonical usage pattern +import httpx, os, time + +key = os.environ.get('DS_API_KEY') +base = os.environ.get('DS_BASE_URL', 'https://api.hasdata.com') +headers = {"x-api-key": key} + +# Step 1 — trigger the scrape +r = httpx.get(f"{base}/scrape/google/serp", headers=headers, params={"q": "your query", "gl": "us"}) +r.raise_for_status() +result_url = r.json()["requestMetadata"]["resultUrl"] + +# Step 2 — wait for CDN, then fetch results +time.sleep(3) +data = httpx.get(result_url).json() + +# Guard sitelinks — can be a dict OR a list depending on the result +sitelinks = data.get("sitelinks", []) +if isinstance(sitelinks, list): + sitelinks = sitelinks[:3] +``` + +```yaml +engine: hasdata +display_name: HasData +name_from: api_key +popular: true +fields: + - { name: api_key, required: true, secret: true, description: "HasData API key from hasdata.com dashboard" } + - { name: base_url, required: false, secret: false, description: "API base URL", default: "https://api.hasdata.com" } +test_snippet: | + import httpx, os, time + key = os.environ['DS_API_KEY'] + base = os.environ.get('DS_BASE_URL', 'https://api.hasdata.com') + headers = {"x-api-key": key} + r = httpx.get(f"{base}/scrape/google/serp", headers=headers, params={"q": "test", "num": "1"}) + assert r.status_code == 200, f"unexpected status {r.status_code}: {r.text[:200]}" + meta = r.json().get("requestMetadata", {}) + assert "resultUrl" in meta, f"missing resultUrl in response: {list(meta.keys())}" + time.sleep(3) + results = httpx.get(meta["resultUrl"]).json() + assert isinstance(results, dict), f"unexpected results type: {type(results)}" + print("ok") +``` + +Endpoints (all GET, auth via `x-api-key` header): + +| API | Path | Key params | +|---|---|---| +| Google SERP | `/scrape/google/serp` | `q`, `gl`, `hl`, `location`, `num`, `start`, `deviceType` | +| Google AI Mode | `/scrape/google/ai-overview` | `pageToken` (from SERP aiOverview block) | +| Google Maps Search | `/scrape/google-maps/search` | `q`, `gl`, `hl`, `ll`, `start` | +| Google Maps Reviews | `/scrape/google-maps/reviews` | `placeId`, `hl` | +| Google Maps Photos | `/scrape/google-maps/photos` | `placeId` | +| Google Maps Posts | `/scrape/google-maps/posts` | `placeId` | +| Google Trends | `/scrape/google/trends` | `q`, `geo`, `date` | +| Google News | `/scrape/google/news` | `q`, `gl`, `hl` | +| Google Shopping | `/scrape/google/shopping` | `q`, `gl`, `hl` | +| Google Images | `/scrape/google/images` | `q`, `gl`, `hl` | +| Google Flights | `/scrape/google/flights` | `departureId`, `arrivalId`, `outboundDate`, `returnDate`, `type` | +| Google Hotels | `/scrape/google/hotels` | `q`, `checkIn`, `checkOut`, `adults`, `gl`, `hl` | +| Google Rank Checker | `/scrape/google/rank-checker` | `q`, `website`, `gl`, `hl` | +| Bing Search | `/scrape/bing/serp` | `q`, `count`, `offset`, `mkt` | +| Amazon Product | `/scrape/amazon/product` | `asin`, `domain`, `deliveryZip` | +| Amazon Search | `/scrape/amazon/search` | `q`, `domain`, `page` | +| Airbnb Listings | `/scrape/airbnb/search` | `location`, `checkIn`, `checkOut`, `adults` | +| Airbnb Property | `/scrape/airbnb/listing` | `listingId`, `checkIn`, `checkOut` | +| Booking.com Search | `/scrape/booking/search` | `location`, `checkIn`, `checkOut`, `adults` | +| Google Hotels (Booking) | `/scrape/booking/place` | `placeId` | +| Glassdoor Jobs | `/scrape/glassdoor/jobs` | `q`, `location` | +| Indeed Jobs | `/scrape/indeed/jobs` | `q`, `location`, `start` | +| Instagram Profile | `/scrape/instagram/profile` | `username` | +| YouTube Search | `/scrape/youtube/search` | `q` | +| YouTube Video | `/scrape/youtube/video` | `videoId` | +| YouTube Channel | `/scrape/youtube/channel` | `channelId` | +| YouTube Transcript | `/scrape/youtube/transcript` | `videoId`, `lang` | +| Redfin Listings | `/scrape/redfin/search` | `location` | +| Redfin Property | `/scrape/redfin/property` | `url` | +| Zillow Listings | `/scrape/zillow/search` | `location` | +| Zillow Property | `/scrape/zillow/property` | `zpid` | +| Shopify Products | `/scrape/shopify/products` | `domain` | +| Yelp Search | `/scrape/yelp/search` | `q`, `location` | +| Yelp Place | `/scrape/yelp/place` | `alias` | +| Yellow Pages Search | `/scrape/yellowpages/search` | `q`, `location` | +| Yellow Pages Place | `/scrape/yellowpages/place` | `url` | +| Web Scraper | `/scrape/web` | `url`, `extractRules`, `screenshot` | + +--- + ## Adding a new data source Follow the YAML format above. Add to `~/.anton/datasources.md` (user overrides). diff --git a/anton/core/interaction/__init__.py b/anton/core/interaction/__init__.py new file mode 100644 index 00000000..9cef4fa8 --- /dev/null +++ b/anton/core/interaction/__init__.py @@ -0,0 +1,15 @@ +"""Mid-turn human interaction primitives for the agent loop. + +Currently this package hosts file/folder disambiguation (the ``select_path`` +tool). The agent depends only on the abstract :class:`SelectionElicitor` +strategy; each host (CLI, cowork-server harness, …) supplies a concrete +implementation, so the core never learns how the prompt is surfaced. +""" + +from anton.core.interaction.selection import ( + SelectionElicitor, + SelectionOption, + SelectionRequest, +) + +__all__ = ["SelectionElicitor", "SelectionOption", "SelectionRequest"] diff --git a/anton/core/interaction/cli.py b/anton/core/interaction/cli.py new file mode 100644 index 00000000..b357d276 --- /dev/null +++ b/anton/core/interaction/cli.py @@ -0,0 +1,53 @@ +"""Terminal implementation of :class:`SelectionElicitor` for standalone CLI runs. + +Renders a numbered picker to the console and reads the user's choice. The agent +loop already pauses the escape watcher around interactive tools, so this only +has to print the options and await a number. +""" + +from __future__ import annotations + +from anton.core.interaction.selection import SelectionRequest + +__all__ = ["CLISelectionElicitor"] + + +class CLISelectionElicitor: + """Picker that prompts on the terminal (standalone ``anton`` chat).""" + + def __init__(self, console) -> None: + self._console = console + + async def elicit(self, request: SelectionRequest) -> str | None: + from anton.utils.prompt import prompt_or_cancel + + # Browse mode has no visual file tree on a terminal — fall back to a + # typed path (the GUI host gets a real navigable browser instead). + if request.mode == "browse": + self._console.print(f"\n[bold]{request.prompt}[/]") + if request.root: + self._console.print(f" [dim]starting at {request.root}[/]") + chosen = await prompt_or_cancel("Enter a path (Esc to cancel)") + return (chosen or "").strip() or None + + options = request.options + if not options: + return None + + self._console.print(f"\n[bold]{request.prompt}[/]") + for index, option in enumerate(options, start=1): + icon = "📁" if option.kind == "folder" else "📄" + detail = f" [dim]{option.detail}[/]" if option.detail else "" + self._console.print(f" [bold]{index}[/]. {icon} {option.label}{detail}") + + choice = await prompt_or_cancel( + "Select a number (Esc to cancel)", + choices=[str(i) for i in range(1, len(options) + 1)], + ) + if choice is None: + return None + try: + selected = int(choice) - 1 + except ValueError: + return None + return options[selected].value if 0 <= selected < len(options) else None diff --git a/anton/core/interaction/selection.py b/anton/core/interaction/selection.py new file mode 100644 index 00000000..82085b69 --- /dev/null +++ b/anton/core/interaction/selection.py @@ -0,0 +1,76 @@ +"""File/folder disambiguation: value types and the elicitor strategy. + +When the agent cannot tell which file or folder the user means, the +``select_path`` tool offers the candidates and asks the user to pick one +*within the current turn* — the choice comes back as the tool result, so the +agent simply keeps going (no separate "I picked X" user message). + +How the prompt is surfaced is a host concern, isolated behind +:class:`SelectionElicitor`: + +* standalone CLI injects a terminal picker; +* the cowork-server harness injects a streaming picker that emits a GUI event + and awaits the reply. + +The tool and the agent loop depend only on the protocol — never on a concrete +transport — so the same disambiguation logic serves every host. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol + +__all__ = ["SelectionOption", "SelectionRequest", "SelectionElicitor"] + + +@dataclass(frozen=True, slots=True) +class SelectionOption: + """One selectable candidate offered to the user. + + ``value`` is the absolute path handed back to the model on selection; + ``label`` is what the user sees (typically the path relative to the + project root). + """ + + value: str + label: str + kind: str # "file" | "folder" + detail: str = "" + + +@dataclass(frozen=True, slots=True) +class SelectionRequest: + """A request for the user to choose a file or folder. + + Two modes: + + * ``"pick"`` — disambiguate between the concrete ``options`` (e.g. several + files share a name). This is the default. + * ``"browse"`` — the target is unknown/unspecified; the host shows a + navigable browser rooted at ``root`` and the user locates the path + themselves. ``options`` is empty in this mode. + """ + + prompt: str + options: tuple[SelectionOption, ...] = () + kind: str = "any" # "file" | "folder" | "any" — what is being chosen + mode: str = "pick" # "pick" | "browse" + root: str = "" # browse-mode start directory (absolute) + + +class SelectionElicitor(Protocol): + """Strategy for surfacing a :class:`SelectionRequest` and awaiting a choice. + + A Protocol (not an ABC) on purpose: hosts satisfy it by shape, so the + cowork-server streaming picker implements ``elicit`` without importing or + inheriting this class. The tool depends only on the shape. + """ + + async def elicit(self, request: SelectionRequest) -> str | None: + """Present *request*, block until the user responds. + + Returns the chosen option's ``value``, or ``None`` if the user + cancelled / dismissed the picker without choosing. + """ + ... diff --git a/anton/core/llm/client.py b/anton/core/llm/client.py index 3d01e4b2..107cf670 100644 --- a/anton/core/llm/client.py +++ b/anton/core/llm/client.py @@ -254,6 +254,18 @@ def from_settings(cls, settings: AntonSettings) -> LLMClient: api_version = getattr(settings, "openai_api_version", None) compatible_flavor = _resolve_openai_compatible_flavor(settings) + # Only Minds / MindsHub / MDB.AI proxy Anthropic over the OpenAI HTTP + # envelope and need Anthropic-shaped image blocks. Gate on the base-URL + # host, NOT on the "openai-compatible" provider name — other compatible + # endpoints (Gemini at generativelanguage.googleapis.com, generic + # proxies) expect standard OpenAI image_url. Mirrors the scratchpad_boot + # gate; forcing anthropic format unconditionally mangled Gemini images. + _oc_base_host = (settings.openai_base_url or "").lower() + _oc_vision_format = ( + "anthropic" + if any(h in _oc_base_host for h in ("mdb.ai", "mindshub.ai")) + else "openai" + ) # Each factory takes the per-role effort so planning and coding stay # independent even when they resolve to the same provider type. providers = { @@ -275,7 +287,7 @@ def from_settings(cls, settings: AntonSettings) -> LLMClient: ssl_verify=settings.minds_ssl_verify, api_version=api_version, supports_vision=True, - vision_format="anthropic", + vision_format=_oc_vision_format, flavor=compatible_flavor, reasoning_effort=effort, ), diff --git a/anton/core/llm/openai.py b/anton/core/llm/openai.py index 87b6ef8c..b75bc4bb 100644 --- a/anton/core/llm/openai.py +++ b/anton/core/llm/openai.py @@ -602,6 +602,19 @@ def native_web_tools(self) -> set[str]: return {"web_search", "web_fetch"} return set() + @staticmethod + def _sanitize_langfuse_tag(tag: str) -> str: + """Clean a caller-supplied langfuse tag so it survives the wire intact. + + Tags are comma-joined into the ``Langfuse-Tags`` header and the MindsHub + router splits that header on commas — so an embedded comma would + silently split one tag into two — while control chars (CR/LF/…) would + make httpx reject the outbound request. Drop both and trim surrounding + whitespace. Returns "" when nothing usable remains, so the caller can + filter the tag out. + """ + return "".join(c for c in tag if c.isprintable() and c != ",").strip() + def _build_trace_headers(self) -> dict[str, str] | None: """Return langfuse-style headers for the active trace, or None. @@ -620,9 +633,21 @@ def _build_trace_headers(self) -> dict[str, str] | None: headers: dict[str, str] = {} if ctx.session_id: headers["Langfuse-Session-Id"] = ctx.session_id + # Langfuse-Tags: the harness identity plus any caller-supplied tags + # (e.g. an eval harness adding "eval", "eval_run:"). Comma-joined; + # the MindsHub router splits, trims and de-dupes them. Caller tags are + # untrusted, so sanitize each (drop commas/control chars, trim) and + # skip any that end up empty before joining. + tags: list[str] = [] if ctx.harness: - headers["Langfuse-Tags"] = ctx.harness - extra: dict[str, object] = {} + tags.append(ctx.harness) + tags.extend(ctx.tags) + tags = [t for t in (self._sanitize_langfuse_tag(s) for s in tags) if t] + if tags: + headers["Langfuse-Tags"] = ",".join(tags) + # Langfuse-Metadata: caller-supplied metadata first, then the built-in + # turn/harness keys so identity always wins on collision. + extra: dict[str, object] = dict(ctx.metadata or {}) if ctx.turn_id is not None: extra["turn_id"] = ctx.turn_id if ctx.harness: diff --git a/anton/core/llm/prompts.py b/anton/core/llm/prompts.py index 527642f2..3271504a 100644 --- a/anton/core/llm/prompts.py +++ b/anton/core/llm/prompts.py @@ -223,6 +223,13 @@ granularity since you didn't specify." Surface each assumption as it happens so the user \ can redirect mid-flight instead of being blocked up front. Acting silently is wrong; \ acting out loud with your assumptions visible is right. +- NEVER let a training-data fact BLOCK a task without first verifying it is still current. \ +Facts like company public/private status, leadership, product availability, regulatory \ +status, or market listings can change after your training cutoff. If something you learned \ +during training would prevent you from completing a request (e.g., "that company isn't \ +publicly traded so I can't fetch stock data"), treat the user's question itself as evidence \ +the fact may have changed — validate it online FIRST, then proceed. State what you're \ +checking and why: "My training says X, but that could be outdated — let me verify." - Only STOP and ASK when acting on a guess would be costly to undo or is genuinely \ unknowable: destructive or irreversible actions (deleting data, spending money, sending \ messages on the user's behalf), credentials or access you can't obtain, or a fork where \ diff --git a/anton/core/llm/tracing.py b/anton/core/llm/tracing.py index fa85efa6..017d6137 100644 --- a/anton/core/llm/tracing.py +++ b/anton/core/llm/tracing.py @@ -29,6 +29,14 @@ class TraceContext: session_id: str | None = None turn_id: int | None = None harness: str | None = None + # Optional, caller-supplied trace annotations forwarded verbatim to the + # langfuse-style headers (see ``OpenAIProvider._build_trace_headers``). + # `tags` are appended to ``Langfuse-Tags``; `metadata` is merged into + # ``Langfuse-Metadata`` (built-in keys win on collision). Kept generic so + # hosts can attach arbitrary correlation data — e.g. an eval harness adding + # an eval-run id — without changing this structure. + tags: tuple[str, ...] = () + metadata: dict[str, str] | None = None _trace_ctx: ContextVar[TraceContext | None] = ContextVar( diff --git a/anton/core/session.py b/anton/core/session.py index 5bc18c12..f2b718ad 100644 --- a/anton/core/session.py +++ b/anton/core/session.py @@ -50,9 +50,11 @@ READ_IMAGE_TOOL, RECALL_TOOL, SCRATCHPAD_TOOL, + SELECT_PATH_TOOL, UPDATE_ARTIFACT_METADATA_TOOL, ToolDef, ) +from anton.core.interaction.selection import SelectionElicitor from anton.core.utils.scratchpad import ( prepare_scratchpad_exec, format_cell_result, @@ -145,6 +147,7 @@ class ChatSessionConfig: # so resuming a conversation days later still reports the real "now". # None → fall back to today. started_at: datetime | None = None + selection_elicitor: SelectionElicitor | None = None class ChatSession: @@ -195,6 +198,11 @@ def __init__(self, config: ChatSessionConfig) -> None: self._cancel_event = asyncio.Event() self._escape_watcher: EscapeWatcher | None = None self._active_datasource: str | None = None + # Strategy for mid-turn file/folder disambiguation (the `select_path` + # tool). Hosts inject a concrete elicitor — a streaming GUI picker in + # cowork-server, a terminal picker on the CLI. None falls back to the + # console picker (CLI) or a graceful no-op (headless). + self.selection_elicitor: SelectionElicitor | None = config.selection_elicitor coding_provider = config.llm_client.coding_provider coding_conn = coding_provider.export_connection_info() @@ -724,6 +732,9 @@ def _build_core_tools(self) -> None: self.tool_registry.register_tool(scratchpad_tool) self.tool_registry.register_tool(READ_IMAGE_TOOL) + # Interactive file/folder disambiguation — always available; degrades + # to a plain-text prompt when no elicitor/console is present. + self.tool_registry.register_tool(SELECT_PATH_TOOL) if self._cortex is not None or self._self_awareness is not None: self.tool_registry.register_tool(MEMORIZE_TOOL) @@ -1512,6 +1523,8 @@ async def turn_stream( user_input: str | list[dict], *, turn_id: int | None = None, + trace_tags: list[str] | None = None, + trace_metadata: dict[str, str] | None = None, ) -> AsyncIterator[StreamEvent]: """Streaming version of turn(). Yields events as they arrive. @@ -1520,6 +1533,12 @@ async def turn_stream( calls + tool spans made during this turn. Stored on `self._current_turn_id` so the provider layer can read it without threading the arg through every internal call. + + `trace_tags` / `trace_metadata` are optional, opaque annotations the + host can attach to this turn's trace (forwarded to the MindsHub + langfuse headers — see the provider's `_build_trace_headers`). They + are deliberately generic: hosts can add arbitrary correlation data + (e.g. an eval-run id) without any change to Anton. """ self._current_turn_id = turn_id self._append_history({"role": "user", "content": user_input}) @@ -1557,6 +1576,8 @@ async def turn_stream( session_id=self._session_id, turn_id=turn_id if turn_id is not None else self._turn_count + 1, harness=self._harness, + tags=tuple(trace_tags or ()), + metadata=trace_metadata or None, ) ) @@ -1922,9 +1943,13 @@ async def _stream_and_handle_tools( (cell.stdout or ""), description=description, ) - elif tc.name == "connect_new_datasource" or ( - tc.name == "publish_or_preview" - and tc.input.get("action") == "publish" + elif ( + tc.name == "connect_new_datasource" + or tc.name == "select_path" + or ( + tc.name == "publish_or_preview" + and tc.input.get("action") == "publish" + ) ): # Interactive tool — pause spinner AND escape watcher yield StreamTaskProgress( @@ -1933,11 +1958,13 @@ async def _stream_and_handle_tools( ) if self._escape_watcher: self._escape_watcher.pause() - result_text = await self.tool_registry.dispatch_tool( - self, tc.name, tc.input - ) - if self._escape_watcher: - self._escape_watcher.resume() + try: + result_text = await self.tool_registry.dispatch_tool( + self, tc.name, tc.input + ) + finally: + if self._escape_watcher: + self._escape_watcher.resume() yield StreamTaskProgress( phase="analyzing", message="Analyzing results...", diff --git a/anton/core/tools/tool_defs.py b/anton/core/tools/tool_defs.py index c37a24b5..debe6c1e 100644 --- a/anton/core/tools/tool_defs.py +++ b/anton/core/tools/tool_defs.py @@ -7,6 +7,7 @@ handle_read_image, handle_recall, handle_scratchpad, + handle_select_path, handle_update_artifact_metadata, ) @@ -422,3 +423,71 @@ class ToolDef: }, handler=handle_read_image, ) + + +SELECT_PATH_TOOL = ToolDef( + name="select_path", + description=( + "Show the user an inline picker to choose a file or folder, and get back " + "the absolute path. Two modes, chosen by what you pass:\n\n" + "• BROWSE — the location is unknown or the user only referred to it vaguely " + "(e.g. 'a folder somewhere', 'my downloads', 'the project I mentioned'). Call " + "with just a `prompt` (and `kind`); the user navigates a picker to locate it. " + "Use this INSTEAD of asking the user to type or paste a path. Optionally set " + "`start_dir` to seed the starting folder.\n" + "• PICK — you already found several matches and need the user to disambiguate. " + "Pass an explicit `candidates` list, OR a glob `pattern` (optionally under " + "`base_dir`) to find matches within the project. Exactly one match resolves " + "immediately with no prompt; zero matches tells you to refine.\n\n" + 'On selection the tool returns {"status":"resolved","path":""} ' + "— use that path directly and keep going. Other statuses: 'cancelled' (user " + "dismissed), 'no_matches', 'invalid'. Never re-ask in plain text after a " + "resolved selection." + ), + # Injected into the system prompt: bias the model toward the picker over a + # type-the-path request, which is the whole point of the tool. + prompt=( + "When the user refers to a file or folder without giving a path you can " + "confidently resolve, call the `select_path` tool to let them pick it — do " + "NOT ask them to paste or type a path, and do not guess. Browse mode (no " + "candidates/pattern) is the right choice when you don't know where it is." + ), + input_schema={ + "type": "object", + "properties": { + "prompt": { + "type": "string", + "description": "One short line telling the user what to choose, " + "e.g. 'Pick the folder to check' or 'Which \"report.csv\" did you mean?'.", + }, + "kind": { + "type": "string", + "enum": ["file", "folder", "any"], + "description": "What the user should choose. Default 'any'.", + }, + "start_dir": { + "type": "string", + "description": "BROWSE mode only: directory to open the picker at " + "(absolute, or relative to the project root). Defaults to the project root.", + }, + "candidates": { + "type": "array", + "items": {"type": "string"}, + "description": "PICK mode: explicit candidate paths (absolute, or " + "relative to the project root) you have already identified.", + }, + "pattern": { + "type": "string", + "description": "PICK mode: glob to find candidates within the project " + "(e.g. '**/config.json'). Used when `candidates` is omitted.", + }, + "base_dir": { + "type": "string", + "description": "PICK mode: directory to resolve `pattern` against, " + "relative to the project root. Defaults to the project root.", + }, + }, + "required": ["prompt"], + }, + handler=handle_select_path, +) diff --git a/anton/core/tools/tool_handlers.py b/anton/core/tools/tool_handlers.py index 6c8625a2..fa6f2155 100644 --- a/anton/core/tools/tool_handlers.py +++ b/anton/core/tools/tool_handlers.py @@ -1,6 +1,8 @@ from __future__ import annotations +import json import logging +from pathlib import Path from typing import TYPE_CHECKING from anton.core.backends.base import Cell @@ -517,7 +519,15 @@ async def handle_read_image( try: path = Path(file_path).expanduser() if not path.is_absolute(): - path = (Path.cwd() / path).resolve() + # Resolve relative paths against the project workspace (where + # artifacts/decks live), not the process cwd. Under the desktop + # app the agent's cwd is NOT the project, so a project-relative + # image path would otherwise land in the wrong directory and + # come back "file not found". Mirrors publish_or_preview. Falls + # back to cwd for the CLI, where cwd already is the project. + base = getattr(getattr(session, "_workspace", None), "base", None) + root = Path(base) if base else Path.cwd() + path = (root / path).resolve() except OSError as exc: return f"Error: invalid path '{file_path}': {exc}" @@ -578,3 +588,220 @@ async def handle_read_image( }, {"type": "text", "text": summary}, ] + + +# --------------------------------------------------------------------------- +# select_path — interactive file/folder disambiguation +# --------------------------------------------------------------------------- + +# Hard caps keep the picker fast and the prompt readable: never offer more +# than _SELECTION_MAX_CANDIDATES options, and stop walking a glob after +# _SELECTION_SCAN_LIMIT entries so a `**` pattern on a huge tree can't stall. +_SELECTION_MAX_CANDIDATES = 50 +_SELECTION_SCAN_LIMIT = 5000 + + +def _selection_root(session: "ChatSession") -> "Path": + """The directory candidates are confined to: the project root (or cwd).""" + workspace = getattr(session, "_workspace", None) + return (workspace.base if workspace is not None else Path.cwd()).resolve() + + +def _is_within(root: "Path", candidate: "Path") -> bool: + """True when *candidate* is inside *root* — the path-traversal guard.""" + try: + candidate.relative_to(root) + return True + except ValueError: + return False + + +def _collect_selection_candidates(tc_input: dict, root: "Path", kind: str) -> "list[Path]": + """Resolve candidate paths: confined to *root*, deduped, kind-filtered, capped. + + Prefers the model's explicit ``candidates`` list; otherwise globs + ``pattern`` (under an optional ``base_dir``). The private ``.anton`` + workspace is never exposed. + """ + seen: set[Path] = set() + found: list[Path] = [] + + def consider(path: Path) -> bool: + """Add *path* if it qualifies. Returns False once the cap is hit.""" + if len(found) >= _SELECTION_MAX_CANDIDATES: + return False + resolved = path.resolve() + if resolved in seen or not _is_within(root, resolved) or not resolved.exists(): + return True + if ".anton" in resolved.relative_to(root).parts: + return True + if (kind == "file" and not resolved.is_file()) or (kind == "folder" and not resolved.is_dir()): + return True + seen.add(resolved) + found.append(resolved) + return True + + explicit = tc_input.get("candidates") + if isinstance(explicit, list) and explicit: + for raw in explicit: + if not isinstance(raw, str) or not raw.strip(): + continue + candidate = Path(raw).expanduser() + if not candidate.is_absolute(): + candidate = root / candidate + if not consider(candidate): + break + else: + pattern = (tc_input.get("pattern") or "").strip() + if pattern: + base_dir = (tc_input.get("base_dir") or "").strip() + search_root = root + if base_dir: + rel = Path(base_dir).expanduser() + search_root = (rel if rel.is_absolute() else root / rel).resolve() + if _is_within(root, search_root): + for scanned, match in enumerate(search_root.glob(pattern)): + if scanned >= _SELECTION_SCAN_LIMIT or not consider(match): + break + + found.sort(key=lambda p: str(p).lower()) + return found + + +def _selection_option(path: "Path", root: "Path"): + """Build a display option for *path* (label = path relative to the root).""" + from anton.core.interaction.selection import SelectionOption + + try: + label = str(path.relative_to(root)) + except ValueError: + label = str(path) + return SelectionOption( + value=str(path), + label=label, + kind="folder" if path.is_dir() else "file", + ) + + +def _resolve_selection_elicitor(session: "ChatSession"): + """The host-injected elicitor, falling back to a terminal picker on the CLI.""" + elicitor = getattr(session, "selection_elicitor", None) + if elicitor is not None: + return elicitor + console = getattr(session, "_console", None) + if console is None: + return None + from anton.core.interaction.cli import CLISelectionElicitor + + return CLISelectionElicitor(console) + + +def _browse_start_dir(tc_input: dict, root: "Path") -> "Path": + """Resolve the browse-mode starting directory (defaults to the project root).""" + raw = (tc_input.get("start_dir") or "").strip() + if not raw: + return root + start = Path(raw).expanduser() + if not start.is_absolute(): + start = root / start + start = start.resolve() + return start if start.is_dir() else root + + +def _status(status: str, message: str = "", **extra) -> str: + """Serialize a select_path tool result, omitting an empty message.""" + return json.dumps({"status": status, **({"message": message} if message else {}), **extra}) + + +async def _run_elicitor(elicitor, request): + """Run the elicitor, returning (chosen, error_json). error_json is None on success.""" + try: + return await elicitor.elicit(request), None + except Exception as exc: + _log.warning("select_path elicitor failed: %s", exc, exc_info=True) + return None, _status("error", f"Selection failed: {exc}") + + +def _finalize_browse_choice(chosen: "str | None", kind: str, root: "Path") -> str: + """Validate a browse-mode pick: any existing path of the requested kind.""" + if chosen is None: + return _status("cancelled", "The user dismissed the picker without choosing. Ask how they would like to proceed.") + path = Path(chosen).expanduser() + if not path.is_absolute(): + path = root / path + if not path.exists(): + return _status("invalid", "The selected path no longer exists.") + if kind == "file" and not path.is_file(): + return _status("invalid", "A folder was selected but a file was expected.") + if kind == "folder" and not path.is_dir(): + return _status("invalid", "A file was selected but a folder was expected.") + return _status("resolved", path=str(path.resolve())) + + +async def handle_select_path(session: "ChatSession", tc_input: dict) -> str: + """Have the user choose a file/folder; return the chosen path as JSON. + + Two modes, chosen automatically from the inputs: + + * **browse** — no ``candidates``/``pattern`` given: the location is unknown, + so the user navigates a picker to locate it. Use this instead of asking + the user to type or paste a path. + * **pick** — ``candidates`` or ``pattern`` given: disambiguate concrete + matches within the project. Auto-resolves a single match and reports + "no matches" for none, so the picker appears only for a genuine (≥2) + ambiguity. + + The result is fed back as the tool result, so the agent continues without a + separate user message. + """ + from anton.core.interaction.selection import SelectionRequest + + prompt = (tc_input.get("prompt") or "Select a file or folder.").strip() + kind = (tc_input.get("kind") or "any").strip().lower() + if kind not in ("file", "folder", "any"): + kind = "any" + + root = _selection_root(session) + elicitor = _resolve_selection_elicitor(session) + has_candidates = isinstance(tc_input.get("candidates"), list) and bool(tc_input.get("candidates")) + has_pattern = bool((tc_input.get("pattern") or "").strip()) + + # ── browse — locate an unspecified path ────────────────────────────── + if not has_candidates and not has_pattern: + if elicitor is None: + return _status( + "picker_unavailable", + "An interactive picker is unavailable here; ask the user for the path in plain text.", + ) + request = SelectionRequest( + prompt=prompt, kind=kind, mode="browse", root=str(_browse_start_dir(tc_input, root)) + ) + chosen, error = await _run_elicitor(elicitor, request) + browse_root = Path(request.root) if request.root else root + return error or _finalize_browse_choice(chosen, kind, browse_root) + + # ── pick — disambiguate concrete candidates within the project ─────── + candidates = _collect_selection_candidates(tc_input, root, kind) + if not candidates: + return _status( + "no_matches", + "No match found. Refine the pattern, omit candidates/pattern to let the user browse, or ask in plain text.", + ) + if len(candidates) == 1: + return _status("resolved", auto_resolved=True, path=str(candidates[0])) + if elicitor is None: + return _status( + "picker_unavailable", + "An interactive picker is unavailable here; ask the user which of these paths they meant.", + candidates=[str(p) for p in candidates], + ) + + options = tuple(_selection_option(p, root) for p in candidates) + chosen, error = await _run_elicitor(elicitor, SelectionRequest(prompt=prompt, options=options, kind=kind)) + if error: + return error + if chosen is None: + return _status("cancelled", "The user dismissed the picker without choosing. Ask how they would like to proceed.") + if chosen not in {option.value for option in options}: + return _status("invalid", "The returned selection was not one of the offered options.") + return _status("resolved", path=chosen) diff --git a/anton/core/tools/web_tools.py b/anton/core/tools/web_tools.py index 0b102942..9cee5cf1 100644 --- a/anton/core/tools/web_tools.py +++ b/anton/core/tools/web_tools.py @@ -26,8 +26,12 @@ from __future__ import annotations import html +import ipaddress +import os +import socket from html.parser import HTMLParser from typing import TYPE_CHECKING, Any +from urllib.parse import urlparse import httpx @@ -46,6 +50,75 @@ _HTTP_TIMEOUT = 30.0 +# ───────────────────────────────────────────────────────────────────────────── +# SSRF guard +# ───────────────────────────────────────────────────────────────────────────── + +# Private/loopback/link-local/cloud-metadata ranges that must never be fetched +# server-side. Cloud instance metadata (169.254.169.254) lives in link-local. +_BLOCKED_NETWORKS = [ + ipaddress.ip_network("127.0.0.0/8"), # loopback + ipaddress.ip_network("::1/128"), # IPv6 loopback + ipaddress.ip_network("10.0.0.0/8"), # RFC1918 private + ipaddress.ip_network("172.16.0.0/12"), # RFC1918 private + ipaddress.ip_network("192.168.0.0/16"), # RFC1918 private + ipaddress.ip_network("169.254.0.0/16"), # link-local / cloud metadata + ipaddress.ip_network("fe80::/10"), # IPv6 link-local + ipaddress.ip_network("fc00::/7"), # IPv6 unique-local + ipaddress.ip_network("100.64.0.0/10"), # carrier-grade NAT / GCP metadata + ipaddress.ip_network("0.0.0.0/8"), # unspecified +] + + +def _is_blocked_ip(addr: str) -> bool: + try: + ip = ipaddress.ip_address(addr) + except ValueError: + return True # unparseable address → block + return any(ip in net for net in _BLOCKED_NETWORKS) + + +def _check_url_ssrf(url: str) -> str | None: + """Return an error string if *url* targets a private/internal host, else None. + + Resolves the hostname to its IP addresses and rejects any that fall in + private/loopback/link-local/cloud-metadata ranges. This prevents both + direct private-IP requests and 302-to-internal redirect bypasses (each + redirect target is checked before following). + + Set ANTON_ALLOW_PRIVATE_FETCH=1 to disable (self-hosted / LAN use only). + """ + if os.environ.get("ANTON_ALLOW_PRIVATE_FETCH") == "1": + return None + + try: + parsed = urlparse(url) + host = parsed.hostname + if not host: + return f"Invalid URL — could not parse hostname: {url!r}" + + # Resolve all A/AAAA records and reject if any land in a blocked range. + # Using all records (not just the first) guards against DNS round-robin + # where one record is public and another is internal. + try: + infos = socket.getaddrinfo(host, None) + except socket.gaierror as exc: + return f"Could not resolve host {host!r}: {exc}" + + addrs = {info[4][0] for info in infos} + for addr in addrs: + if _is_blocked_ip(addr): + return ( + f"Fetch blocked: {host!r} resolves to a private or " + f"reserved address ({addr}). " + "Set ANTON_ALLOW_PRIVATE_FETCH=1 to allow fetching from " + "private/LAN addresses (self-hosted deployments only)." + ) + except Exception as exc: + return f"SSRF pre-flight check failed for {url!r}: {exc}" + + return None + async def _search_exa(query: str, api_key: str, max_results: int) -> str: """Hit Exa's ``/search`` endpoint and format hits as markdown.""" @@ -161,11 +234,30 @@ def _strip_html(body: str) -> str: async def _fetch_url(url: str, max_chars: int) -> str: """GET a URL and return its text content, truncated to ``max_chars``.""" + # SSRF guard: resolve the initial URL before opening any connection. + if err := _check_url_ssrf(url): + return err + try: + # follow_redirects=False so we can inspect each redirect target before + # following it — prevents a public URL redirecting to an internal one. async with httpx.AsyncClient( - timeout=_HTTP_TIMEOUT, follow_redirects=True + timeout=_HTTP_TIMEOUT, follow_redirects=False ) as client: resp = await client.get(url, headers={"User-Agent": "AntonBot/1.0"}) + + # Manually follow redirects, checking each destination for SSRF. + hops = 0 + while resp.is_redirect and hops < 10: + location = resp.headers.get("location", "") + if not location: + break + # Resolve relative redirects against the current URL. + next_url = str(resp.next_request.url) if resp.next_request else location + if err := _check_url_ssrf(next_url): + return err + resp = await client.send(resp.next_request) + hops += 1 except httpx.TimeoutException: return f"Fetch timed out after {_HTTP_TIMEOUT}s for {url}" except httpx.HTTPError as exc: diff --git a/anton/publisher.py b/anton/publisher.py index cb21e228..6150994b 100644 --- a/anton/publisher.py +++ b/anton/publisher.py @@ -28,6 +28,13 @@ # File extensions treated as text and subject to credential scrubbing. _TEXT_EXTENSIONS = {".html", ".htm", ".js", ".css", ".py", ".txt"} +# Fixed zip entry timestamp so the bundle md5 is a pure function of content + +# arcname, never of the wall clock. Without this, zipfile stamps text entries +# with the current localtime (and binary entries with the file mtime), so the +# md5 "floats" between processes and compute_publish_md5() never reproduces the +# stored last_md5 — the root cause of the false "Unpublished changes" badge. +_ZIP_EPOCH = (1980, 1, 1, 0, 0, 0) + # Artifact types that ship as a fullstack bundle (backend + static/ + secrets). FULLSTACK_ARTIFACT_TYPES = frozenset({"fullstack-stateful-app", "fullstack-stateless-app"}) @@ -35,7 +42,7 @@ _FULLSTACK_EXCLUDED = {"metadata.json", "README.md", "backend.log", ".published.json"} -DEFAULT_PUBLISH_URL = "https://4nton.ai" +DEFAULT_PUBLISH_URL = "https://view.mindshub.ai" # Owner-side housekeeping files that must never enter the published # bundle. `.published.json` in particular holds the artifact's plaintext @@ -150,12 +157,19 @@ def _scrub_content(text: str) -> str: def _write_scrubbed(zf: zipfile.ZipFile, src: Path, arc_name: str) -> None: - """Add *src* to *zf* as *arc_name*, scrubbing credentials from text files.""" + """Add *src* to *zf* as *arc_name*, scrubbing credentials from text files. + + Every entry is written via a ZipInfo carrying a fixed date_time so the + bundle bytes (and thus its md5) depend only on content + arcname, never on + the current time or the source file's mtime. + """ + zi = zipfile.ZipInfo(arc_name, date_time=_ZIP_EPOCH) + zi.compress_type = zipfile.ZIP_DEFLATED if src.suffix.lower() in _TEXT_EXTENSIONS: - raw = src.read_text(encoding="utf-8", errors="ignore") - zf.writestr(arc_name, _scrub_content(raw)) + data = _scrub_content(src.read_text(encoding="utf-8", errors="ignore")).encode("utf-8") else: - zf.write(src, arc_name) + data = src.read_bytes() + zf.writestr(zi, data) def _zip_html(path: Path) -> bytes: @@ -365,3 +379,39 @@ def unpublish( url = f"{publish_url.rstrip('/')}/delete/{md5}" raw = minds_request(url, api_key, method="DELETE", verify=ssl_verify) return json.loads(raw) + + +def list_versions( + report_id: str, + *, + api_key: str, + publish_url: str = DEFAULT_PUBLISH_URL, + ssl_verify: bool = True, +) -> dict: + """List a published report's version history. User is derived from the token. + + Returns dict with keys: report_id, current_md5, artifact_type, title, + versions (list of {md5, published_at, title}). + """ + url = f"{publish_url.rstrip('/')}/versions/{report_id}" + raw = minds_request(url, api_key, method="GET", verify=ssl_verify) + return json.loads(raw) + + +def activate_version( + report_id: str, + md5: str, + *, + api_key: str, + publish_url: str = DEFAULT_PUBLISH_URL, + ssl_verify: bool = True, +) -> dict: + """Make an existing version live (rollback): flip current_md5 to `md5`. + + Static reports only — the service rejects fullstack apps with HTTP 409. + Returns dict with keys: report_id, current_md5, view_url. + """ + url = f"{publish_url.rstrip('/')}/activate/{report_id}" + payload = json.dumps({"md5": md5}).encode() + raw = minds_request(url, api_key, method="POST", payload=payload, verify=ssl_verify) + return json.loads(raw) diff --git a/anton/utils/datasources.py b/anton/utils/datasources.py index 01e6364e..27b1f0a9 100644 --- a/anton/utils/datasources.py +++ b/anton/utils/datasources.py @@ -20,6 +20,31 @@ # DS_* var names for **ALL** fields of registered engines. _DS_KNOWN_VARS: set[str] = set() +# Provider credential env vars injected into the scratchpad execution env for +# the code to USE. Their values must never reach the LLM (ENG-463): a model can +# only emit a secret it can see, and the agent was caught writing a raw `mdb_` +# key into generated code. Scrubbed by exact value (labeled) from anything that +# re-enters model context. +_PROVIDER_SECRET_VARS: tuple[str, ...] = ( + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "GEMINI_API_KEY", + "ANTON_OPENAI_API_KEY", + "ANTON_ANTHROPIC_API_KEY", + "ANTON_GEMINI_API_KEY", + "ANTON_MINDS_API_KEY", +) + +# Well-known provider key formats — scrubbed by shape so a key is redacted even +# when it didn't come from one of our env vars (e.g. the model already emitted +# it, or it arrived via an unexpected path). Length floors keep this from +# matching short incidental `sk-`/`mdb_` strings. +_SECRET_KEY_PATTERN = re.compile( + r"mdb_[A-Za-z0-9._-]{10,}" # MindsHub + r"|sk-[A-Za-z0-9_-]{20,}" # OpenAI / Anthropic (sk-, sk-proj-, sk-ant-) + r"|AIza[A-Za-z0-9_-]{30,}" # Google / Gemini +) + def _reset_registered_ds_vars() -> None: """Clear the DS_* var registries so they can be rebuilt from current vault state.""" @@ -80,14 +105,19 @@ def register_secret_vars( def scrub_credentials(text: str) -> str: - """Remove secret DS_* values from scratchpad output before it reaches the LLM. - - Only redacts vars registered as secret via _register_secret_vars (driven by - DatasourceField.secret=true in datasources.md). Non-secret fields of known - engines (DS_HOST, DS_PORT, DS_BASE_URL, …) are left readable so the LLM can - reason about connection errors. For truly unknown DS_* vars (custom engines - not yet in the registry) the fallback scrubs any long value — conservative - but safe. + """Remove secret values from scratchpad/tool output before it reaches the LLM. + + Redacts, in order: + * DS_* values registered as secret via register_secret_vars (driven by + DatasourceField.secret=true). Non-secret fields of known engines + (DS_HOST, DS_PORT, DS_BASE_URL, …) stay readable so the LLM can reason + about connection errors. + * Unknown DS_* vars (custom engines not yet in the registry) — any long + value, conservatively. + * Provider credentials (ENG-463): the values of _PROVIDER_SECRET_VARS, + then anything matching a well-known key shape (_SECRET_KEY_PATTERN), so + a raw `mdb_`/`sk-`/`AIza` key can't reach model context via a tool + result, traceback, or settings echo. """ for key in _DS_SECRET_VARS: value = os.environ.get(key, "") @@ -104,6 +134,14 @@ def scrub_credentials(text: str) -> str: if not value or len(value) <= 8: continue text = text.replace(value, f"[{key}]") + # Provider keys: exact-value first (informative label), then by shape to + # catch any that didn't originate from one of our env vars. + for key in _PROVIDER_SECRET_VARS: + value = os.environ.get(key, "") + if not value or len(value) <= 8: + continue + text = re.sub(r'(? str | None: + """A short, non-secret label so the LLM can tell connections apart — the + account email, or the database host (+ name). + + Scoped to these inherently non-secret fields on purpose: it is NOT a dump of + every field (which would surface opaque ``client_id`` / config like + ``ssl_mode``) and never a secret. Lets the agent pick the right account + ("send from my support email") even when the connection slug is an old random + one. Any field a record explicitly marks secret via ``secure_keys`` is + skipped, defensively, even though email/host are not normally secret. + """ + secure = set(secure_keys or []) + # `email` is collected by credential forms; `account_email` is stored by the + # OAuth flows (from userinfo). Either identifies the account. + for key in ("email", "account_email"): + val = str(fields.get(key, "")).strip() + if val and key not in secure: + return val + host = str(fields.get("host", "")).strip() + if host and "host" not in secure: + database = str(fields.get("database", "")).strip() + if database and "database" not in secure: + return f"{host}/{database}" + return host + return None + + def restore_namespaced_env(vault: DataVault) -> None: """Clear all DS_* vars, then reinject every saved connection as namespaced.""" _reset_registered_ds_vars() diff --git a/docs/docs/start/install.md b/docs/docs/start/install.md index 048dd78f..3e880ba6 100644 --- a/docs/docs/start/install.md +++ b/docs/docs/start/install.md @@ -47,8 +47,8 @@ The Windows installer additionally: Anton is also available as a desktop application wrapping the same engine: -- **macOS**: [anton-latest.pkg](https://downloads.mindsdb.com/anton/mac/anton-latest.pkg) -- **Windows**: [anton-latest.exe](https://downloads.mindsdb.com/anton/windows/anton-latest.exe) +- **macOS**: [anton-latest.pkg](https://downloads.mindshub.ai/anton/mac/anton-latest.pkg) +- **Windows**: [anton-latest.exe](https://downloads.mindshub.ai/anton/windows/anton-latest.exe) See [Desktop app](/use/desktop) for more. diff --git a/docs/docs/use/desktop.md b/docs/docs/use/desktop.md index 01733487..6b50bb89 100644 --- a/docs/docs/use/desktop.md +++ b/docs/docs/use/desktop.md @@ -11,8 +11,8 @@ workspaces — behind a graphical UI. ## Download -- **macOS**: [anton-latest.pkg](https://downloads.mindsdb.com/anton/mac/anton-latest.pkg) -- **Windows**: [anton-latest.exe](https://downloads.mindsdb.com/anton/windows/anton-latest.exe) +- **macOS**: [anton-latest.pkg](https://downloads.mindshub.ai/anton/mac/anton-latest.pkg) +- **Windows**: [anton-latest.exe](https://downloads.mindshub.ai/anton/windows/anton-latest.exe) Run the installer and launch Anton like any other app. On first launch you pick an LLM provider, just like the terminal flow — see diff --git a/tests/test_analytics.py b/tests/test_analytics.py new file mode 100644 index 00000000..22b2b40e --- /dev/null +++ b/tests/test_analytics.py @@ -0,0 +1,105 @@ +"""Tests for the anonymous analytics layer (ENG-385). + +CI/automation traffic is dropped entirely rather than sent. ``send_event`` fires +a daemon thread doing an HTTP GET; both are stubbed so we can assert what (if +anything) would be sent, without network or threads. +""" + +from __future__ import annotations + +import urllib.parse + +import anton.analytics as analytics + +# Markers _is_ci() consults — cleared in tests so the suite's own environment +# (it may run under GitHub Actions) doesn't leak into assertions. +_CI_MARKERS = ( + "ANTON_IS_CI", + "GITHUB_ACTIONS", + "GITLAB_CI", + "BUILDKITE", + "CIRCLECI", + "TF_BUILD", + "JENKINS_URL", +) + + +class _Settings: + analytics_enabled = True + analytics_url = "https://example.test/collect" + + +def _clear_ci(monkeypatch): + monkeypatch.setattr(analytics, "_cached_is_ci", None) + for var in _CI_MARKERS: + monkeypatch.delenv(var, raising=False) + + +def _capture_url(monkeypatch) -> list[str]: + """Run send_event's thread target synchronously and record the GET URL.""" + captured: list[str] = [] + + class _SyncThread: + def __init__(self, target=None, args=(), daemon=None): + self._target = target + self._args = args + + def start(self): + if self._target: + self._target(*self._args) + + monkeypatch.setattr(analytics.threading, "Thread", _SyncThread) + monkeypatch.setattr(analytics, "_fire", captured.append) + return captured + + +def _query(url: str) -> dict[str, str]: + return dict(urllib.parse.parse_qsl(urllib.parse.urlparse(url).query)) + + +def test_is_ci_true_with_explicit_anton_flag(monkeypatch): + _clear_ci(monkeypatch) + monkeypatch.setenv("ANTON_IS_CI", "true") + assert analytics._is_ci() is True + + +def test_is_ci_true_with_github_actions(monkeypatch): + _clear_ci(monkeypatch) + monkeypatch.setenv("GITHUB_ACTIONS", "true") + assert analytics._is_ci() is True + + +def test_is_ci_ignores_bare_ci_false(monkeypatch): + # A stray `CI=false` (or a leaked `CI`) must not classify as CI — the bare + # `CI` var is intentionally not consulted. + _clear_ci(monkeypatch) + monkeypatch.setenv("CI", "false") + assert analytics._is_ci() is False + + +def test_is_ci_false_without_markers(monkeypatch): + _clear_ci(monkeypatch) + assert analytics._is_ci() is False + + +def test_send_event_dropped_in_ci(monkeypatch): + _clear_ci(monkeypatch) + monkeypatch.setenv("ANTON_IS_CI", "true") + captured = _capture_url(monkeypatch) + + analytics.send_event(_Settings(), "anton_started") + + assert captured == [] # CI traffic is dropped, never sent + + +def test_send_event_sends_when_not_ci(monkeypatch): + _clear_ci(monkeypatch) + captured = _capture_url(monkeypatch) + + analytics.send_event(_Settings(), "anton_query", llm_provider="openai") + + assert len(captured) == 1 + params = _query(captured[0]) + assert params["action"] == "anton_query" + assert params["llm_provider"] == "openai" + assert "is_ci" not in params # flag removed; CI events aren't sent at all diff --git a/tests/test_client.py b/tests/test_client.py index 0885b6e5..6a73796f 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -89,6 +89,29 @@ def test_from_settings_creates_client(self): assert isinstance(client._planning_provider, AnthropicProvider) assert isinstance(client._coding_provider, AnthropicProvider) + def _oc_vision_format(self, base_url: str) -> str: + settings = AntonSettings( + planning_provider="openai-compatible", + coding_provider="openai-compatible", + openai_api_key="test-key", + openai_base_url=base_url, + planning_model="m", + coding_model="m", + _env_file=None, + ) + return LLMClient.from_settings(settings)._planning_provider._vision_format + + def test_openai_compatible_vision_format_gated_on_host(self): + # Minds/MindsHub proxy Anthropic → anthropic image blocks. + assert self._oc_vision_format("https://api.mindshub.ai/v1") == "anthropic" + assert self._oc_vision_format("https://mdb.ai/api/v1") == "anthropic" + # Gemini + generic OpenAI-compatible endpoints expect OpenAI image_url — + # NOT anthropic (the bug: unconditional anthropic mangled Gemini images). + assert self._oc_vision_format( + "https://generativelanguage.googleapis.com/v1beta/openai/" + ) == "openai" + assert self._oc_vision_format("https://my-proxy.example.com/v1") == "openai" + def test_unknown_planning_provider_raises(self): settings = AntonSettings( planning_provider="unknown", diff --git a/tests/test_datasource_context_identity.py b/tests/test_datasource_context_identity.py new file mode 100644 index 00000000..dff4d778 --- /dev/null +++ b/tests/test_datasource_context_identity.py @@ -0,0 +1,84 @@ +"""build_datasource_context surfaces a non-secret identity per connection. + +ENG-508: the LLM must be able to tell connections apart (which Gmail, which DB) +without exposing secrets — so the system-prompt section shows the account email +or the DB host/name, never the credential, and never a dump of opaque/config +fields. +""" +from anton.core.datasources.data_vault import LocalDataVault +from anton.utils.datasources import _connection_identity, build_datasource_context + + +class TestConnectionIdentity: + def test_email_wins(self): + assert _connection_identity({"email": "support@acme.com"}) == "support@acme.com" + + def test_host_and_database(self): + assert _connection_identity({"host": "db.acme.com", "database": "sales"}) == "db.acme.com/sales" + + def test_host_only(self): + assert _connection_identity({"host": "db.acme.com"}) == "db.acme.com" + + def test_no_identity_field(self): + assert _connection_identity({"client_id": "opaque", "ssl_mode": "require"}) is None + assert _connection_identity({}) is None + + def test_respects_secure_keys(self): + # Defensive: a field a record marks secret is never surfaced as identity. + assert _connection_identity({"email": "u@x.com"}, ["email"]) is None + assert _connection_identity({"email": "u@x.com"}, []) == "u@x.com" + assert _connection_identity({"host": "h", "database": "d"}, ["database"]) == "h" + assert _connection_identity({"host": "h"}, ["host"]) is None + + def test_oauth_account_email(self): + # OAuth flows store the address under `account_email`. + assert _connection_identity({"account_email": "u@acme.com"}) == "u@acme.com" + assert _connection_identity({"account_email": "u@acme.com"}, ["account_email"]) is None + + +class TestBuildContext: + def test_shows_identity_not_secrets_or_opaque(self, tmp_path): + v = LocalDataVault(tmp_path) + v.save("gmail", "support", {"email": "support@acme.com", "app_password": "SECRETVAL123"}) + v.save("postgres", "prod", {"host": "db.acme.com", "database": "sales", "password": "PGSECRET"}) + v.save("asana", "team", {"client_id": "opaque-guid", "access_token": "TOKSECRET"}) + + ctx = build_datasource_context(v) + + # Identity is shown so the agent can pick the right account. + assert "support@acme.com" in ctx + assert "db.acme.com/sales" in ctx + # Secrets are never in the prompt (only their DS_* var name). + assert "SECRETVAL123" not in ctx + assert "PGSECRET" not in ctx + assert "TOKSECRET" not in ctx + assert "DS_GMAIL_SUPPORT__APP_PASSWORD" in ctx + # Opaque/config field values are not surfaced as identity. + assert "opaque-guid" not in ctx + + def test_empty_vault_returns_empty(self, tmp_path): + assert build_datasource_context(LocalDataVault(tmp_path)) == "" + + def test_meta_fields_not_listed_as_env_vars(self, tmp_path): + v = LocalDataVault(tmp_path) + v.save( + "gmail", "support", + {"email": "u@x.com", "app_password": "p", "_connector_id": "gmail", + "_method": "app-password", "_label": "Support"}, + ) + ctx = build_datasource_context(v) + # `_`-prefixed bookkeeping must not appear as DS_* env vars. + assert "__CONNECTOR_ID" not in ctx + assert "__METHOD" not in ctx + assert "__LABEL" not in ctx + assert "DS_GMAIL_SUPPORT__EMAIL" in ctx # real fields still listed + + def test_label_preferred_over_email(self, tmp_path): + v = LocalDataVault(tmp_path) + v.save( + "gmail", "support", + {"email": "regtr@mail.com", "app_password": "x", "_label": "Support"}, + ) + ctx = build_datasource_context(v) + assert "Support" in ctx # the human label is shown + assert "regtr@mail.com" not in ctx # label preferred over the opaque email diff --git a/tests/test_scrubbing.py b/tests/test_scrubbing.py index bf1afb99..2fbfd315 100644 --- a/tests/test_scrubbing.py +++ b/tests/test_scrubbing.py @@ -66,3 +66,42 @@ def test_unknown_short_ds_var_not_scrubbed(self, monkeypatch): monkeypatch.setenv("DS_ENABLE_FEATURE", "on") result = scrub_credentials("flag=on active") assert "on" in result + + +class TestScrubProviderKeys: + """Provider API keys must never reach model context (ENG-463).""" + + MINDS_KEY = "mdb_dI2OzIgO.5t7QUxqGPdgrdg2wNwvFFDTUHPyYUZRH" + + def test_provider_key_value_scrubbed_with_label(self, monkeypatch): + """A live provider key present in env is redacted with its var label.""" + monkeypatch.setenv("ANTON_MINDS_API_KEY", self.MINDS_KEY) + result = scrub_credentials(f'api_key = "{self.MINDS_KEY}"') + assert self.MINDS_KEY not in result + assert "[ANTON_MINDS_API_KEY]" in result + + def test_openai_key_value_scrubbed(self, monkeypatch): + key = "sk-proj-abcDEF1234567890abcDEF1234567890" + monkeypatch.setenv("OPENAI_API_KEY", key) + result = scrub_credentials(f"OPENAI_API_KEY={key}") + assert key not in result + assert "[OPENAI_API_KEY]" in result + + def test_mdb_key_scrubbed_by_pattern_without_env(self): + """A key the model already emitted (not in any env var) is caught by shape.""" + result = scrub_credentials("here it is: mdb_AAAAAAAAAA.BBBBBBBBBBBBCCCC") + assert "mdb_AAAAAAAAAA" not in result + assert "[REDACTED_API_KEY]" in result + + def test_sk_and_gemini_keys_scrubbed_by_pattern(self): + text = "k1=sk-ant-api03-abcdefghij1234567890XYZ k2=AIzaSyA1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q" + result = scrub_credentials(text) + assert "sk-ant-api03" not in result + assert "AIzaSy" not in result + + def test_short_sk_and_base_url_left_readable(self, monkeypatch): + """Short `sk-` strings and non-secret base URLs are not over-redacted.""" + monkeypatch.setenv("ANTON_OPENAI_BASE_URL", "https://api.openai.com/v1") + result = scrub_credentials("sk-abc connecting to https://api.openai.com/v1") + assert "sk-abc" in result + assert "https://api.openai.com/v1" in result diff --git a/tests/test_settings.py b/tests/test_settings.py index ebc318ad..4ad71c97 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -106,3 +106,56 @@ def test_workspace_path_before_resolve(self, tmp_path, monkeypatch): s = AntonSettings(anthropic_api_key="test", _env_file=None) # Before resolve, workspace_path falls back to cwd assert s.workspace_path == tmp_path + + +class TestMindsOpenAIBaseUrlDerivation: + """model_post_init derives a host-aware openai_base_url from minds_url. + + Regression for the mdb.ai-era hardcoded /api/v1 (ENG-436): api.mindshub.ai + must derive /v1, mdb.ai keeps /api/v1, and an already-suffixed URL is + preserved. Derivation only fires for an openai-compatible provider when + no openai key/url is already set. + """ + + def _derive(self, minds_url, monkeypatch): + for k in _ANTON_MODEL_KEYS + [ + "ANTON_OPENAI_API_KEY", + "ANTON_OPENAI_BASE_URL", + "ANTON_MINDS_API_KEY", + "ANTON_MINDS_URL", + ]: + monkeypatch.delenv(k, raising=False) + return AntonSettings( + minds_api_key="mdb_test", + minds_url=minds_url, + planning_provider="openai-compatible", + coding_provider="openai-compatible", + _env_file=None, + ) + + def test_mindshub_derives_v1(self, monkeypatch): + s = self._derive("https://api.mindshub.ai", monkeypatch) + assert s.openai_api_key == "mdb_test" + assert s.openai_base_url == "https://api.mindshub.ai/v1" + + def test_legacy_mdb_ai_derives_api_v1(self, monkeypatch): + s = self._derive("https://mdb.ai", monkeypatch) + assert s.openai_base_url == "https://mdb.ai/api/v1" + + def test_already_suffixed_url_preserved(self, monkeypatch): + s = self._derive("https://api.mindshub.ai/v1", monkeypatch) + assert s.openai_base_url == "https://api.mindshub.ai/v1" + + def test_no_derivation_when_openai_key_present(self, monkeypatch): + for k in _ANTON_MODEL_KEYS + ["ANTON_OPENAI_BASE_URL"]: + monkeypatch.delenv(k, raising=False) + s = AntonSettings( + minds_api_key="mdb_test", + minds_url="https://api.mindshub.ai", + openai_api_key="sk-real-user-key", + planning_provider="openai-compatible", + _env_file=None, + ) + # Derivation is skipped because openai_api_key is already set. + assert s.openai_api_key == "sk-real-user-key" + assert s.openai_base_url is None diff --git a/tests/test_trace_headers.py b/tests/test_trace_headers.py new file mode 100644 index 00000000..b07bc9fd --- /dev/null +++ b/tests/test_trace_headers.py @@ -0,0 +1,65 @@ +"""Unit tests for OpenAIProvider._build_trace_headers. + +Locks in the load-bearing behavior of the langfuse trace headers that the +MindsHub router reads: caller-supplied tags are appended after the harness +identity and sanitized, and built-in identity metadata (turn_id / harness) +always wins over caller-supplied metadata on key collision. +""" + +import json + +from anton.core.llm.openai import OpenAIProvider +from anton.core.llm.tracing import ( + TraceContext, + reset_trace_context, + set_trace_context, +) + + +def _provider() -> OpenAIProvider: + # A MindsHub base URL turns on trace-header emission; no network at init. + return OpenAIProvider(api_key="test", base_url="https://api.mindshub.ai/v1") + + +def _headers_for(ctx: TraceContext) -> dict[str, str] | None: + token = set_trace_context(ctx) + try: + return _provider()._build_trace_headers() + finally: + reset_trace_context(token) + + +def test_caller_tags_appended_after_harness(): + headers = _headers_for( + TraceContext(session_id="s1", turn_id=3, harness="anton", + tags=("eval", "eval_run:r1")) + ) + assert headers["Langfuse-Session-Id"] == "s1" + assert headers["Langfuse-Tags"] == "anton,eval,eval_run:r1" + + +def test_builtin_metadata_wins_over_caller(): + headers = _headers_for( + TraceContext(turn_id=7, harness="anton", + metadata={"harness": "spoof", "turn_id": "spoof", "eval_run_id": "r1"}) + ) + meta = json.loads(headers["Langfuse-Metadata"]) + assert meta["harness"] == "anton" # identity wins + assert meta["turn_id"] == 7 # identity wins + assert meta["eval_run_id"] == "r1" # caller-only key preserved + + +def test_tags_are_sanitized(): + headers = _headers_for( + TraceContext(harness="anton", + tags=("good", "ba,d", "wi\nth-nl", " ", " spaced ")) + ) + # comma + newline stripped (not split into new tags), blank dropped, + # surrounding whitespace trimmed. + assert headers["Langfuse-Tags"].split(",") == [ + "anton", "good", "bad", "with-nl", "spaced", + ] + + +def test_no_trace_context_returns_none(): + assert _provider()._build_trace_headers() is None