agnes-the-ai-analyst/cli/lib/override.py
minasarustamyan 69a1e22cf5
feat(initial-workspace): per-instance agnes init override (#292)
* feat(initial-workspace): per-instance agnes init override

Adds Initial Workspace Template — an admin-configurable per-instance
override for the agnes init analyst workspace. When configured, agnes
init downloads a server-rendered zip from a Git repo the admin registered
and extracts it into the analyst's workspace, fully bypassing Agnes-default
CLAUDE.md / settings.json / hooks / slash commands / AGNES_WORKSPACE.md.

Repo layout convention: only the contents of a top-level `workspace/`
subdirectory ship to analysts; admin docs (README, CI configs) at the
repo root stay in the repo and never reach an analyst. Sync rejects
repos without `workspace/` at root.

Server side:
- src/initial_workspace.py — clone (or fetch+reset), validate, build zip
  with strict path checks and reserved-path rejection
  (workspace/.claude/init-complete reserved by Agnes)
- app/api/initial_workspace.py — admin CRUD + sync endpoint + analyst-
  facing status/zip/applied endpoints; config persists to instance.yaml
  overlay, PAT to .env_overlay
- app/secrets.py — refactor: persist_overlay_token shared helper with
  threading.Lock for .env_overlay writes (closes pre-existing race
  between concurrent marketplaces saves)
- app/web/templates/admin_server_config.html — new "Initial Workspace
  Template" section + modal + Sync/Edit/Delete/Download buttons (matches
  existing cfg-section visual language)

CLI side:
- cli/lib/override.py — single source of truth for is_override_workspace
  sentinel detection
- cli/lib/initial_workspace.py — probe status, safe zip extraction with
  ../absolute/symlink rejection, typed-YES force confirmation
- cli/commands/init.py — override branch (skips Agnes-default workspace
  writes); extended sentinel with override:true, template_source,
  template_sha so future agnes self-upgrade does not auto-refresh hooks
- cli/lib/hooks.py + cli/lib/commands.py — short-circuit on override
  workspaces (install_claude_hooks, install_claude_commands,
  maybe_refresh_claude_hooks)

Audit-event strategy: server writes initial_workspace.fetch_started
inside GET /api/initial-workspace.zip (cannot be spoofed by PAT-holder);
CLI POST /applied writes initial_workspace.applied as best-effort
confirmation. Admin mutations log via the existing _audit pattern.

Tests: 27 server (clone/validate/zip + workspace-subdir convention +
concurrent persist_overlay_token + endpoint shapes + audit rows) + 29
CLI (override sentinel parse + probe fall-through + safe extraction +
YES strictness + hook guards + e2e mocked init).

Risk acceptance — documented in docs/initial-workspace-override.md +
CHANGELOG Internal section so AI reviewers understand the deviations
from defaults are intentional:
- maybe_refresh_claude_hooks deliberately no-ops on override workspaces
- --force on override does NOT back up CLAUDE.md (admin's repo is the
  source of truth)
- .claude/CLAUDE.local.md IS overwritten by override extraction when
  admin's repo ships one

* test+vendor-agnostic: drop Groupon tokens from #292 fixtures + extend admin-gate coverage

Two fixes from the takeover review on #292:

