* fix(cta): fall back to textarea+execCommand when Clipboard API rejects
The "Setup a new Claude Code" CTA fetches /auth/tokens, parses the JSON
response, renders the setup script, THEN calls
`navigator.clipboard.writeText()`. Modern browsers (Safari, Firefox, and
Chrome on stricter configurations) reject `writeText` with
NotAllowedError when transient user activation has been consumed by an
intervening `await` — which is exactly the case here. Users perceived
this as "the browser blocked the copy" and got the manual-paste fallback
modal even though the textarea + `document.execCommand('copy')` path
WOULD have worked synchronously without needing fresh user activation.
`copyToClipboard` now:
- prefers the modern Clipboard API (unchanged for the happy path)
- on writeText rejection, falls back to `copyViaTextarea` instead of
surfacing the rejection to the caller's catch block.
`copyViaTextarea` is the previously-inline textarea fallback factored
out into a named helper, with two small hardening touches:
- `readonly` + `tabindex=-1` so the hidden textarea doesn't steal
focus or pop the virtual keyboard on mobile.
- explicit `setSelectionRange(0, text.length)` to belt-and-braces the
selection on iOS Safari (where `.select()` alone sometimes selects
zero chars on touch-focused textareas).
Only the CTA button needed this — the Step-1 install-command and the
connector-copy buttons all call `writeText` synchronously inside the
click handler (no awaits in between), so they keep their existing
user-gesture context and didn't hit the same rejection. No template
changes there.
* refactor(home): fold Atlassian MCP registration into connectors block
The standalone "Register the Atlassian MCP server" step (was step 6 in
the unified setup script) moves INTO the Atlassian connector's prompt
body so all Atlassian-related setup lives in one logical group. Same
intent that #247 carried for connectors, applied one level deeper:
the hosted Remote MCP registration is part of "set up Atlassian", not
its own ungrouped step.
What changed:
- `app/web/connector_prompts.py` — the Atlassian prompt's step 5
replaces the speculative "Register the on-demand Atlassian MCP under
.claude/mcp/atlassian" line with the actual hosted Remote MCP
registration: `claude mcp add --transport sse atlassian
https://mcp.atlassian.com/v1/sse || true`. The `|| true` keeps re-runs
idempotent and the body explains the OAuth-on-first-use contract.
Both /home's Atlassian tile and the inlined setup-script Atlassian
sub-block emit this line — single source of truth holds.
- `app/web/setup_instructions.py` — `_mcp_servers_block` deleted; the
`mcp_servers` step is removed from `_step_numbers`; resolve_lines no
longer calls it.
- Renumbering: install (1), init (2), catalog (3), preflight (4),
marketplace (5), diagnose (6), connectors (7), confirm (8). Was:
6 = mcp_servers, 7 = diagnose, 8 = connectors, 9 = confirm.
- `tests/test_setup_instructions.py` — Confirm step 9→8, Connect 8→7,
diagnose 7→6, mcp_servers references dropped.
`test_step_numbering_with_connectors_step` now asserts
`"mcp_servers" not in steps`. Stray-Confirm assertion lists shift
by one position.
- `tests/test_setup_page_unified.py` + `tests/test_web_ui.py` — same
step-number shifts in the rendered /setup preview assertions.
The `claude mcp add` line is still the Atlassian Remote-MCP path that
the 2026-05-10 init-report Fix C added — only its position in the
flow changes. /home Atlassian tile copying continues to install the
MCP too (the prompt body the tile pastes contains the same line).
112 tests pass.
* feat(atlassian): operator-overrideable base URL via AGNES_ATLASSIAN_BASE_URL
Adds an env var / YAML key the operator (Terraform module, customer-VM
template, OSS instance.yaml) can set to bake the Atlassian Cloud site
root into the connector prompt — so end users don't have to guess /
paste their org's `https://<myorg>.atlassian.net`.
When set, the Atlassian connector prompt (rendered on both /home tile
and inlined into the setup-script step 7 Atlassian sub-block) replaces
step 1's "Ask me for my Atlassian Cloud site URL and email" with a
one-line note that the URL is already provisioned by the operator and
asks only for the email. Step 4's helper-script body has the
`BASE_URL='<the site URL I gave you>'` placeholder substituted with
the literal value. When unset (empty), the existing "ask the user"
flow remains — no regression for OSS instances.
Resolution + normalization in `get_atlassian_base_url()`:
- env `AGNES_ATLASSIAN_BASE_URL` > yaml `instance.atlassian.base_url` > ""
- strips trailing slash + trailing `/wiki` so the canonical value is
the bare site root. Matches the per-user helper script's
normalization at storage time (atlassian_prompt step 4 guard 2), so
the literal baked in by the operator stays consistent with what the
user's helper script would have computed from their input.
Plumbing:
- `app/instance_config.py`: new `get_atlassian_base_url()` resolver.
- `app/web/connector_prompts.py`:
- `atlassian_prompt(*, base_url: str = "")` — string-replace two
explicit placeholder phrases when base_url is truthy; otherwise
return the prompt unchanged.
- `all_connector_prompts(..., atlassian_base_url: str = "")` —
forwards the kwarg.
- `app/web/router.py` (`_build_context`): reads
`get_atlassian_base_url()` and passes it through to
`all_connector_prompts(...)` so both the /home tile context AND the
inlined-script `resolve_lines(...)` call use the same value.
- `src/welcome_template.py` (`compute_default_agent_prompt`): same
threading via the existing import-on-demand path.
Tests (`tests/test_home_route_resolution.py`):
- `get_atlassian_base_url` resolver: default empty, env override,
trailing-slash strip, trailing-`/wiki` strip.
- `atlassian_prompt(base_url=...)`: literal URL baked in, ask-step
removed, placeholder replaced, operator-baked-in copy appears.
- `atlassian_prompt(base_url="")`: existing ask-the-user flow
unchanged.
- `all_connector_prompts(atlassian_base_url=...)`: kwarg threads
through to the rendered atlassian prompt.
135 tests pass.
* feat(asana): register hosted Asana Remote MCP in connector prompt
The Asana connector prompt only stored a PAT in the OS keychain + ran
a curl verify against /api/1.0/users/me. That set Claude Code up for
direct `curl` calls but didn't actually wire Asana into Claude's tool
list — so the user couldn't ask Claude to "find my open Asana tasks"
and have it work. Symmetric oversight to the Atlassian connector's
original speculative `.claude/mcp/atlassian` line that this branch
already replaced with `claude mcp add --transport sse atlassian
https://mcp.atlassian.com/v1/sse`.
Adds a new step 5 that registers Asana's hosted Remote MCP:
claude mcp add --transport http asana https://mcp.asana.com/mcp || true
This is the V2 endpoint (streamable HTTP transport, launched February
2026). The V1 SSE endpoint at https://mcp.asana.com/sse was deprecated
2026-05-11 (today) and must NOT be used — calling it out explicitly
in the prompt body so a future operator who finds an old reference
doesn't paste the dead URL. OAuth is handled by Claude Code at first
use, same model as the Atlassian MCP step.
The PAT stored in step 3 stays for direct `curl` calls (precheck +
ad-hoc scripts) — the MCP path uses its own OAuth grant, not the PAT.
Old step 5 (revoke instructions) renumbers to step 6 and adds the
`claude mcp remove asana` cleanup hint.
Same single-source-of-truth invariant holds: /home Asana tile + the
inlined Asana sub-block in the setup script (step 7 connectors) both
emit identical text from `asana_prompt()`.
71 tests pass.
* feat(asana): drive MCP OAuth login + end-to-end validation post-register
`claude mcp add --transport http asana ...` only registers the
server in Claude Code's local config — it does NOT trigger OAuth.
The browser tab opens the first time any `mcp__asana__*` tool gets
invoked. So the previous step 5 left a user looking at a "registered"
MCP that, in practice, hadn't authed yet and would fail on first
real use. Same blind spot Atlassian's prompt also has, but Asana was
the one called out in the latest review pass.
Adds a new step 6 between MCP registration (step 5) and the revoke
instructions (now step 7):
a. Tell the user verbatim what's about to happen — a low-impact
read through the MCP will pop the OAuth browser tab; sign in
with the same account whose PAT they stored in step 3 and
approve. Frames the OAuth as one-time so users don't wait
for it on every later call.
b. Drive an actual MCP read. Don't prescribe the exact tool name
because the Asana MCP's exposed surface (`mcp__asana__*`) is
versioned upstream and we don't want to pin to a name that
gets renamed. Instead: tell Claude to pick the lightest read
from its surfaced tool list (users-me / list-workspaces /
equivalent). Document the recovery path when Claude Code
times out waiting for the OAuth tool use: `claude mcp list`
to confirm registration before retrying.
c. Print a single one-line proof that combines wiring + auth:
"Asana MCP connected as <name> — <N> workspace(s) visible."
Explicit anti-echo callout for tokens, task content, comments.
On failure, surface the exact Claude-Code error and stop —
no silent pass.
d. Sanity-check that the MCP OAuth identity and the PAT identity
reference the same Asana account. Easy mistake to make when
the user has multiple Asana accounts — flag only on mismatch,
keep quiet when they match. Recovery: `claude mcp remove asana
&& claude logout asana` then redo step 5.
Step 7 (revoke) absorbs both the keychain delete + the
`claude logout asana` line so users have a single place to undo
everything.
43 tests pass.
* fix(init): clear stale CA env vars on Windows before any TLS handshake
Reported by the 2026-05-11 Windows test pass: after `agnes init` the
gws connector failed with `UnknownIssuer` TLS errors because
`SSL_CERT_FILE` and `REQUESTS_CA_BUNDLE` were still set in Windows
User scope pointing at `C:\Users\localadmin\.config\agnes\ca-bundle.pem`
— a file that did not exist on the test host. Past Agnes installs
(the setup-prompt trust block + older bootstrap helpers) write those
pointers when they materialize a combined Agnes-CA bundle; when the
bundle file later disappears (re-init on a new VM, machine swap, the
~/.agnes dir wiped), the pointers go stale and every native Windows
TLS handshake fails before Agnes itself runs. SSL_CERT_FILE in
particular REPLACES (not appends to) the trust store, so a stale
pointer is silently catastrophic.
`agnes init` now clears stale pointers in two layers before the first
server roundtrip:
1. Current-process env (os.environ) — what the immediately-following
`api_get` to /api/catalog/tables actually reads. Without this, init
itself blows up before it gets to step 2.
2. Windows User-scope env via PowerShell
`[Environment]::SetEnvironmentVariable(name, $null, 'User')` — what
every future shell + every native tool (gws, claude.exe, pip, uv)
inherits. The 2026-05-11 reporter expected this exact cleanup
("init was supposed to clear these but they persisted").
The cleanup is best-effort and conservative:
- Only deletes a var when its value points at a path that does NOT
exist on disk. Intentional operator config (e.g. SSL_CERT_FILE
pointing at a corp certifi bundle) stays put.
- PowerShell missing / restricted execution policy / WSL-without-pwsh:
swallowed silently. The current-process leg still runs, which
unblocks init even on hosts where the User-scope leg cannot fire.
Tests (`tests/test_init_ca_cleanup.py`, 6 cases):
- Stale pointers → removed from process env.
- Real-path pointers → preserved.
- Non-Windows hosts: PowerShell is not invoked.
- Windows hosts: PowerShell IS invoked with a script that checks
all three vars + uses Test-Path + SetEnvironmentVariable.
- PowerShell FileNotFoundError: cleanup swallows it, does not raise.
- `_is_windows_host()` reflects sys.platform.
* refactor(asana): MCP-first flow — drop PAT storage, precheck via `claude mcp list`
The Asana hosted MCP at https://mcp.asana.com/mcp authenticates via
OAuth (Claude Code holds the grant; browser tab pops on first tool
use). The earlier prompt walked the user through creating + keychain-
storing an Asana Personal Access Token AND registering the MCP — two
parallel auth surfaces for one connector. Once the MCP works, the PAT
has no consumer: the precheck/verify steps that used `curl
$BASE/api/1.0/users/me` are just redundant proof that Asana itself is
reachable, which the OAuth handshake already establishes.
Removed:
- Step 0 keychain probe + curl verify against /users/me with PAT.
- Step 1 open developer-console / create PAT.
- Step 2 click "+ New access token", warn shown-ONCE.
- Step 3 helper-script for keychain-storage (per-OS bodies: macOS
`security add-generic-password`, Linux `secret-tool store`, Windows
`cmdkey /generic`).
- Step 4 PAT-side `users/me` verify.
- Step 5's split that kept the PAT around for direct curl scripts.
- Step 6d's "MCP vs PAT identity sanity check" — there is no PAT
anymore, nothing to mismatch against.
New flow (3 steps total):
- Step 0 precheck: `claude mcp list | grep ^asana` — if found, the
server is registered AND Claude Code is holding its OAuth grant
(otherwise prior failure would have removed it); print
"Asana MCP already registered — skipping setup" and stop. Tells the
user the explicit reset command (`claude mcp remove asana && claude
logout asana`) so a re-register stays one paste.
- Step 1: `claude mcp add --transport http asana
https://mcp.asana.com/mcp` — no `|| true` because step 0 should have
caught the "already exists" case. Step explains the V2-vs-V1
endpoint distinction (V1 SSE deprecated 2026-05-11) and the
abort-clean recovery if the precheck somehow missed the existing
server.
- Step 2: same OAuth + low-impact-read validation pattern as before.
- Step 3: revoke instructions (mcp remove + logout + Asana-side app
revoke at app.asana.com/Settings → Apps).
Both surfaces (the /home Asana tile and the inlined Asana sub-block
in the setup script's step 7) emit the new text from the same
asana_prompt() — single-source-of-truth invariant intact.
77 tests pass.
429 lines
17 KiB
Python
429 lines
17 KiB
Python
"""Instance configuration — loads instance.yaml and exposes to FastAPI."""
|
|
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Any, Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_instance_config: Optional[dict] = None
|
|
|
|
|
|
def reset_cache() -> None:
|
|
"""Drop the in-process instance.yaml cache; the next ``load_instance_config``
|
|
call re-reads from disk. Used by `/api/admin/server-config` after a save.
|
|
Public alias so callers don't have to reach into the private global.
|
|
|
|
Also clears ``connectors.bigquery.access.get_bq_access`` so the v2 endpoints
|
|
pick up new BigQuery project IDs after an admin saves `instance.yaml` —
|
|
without this, `get_bq_access`'s `@functools.cache` would freeze the projects
|
|
at first call and require a container restart to pick up changes (Devin
|
|
ANALYSIS_0004 on PR #138). Lazy-imported so this module stays usable in
|
|
environments where the connectors package can't be imported (e.g. unit
|
|
tests of instance_config in isolation)."""
|
|
global _instance_config
|
|
_instance_config = None
|
|
try:
|
|
from connectors.bigquery.access import get_bq_access
|
|
get_bq_access.cache_clear()
|
|
except Exception:
|
|
# Connectors module not loaded yet, or BQ deps missing — both fine.
|
|
pass
|
|
|
|
|
|
def _deep_merge(base: dict, patch: dict) -> dict:
|
|
"""Deep-merge `patch` into `base`, returning a new dict.
|
|
|
|
Dict-into-dict recurses; everything else (scalars, lists, None) is
|
|
replaced wholesale. Used so the writable overlay can hold only the
|
|
sections an operator has touched, while everything else flows from
|
|
the static file unchanged. Same semantics as the helper in
|
|
`/api/admin/server-config`'s POST handler.
|
|
"""
|
|
out = dict(base)
|
|
for key, value in patch.items():
|
|
if isinstance(value, dict) and isinstance(out.get(key), dict):
|
|
out[key] = _deep_merge(out[key], value)
|
|
else:
|
|
out[key] = value
|
|
return out
|
|
|
|
|
|
def load_instance_config() -> dict:
|
|
"""Load instance.yaml as a deep-merge of the static file and the
|
|
writable overlay.
|
|
|
|
Resolution:
|
|
1. Static base: ``CONFIG_DIR/instance.yaml`` via ``config.loader``
|
|
(the source of truth for sections the editor doesn't expose —
|
|
``datasets``, ``corporate_memory``, ``openmetadata``, etc.).
|
|
2. Overlay patch: ``DATA_DIR/state/instance.yaml`` (written by
|
|
``/api/admin/configure`` and ``/api/admin/server-config``;
|
|
contains only the sections those endpoints accept).
|
|
3. Overlay wins per-leaf via deep-merge — operator edits persist,
|
|
static-only sections still flow through.
|
|
|
|
Pre-2026-04-28 this function returned the overlay verbatim when it
|
|
existed and only fell back to static when it didn't. That was a
|
|
silent footgun: the moment someone saved any section through the
|
|
new editor (which writes a narrow overlay by design), every
|
|
consumer of static-only sections (corporate memory page, dataset
|
|
list, OpenMetadata client) saw empty defaults. See PR #107.
|
|
"""
|
|
global _instance_config
|
|
if _instance_config is not None:
|
|
return _instance_config
|
|
|
|
import yaml
|
|
|
|
# Static base — strict validation lives in config.loader.
|
|
base: dict = {}
|
|
try:
|
|
from config.loader import load_instance_config as _load
|
|
base = _load() or {}
|
|
logger.info("Loaded instance.yaml base from config/")
|
|
except Exception as e:
|
|
logger.warning(f"Could not load static instance.yaml: {e}")
|
|
|
|
# Overlay patch from the writable volume. Best-effort — a corrupt
|
|
# overlay shouldn't take the app offline (we'd rather serve stale/base
|
|
# config than 500 every request), but log loudly with a traceback so
|
|
# the corruption surfaces in the operator's logs immediately. The
|
|
# write-side endpoints (POST /api/admin/server-config and /configure)
|
|
# refuse to overwrite a corrupt overlay with HTTP 500, so an admin
|
|
# noticing the saves break is the second line of defence.
|
|
#
|
|
# ${ENV_VAR} interpolation: ``config.loader.load_instance_config`` runs
|
|
# the static base through ``_resolve_env_refs`` already, but raw
|
|
# ``yaml.safe_load`` here would leave overlay strings like
|
|
# ``${ANTHROPIC_API_KEY}`` as literal placeholders. /api/admin/configure
|
|
# writes exactly that string into the seeded ai: block (#176), so we
|
|
# mirror the resolver here before the deep-merge — without it, the
|
|
# LLM factory receives the literal placeholder and rejects it as an
|
|
# invalid api key (#179 review fix).
|
|
# Resolve via _state_dir() so the path matches the writer in
|
|
# app/api/admin.py — under the flat-mount layout (STATE_DIR=/data-state)
|
|
# both the configure-endpoint and the server-config-endpoint write
|
|
# ``/data-state/instance.yaml``; reading from ``/data/state/...`` here
|
|
# would silently load stale config from the regenerable data disk.
|
|
from app.secrets import _state_dir
|
|
overlay_path = _state_dir() / "instance.yaml"
|
|
if overlay_path.exists():
|
|
try:
|
|
overlay = yaml.safe_load(overlay_path.read_text()) or {}
|
|
from config.loader import _resolve_env_refs
|
|
overlay = _resolve_env_refs(overlay)
|
|
base = _deep_merge(base, overlay)
|
|
logger.info("Merged overlay from %s", overlay_path)
|
|
except Exception:
|
|
logger.exception(
|
|
"instance.yaml overlay at %s is corrupt — falling back to "
|
|
"static base config; saves through the editor will refuse "
|
|
"until the file is repaired", overlay_path,
|
|
)
|
|
|
|
_instance_config = base
|
|
return _instance_config
|
|
|
|
|
|
def get_value(*keys, default=None) -> Any:
|
|
"""Get nested value from instance config."""
|
|
config = load_instance_config()
|
|
current = config
|
|
for key in keys:
|
|
if isinstance(current, dict):
|
|
current = current.get(key)
|
|
else:
|
|
return default
|
|
if current is None:
|
|
return default
|
|
return current
|
|
|
|
|
|
def get_data_source_type() -> str:
|
|
return os.environ.get("DATA_SOURCE", get_value("data_source", "type", default="local"))
|
|
|
|
|
|
def get_home_route() -> str:
|
|
"""Path that ``/`` redirects to for an authenticated user.
|
|
|
|
Resolution order: ``AGNES_HOME_ROUTE`` env var (Terraform-friendly,
|
|
overrides everything) > ``instance.home_route`` in instance.yaml >
|
|
default ``/dashboard``. The env-overrides-yaml shape mirrors
|
|
:func:`get_data_source_type` (precedent in this file) so operators
|
|
can flip a fork to ``/home`` per-deployment without forking the
|
|
YAML.
|
|
|
|
Validated to start with ``/`` and not ``//`` so a misconfigured
|
|
value can't pivot the root redirect to an external host.
|
|
"""
|
|
raw = os.environ.get("AGNES_HOME_ROUTE") or get_value(
|
|
"instance", "home_route", default="/dashboard"
|
|
)
|
|
route = (raw or "").strip()
|
|
if not route.startswith("/") or route.startswith("//"):
|
|
return "/dashboard"
|
|
return route
|
|
|
|
|
|
def get_gws_oauth_credentials() -> dict:
|
|
"""Pre-configured Google Workspace CLI OAuth client (client_id + secret).
|
|
|
|
When set, /home renders a connector prompt that tells the analyst (and
|
|
Claude) to export `GOOGLE_WORKSPACE_CLI_CLIENT_ID` and
|
|
`GOOGLE_WORKSPACE_CLI_CLIENT_SECRET` and skip the "create your own GCP
|
|
project" walkthrough — the operator has already provisioned a shared
|
|
OAuth app for the instance. When unset, the prompt falls back to the
|
|
manual `gws auth setup` flow.
|
|
|
|
OAuth client_id + secret here are app identifiers for an installed
|
|
"Desktop app" OAuth client, not a per-user secret. They're rendered
|
|
into the public /home page on purpose — they identify the OAuth app,
|
|
and the redirect-URI / scope guardrails on the GCP-side OAuth client
|
|
are what enforce safety. Treat them like a publishable bundle ID,
|
|
not a credential.
|
|
|
|
Resolution order (env-overrides-yaml, mirrors :func:`get_home_route`):
|
|
|
|
- ``AGNES_GWS_CLIENT_ID`` env > ``instance.gws.client_id`` YAML > None
|
|
- ``AGNES_GWS_CLIENT_SECRET`` env > ``instance.gws.client_secret`` YAML > None
|
|
- ``AGNES_GWS_OAUTHLIB_INSECURE_TRANSPORT`` env > ``instance.gws.oauthlib_insecure_transport`` YAML > "1"
|
|
(kept as "1" by default because the gws CLI binds an HTTP loopback
|
|
on 127.0.0.1:8080 for the OAuth redirect, and Google's oauthlib
|
|
refuses non-HTTPS redirects without this flag).
|
|
|
|
Both id and secret must be set for the configured branch to engage;
|
|
a half-configured instance falls back to manual setup with a warning.
|
|
"""
|
|
cid = os.environ.get("AGNES_GWS_CLIENT_ID") or get_value(
|
|
"instance", "gws", "client_id", default=""
|
|
)
|
|
secret = os.environ.get("AGNES_GWS_CLIENT_SECRET") or get_value(
|
|
"instance", "gws", "client_secret", default=""
|
|
)
|
|
insecure = os.environ.get("AGNES_GWS_OAUTHLIB_INSECURE_TRANSPORT") or get_value(
|
|
"instance", "gws", "oauthlib_insecure_transport", default="1"
|
|
)
|
|
project_id = os.environ.get("AGNES_GWS_PROJECT_ID") or get_value(
|
|
"instance", "gws", "project_id", default=""
|
|
)
|
|
cid = (cid or "").strip()
|
|
secret = (secret or "").strip()
|
|
project_id = (project_id or "").strip()
|
|
# Derive project_id from the client_id when not explicitly set. Google's
|
|
# OAuth client_id format is "<numeric-project-number>-<random>.apps.
|
|
# googleusercontent.com"; the numeric prefix is required by the
|
|
# client_secret.json schema (gws CLI's Rust struct treats it as
|
|
# non-Option). Falls back to "" when the client_id is empty or
|
|
# malformed; the configured branch in the template degrades gracefully.
|
|
if not project_id and cid and "-" in cid:
|
|
project_id = cid.split("-", 1)[0]
|
|
return {
|
|
"client_id": cid,
|
|
"client_secret": secret,
|
|
"project_id": project_id,
|
|
"oauthlib_insecure_transport": str(insecure).strip() or "1",
|
|
"configured": bool(cid and secret),
|
|
}
|
|
|
|
|
|
def get_home_automode_visibility() -> bool:
|
|
"""Whether /home renders the "Step 3 — turn on auto-accept mode"
|
|
install-block. Auto-accept mode is the recommended middle ground
|
|
between default per-action prompting (slow) and full YOLO
|
|
(`--dangerously-skip-permissions`, broad blast radius).
|
|
|
|
Cautious-rollout instances can hide the section by setting
|
|
``AGNES_HOME_SHOW_AUTOMODE=0`` so users learn the permission flow
|
|
first; the same content stays available on /setup-advanced.
|
|
|
|
Resolution: env var > ``instance.home.show_automode`` YAML > True.
|
|
Mirrors :func:`get_home_route` shape so Terraform overrides work
|
|
the same way.
|
|
"""
|
|
raw = os.environ.get("AGNES_HOME_SHOW_AUTOMODE")
|
|
if raw is None:
|
|
raw = get_value("instance", "home", "show_automode", default=True)
|
|
if isinstance(raw, bool):
|
|
return raw
|
|
return str(raw).strip().lower() not in ("0", "false", "no", "off", "")
|
|
|
|
|
|
def get_instance_name() -> str:
|
|
return get_value("instance", "name", default="AI Data Analyst")
|
|
|
|
|
|
def get_instance_subtitle() -> str:
|
|
return get_value("instance", "subtitle", default="")
|
|
|
|
|
|
def get_instance_admin_email() -> str:
|
|
"""Operator-facing contact address shown in user-side prompts that
|
|
suggest the user reach out to their Agnes admin (e.g. the /home GWS
|
|
connector tile renders an "Email admin" mailto button when no shared
|
|
OAuth app is provisioned). Empty string when unset — the template
|
|
branches on the value being truthy, so an empty value hides the
|
|
button rather than rendering a broken `mailto:` link.
|
|
|
|
Resolution: ``AGNES_INSTANCE_ADMIN_EMAIL`` env > ``instance.admin_email`` YAML > "".
|
|
Mirrors :func:`get_home_route` shape so Terraform overrides work.
|
|
"""
|
|
raw = os.environ.get("AGNES_INSTANCE_ADMIN_EMAIL")
|
|
if raw is None:
|
|
raw = get_value("instance", "admin_email", default="")
|
|
return (raw or "").strip()
|
|
|
|
|
|
def get_atlassian_base_url() -> str:
|
|
"""Operator-provisioned Atlassian Cloud site URL — baked into the
|
|
Atlassian connector prompt so end users don't have to guess /
|
|
paste their org's `https://<myorg>.atlassian.net`.
|
|
|
|
When set, the connector prompt's "ask me for the site URL" step
|
|
is replaced by a literal value the helper script substitutes
|
|
directly. When unset (empty string), the prompt falls back to
|
|
asking the user — same flow as today.
|
|
|
|
Normalized: trailing slashes and a trailing ``/wiki`` are stripped
|
|
so the value is always the bare site root. Matches the
|
|
normalization the per-user helper script already does at storage
|
|
time (see atlassian_prompt step 4 guard 2).
|
|
|
|
Resolution: ``AGNES_ATLASSIAN_BASE_URL`` env > ``instance.atlassian.base_url`` YAML > "".
|
|
Mirrors :func:`get_instance_admin_email` so Terraform overrides
|
|
work the same way.
|
|
"""
|
|
raw = os.environ.get("AGNES_ATLASSIAN_BASE_URL")
|
|
if raw is None:
|
|
raw = get_value("instance", "atlassian", "base_url", default="")
|
|
value = (raw or "").strip().rstrip("/")
|
|
if value.endswith("/wiki"):
|
|
value = value[: -len("/wiki")]
|
|
return value
|
|
|
|
|
|
def get_sync_interval() -> str:
|
|
"""Human-readable refresh cadence shown in the analyst welcome prompt."""
|
|
return get_value("instance", "sync_interval", default="1 hour")
|
|
|
|
|
|
def get_allowed_domains() -> list:
|
|
domain = get_value("auth", "allowed_domain", default="")
|
|
if domain:
|
|
return [d.strip() for d in domain.split(",") if d.strip()]
|
|
return []
|
|
|
|
|
|
def get_datasets() -> dict:
|
|
return get_value("datasets", default={})
|
|
|
|
|
|
def get_theme() -> dict:
|
|
return get_value("theme", default={})
|
|
|
|
|
|
def get_auth_config() -> dict:
|
|
return get_value("auth", default={})
|
|
|
|
|
|
def get_corporate_memory_config() -> dict:
|
|
return get_value("corporate_memory", default={})
|
|
|
|
|
|
def get_guardrails_config() -> dict:
|
|
"""Flea-market upload-guardrail config (see docs/STORE_GUARDRAILS.md).
|
|
|
|
Returns the ``guardrails:`` block from instance.yaml, or an empty dict
|
|
when not configured. Call site: ``src/store_guardrails/runner.py``.
|
|
"""
|
|
return get_value("guardrails", default={})
|
|
|
|
|
|
def get_guardrails_review_model() -> str:
|
|
"""Resolved Anthropic model ID used for the LLM security review.
|
|
|
|
Reads ``guardrails.review_model`` (one of ``haiku``, ``sonnet``,
|
|
``opus``, or a concrete ``claude-*`` model ID) and returns the
|
|
concrete model ID. Defaults to Haiku — the cheapest tier — when the
|
|
operator hasn't set the key. Override per-instance for higher-stakes
|
|
review at proportionally higher cost.
|
|
"""
|
|
from connectors.llm.factory import resolve_model_tier
|
|
|
|
raw = get_value("guardrails", "review_model", default="haiku")
|
|
return resolve_model_tier(raw)
|
|
|
|
|
|
def get_guardrails_blocked_quota_per_day() -> int:
|
|
"""Per-submitter cap on `blocked_inline` rows in the trailing 24h.
|
|
|
|
Defaults to 50. Set to 0 in instance.yaml to disable the quota
|
|
entirely (useful for trusted single-tenant deployments). Bounds the
|
|
worst case where a bot loops on malformed ZIPs and fills disk +
|
|
the admin queue with noise.
|
|
"""
|
|
val = get_value("guardrails", "blocked_quota_per_day", default=50)
|
|
try:
|
|
return max(0, int(val))
|
|
except (TypeError, ValueError):
|
|
return 50
|
|
|
|
|
|
def get_guardrails_blocked_bundle_ttl_days() -> int:
|
|
"""How many days to keep a blocked bundle's bytes on disk.
|
|
|
|
Default 30. The submission row + sha256 + size always survive — only
|
|
the bundle bytes get removed. ``bundle_purged_at`` is stamped so the
|
|
detail UI renders *"Bundle purged on …"*. Set to 0 to disable the
|
|
TTL purge entirely (bundles persist indefinitely until manual
|
|
Delete).
|
|
"""
|
|
val = get_value("guardrails", "blocked_bundle_ttl_days", default=30)
|
|
try:
|
|
return max(0, int(val))
|
|
except (TypeError, ValueError):
|
|
return 30
|
|
|
|
|
|
def get_guardrails_stuck_review_grace_seconds() -> int:
|
|
"""How long a submission may stay at ``status='pending_llm'`` before
|
|
the reaper flips it to ``review_error``.
|
|
|
|
The BackgroundTasks worker normally writes a verdict within a few
|
|
seconds. If the worker crashes between status flip and verdict
|
|
write, the row would otherwise sit at pending_llm forever — admin
|
|
queue surfaces it indefinitely; submitter never gets a verdict.
|
|
|
|
Default 1800s (30 min) comfortably exceeds the Sonnet/Opus p99
|
|
wall time for the configured ``MAX_REVIEW_BYTES`` payload. Set to
|
|
0 to disable the reaper entirely.
|
|
"""
|
|
val = get_value("guardrails", "stuck_review_grace_seconds", default=1800)
|
|
try:
|
|
return max(0, int(val))
|
|
except (TypeError, ValueError):
|
|
return 1800
|
|
|
|
|
|
def get_guardrails_enabled() -> bool:
|
|
"""Master kill-switch for the guardrail pipeline.
|
|
|
|
Defaults to True. Operators can disable by setting ``guardrails.enabled:
|
|
false`` in instance.yaml — useful for local development against the
|
|
UI without burning Anthropic tokens. Inline checks always run; this
|
|
flag only gates the LLM step (and skips the pending → approved hold).
|
|
|
|
Auto-fallback: when the YAML says enabled but no ANTHROPIC_API_KEY /
|
|
LLM_API_KEY is set in the environment, behave as disabled. This
|
|
keeps the test suite + first-boot operator experience sane — uploads
|
|
auto-approve until the operator wires up an LLM provider rather than
|
|
silently piling up in ``review_error``.
|
|
"""
|
|
if not bool(get_value("guardrails", "enabled", default=True)):
|
|
return False
|
|
if os.environ.get("ANTHROPIC_API_KEY", "").strip():
|
|
return True
|
|
if os.environ.get("LLM_API_KEY", "").strip():
|
|
return True
|
|
return False
|