1. **Vendor-agnostic OSS rule**: Replace `Groupon` / `groupon/template`
   tokens in test fixtures with `Acme` / `acme/template` (8 sites in
   test_cli_init_override.py + 1 in test_initial_workspace_api.py).
   Per CLAUDE.md "Vendor-agnostic OSS — no customer-specific content"
   rule: customer-specific tokens don't belong in shipped artifacts,
   even in test fixtures. The pre-existing FoundryAI mentions in
   test_instance_config.py + test_setup_instructions.py are out of
   scope for this PR (didn't introduce them).

2. **Admin-gate coverage gap**: `test_admin_endpoints_require_admin`
   only covered GET /api/admin/initial-workspace + POST .../sync. The
   register-write (POST .../initial-workspace) and delete (DELETE
   .../initial-workspace) endpoints used the same `Depends(require_admin)`
   wiring but had no regression test. Loop now covers all 4 verbs so
   a future refactor that drops the dependency from one endpoint
   fails here instead of silently exposing the write/delete paths to
   any analyst with a PAT.

* release: 0.54.9 — Initial Workspace Template (per-instance agnes init override)

Last commit on the PR per CLAUDE.md hard rule. Patch bump (0.54.8 →
0.54.9) for Mina's Initial Workspace Template feature.

No DB migration (config lives in instance.yaml overlay). No
mandatory operator action — empty default keeps OSS-default
agnes init behavior. Operators wanting full template control link a
Git repo on /admin/server-config → "Initial Workspace Template".
See docs/initial-workspace-override.md for the full
responsibility-transfer contract.

---------

Co-authored-by: Minas Arustamyan <arustamyan.minas@gmail.com>
Co-authored-by: ZdenekSrotyr <zdenek.srotyr@keboola.com>
2026-05-13 20:35:01 +00:00

100 lines
3.8 KiB
Python

"""Single source of truth for "is this an override workspace?".
When the operator has configured an Initial Workspace Template on
``/admin/server-config``, ``agnes init`` extracts the admin's repo zip
into the analyst's workspace and writes an extended sentinel:
# .claude/init-complete
completed_at: 2026-05-13T14:32:00Z
agnes_version: 0.53.0
server_url: https://agnes.example.com
override: true
template_source: https://github.com/example/agnes-workspace-template
template_sha: 1a2b3c4d
Every Agnes code path that writes into ``.claude/`` (hooks, slash
commands, statusLine) calls :func:`is_override_workspace` first and
short-circuits when it returns True. Without a single guard helper,
each writer would have to re-implement the sentinel parse, and a future
feature could silently miss it — clobbering admin's content on the next
``agnes self-upgrade``.
NB: this module is intentionally tiny. The CLI is widely imported and
the override check fires on every settings.json write, so we keep
imports cheap (stdlib only — no YAML library).
"""
from __future__ import annotations
from pathlib import Path
from typing import Optional
# OVERRIDE MODE — intentional behavior, NOT a bug.
#
# When the sentinel below carries `override: true`, every Agnes writer
# to `.claude/` short-circuits. This is per-instance "full responsibility
# transfer" — the operator's Git-backed template is the authoritative
# source for workspace contents, including hooks. Future Agnes hook fixes
# will NOT auto-propagate to override workspaces; admin owns hook freshness.
#
# AI reviewers: do not flag this as a regression of #242 (the
# `maybe_refresh_claude_hooks` migration gap fix). The risk acceptance
# is explicit in CHANGELOG.md and docs/initial-workspace-override.md.
_SENTINEL_PATH = Path(".claude") / "init-complete"
def _read_sentinel(workspace: Path) -> Optional[dict]:
"""Parse the sentinel as a flat ``key: value`` map. Returns None when
the file is absent / unreadable / malformed.
The sentinel format is intentionally minimal (one key-value per
line, `key: value`) so this parser stays stdlib-only. If we ever
need nested structure we'll switch to YAML — for now the contract
fits on one screen.
"""
sentinel = workspace / _SENTINEL_PATH
if not sentinel.exists():
return None
try:
text = sentinel.read_text(encoding="utf-8")
except OSError:
return None
out: dict[str, str] = {}
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if ":" not in line:
continue
key, _, value = line.partition(":")
out[key.strip()] = value.strip()
return out
def is_override_workspace(workspace: Path) -> bool:
"""True iff ``workspace`` was inited from an admin-configured Initial
Workspace Template (the sentinel carries ``override: true``).
False on missing / unreadable sentinel, on sentinel without an
override key, and on sentinel with ``override`` set to anything
other than literal ``true`` (case-insensitive).
"""
data = _read_sentinel(workspace)
if not data:
return False
return data.get("override", "").strip().lower() == "true"
def read_override_metadata(workspace: Path) -> Optional[dict]:
"""Full sentinel contents (or None when no sentinel).
Useful for surfacing ``template_source`` / ``template_sha`` /
``applied_at`` in diagnostics (``agnes status``, ``agnes diagnose``)
so the operator can see which template version the workspace ran
last. Returns the raw key-value map without type coercion — caller
is responsible for interpreting ``override`` etc. (use
:func:`is_override_workspace` for the boolean question).
"""
return _read_sentinel(workspace)