agnes-the-ai-analyst/src/initial_workspace.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

286 lines
12 KiB
Python

"""Per-instance Initial Workspace Template — clone, validate, zip.
Mirrors ``src/marketplace.py`` in shape (shallow clone, fetch+reset on
re-sync, token redaction, threading lock) but is a SINGLETON: there is at
most one initial-workspace template per Agnes instance. Configuration
lives in ``instance.yaml`` under ``initial_workspace:`` rather than the
DB (read by ``app/api/initial_workspace.py``, not by this module).
This module has no HTTP surface. Callers:
- ``app/api/initial_workspace.py::sync_endpoint`` (admin "Sync now" click)
- ``app/api/initial_workspace.py::status_endpoint`` (analyst CLI status probe)
- ``app/api/initial_workspace.py::zip_endpoint`` (analyst CLI content fetch)
"""
from __future__ import annotations
import io
import logging
import os
import shutil
import subprocess
import threading
import zipfile
from pathlib import Path
from typing import List, Optional
from app.utils import get_initial_workspace_dir
from src.marketplace import _authenticated_url, _redact
logger = logging.getLogger(__name__)
GIT_TIMEOUT_SEC = 300
# Two admins clicking "Sync now" simultaneously would otherwise race on the
# same working directory (one clone in progress, the other tries to fetch
# from a half-cloned target). Process-local lock is enough because Agnes
# is the sole writer to ``${DATA_DIR}/initial-workspace/``.
_sync_lock = threading.Lock()
# Convention: only the contents of ``<repo-root>/workspace/`` get shipped
# to the analyst's local workspace. Anything else in the repo (README.md,
# CI config, docs, scripts for the admin team) lives at the repo root and
# is INVISIBLE to Agnes. This split keeps the repo usable both as a
# normal codebase (with its own README + CI) and as a workspace template.
_WORKSPACE_SUBDIR = "workspace"
# Paths Agnes reserves and refuses to extract from an admin's template
# repo. Stored RELATIVE TO ``<repo-root>/workspace/`` — so
# ``.claude/init-complete`` here means ``workspace/.claude/init-complete``
# in the admin's repo.
#
# ``.claude/init-complete`` is the sentinel the CLI writes at the end of
# ``agnes init`` (and reads on subsequent runs to decide
# resume-vs-refuse semantics). If admin's repo shipped this file, the
# extraction would clobber Agnes's completion-tracking write, breaking
# override-workspace detection.
#
# Surface the rejection at SYNC time (admin sees it in the Sync-now
# modal) rather than at extract time (analyst sees a confusing failure
# half-way through ``agnes init``). The repo on disk stays unchanged —
# admin must commit + push a fix.
_RESERVED_PATHS: tuple[str, ...] = (
".claude/init-complete",
)
class TemplateValidationError(ValueError):
"""Raised by ``validate_template_tree`` on a structurally unsafe or
Agnes-reserved entry. Surfaces in the Sync-now modal so the admin
sees the specific path that's wrong.
"""
def _run_git(args: List[str], cwd: Optional[Path] = None) -> subprocess.CompletedProcess:
env = {**os.environ, "GIT_TERMINAL_PROMPT": "0"}
return subprocess.run(
["git", *args],
cwd=str(cwd) if cwd else None,
env=env,
capture_output=True,
text=True,
timeout=GIT_TIMEOUT_SEC,
check=True,
)
def validate_template_tree(root: Path) -> None:
"""Walk ``root/workspace/`` and reject paths Agnes refuses to ship.
Called from two places:
1. ``sync_template`` after a successful clone — surfaces validation
errors to the admin in the Sync-now modal response.
2. ``build_zip`` — second-layer defense in case a path snuck in
between sync and zip-build (shouldn't happen, but cheap to
re-check).
Strict pre-check: ``<root>/workspace/`` MUST exist. The repo root
itself is admin's territory (README, CI configs, etc.) — only the
``workspace/`` subdir reaches the analyst. Repos that don't follow
this convention are rejected so analysts never receive admin-only
files by accident.
Within the ``workspace/`` subtree, rejects:
* Symlinks anywhere in the tree (analyst-side extraction following
symlinks would let admin's repo escape the workspace dir).
* ``..`` segments (defense in depth; ``git clone`` shouldn't produce
these, but a manually-edited working dir could).
* Absolute paths (same rationale).
* Agnes-reserved paths in ``_RESERVED_PATHS`` (currently
``.claude/init-complete``, i.e. ``workspace/.claude/init-complete``
in the repo).
Raises ``TemplateValidationError`` with the offending path and the
reason. The error message must NOT leak any token-containing string.
"""
if not root.exists():
return
workspace_dir = root / _WORKSPACE_SUBDIR
if not workspace_dir.is_dir():
raise TemplateValidationError(
f"Repository must contain a {_WORKSPACE_SUBDIR!r} directory at root; "
"its contents are what gets shipped to analyst workspaces. "
"Files outside `workspace/` (README, CI configs, etc.) stay in "
"the repo and are NOT delivered to analysts."
)
for entry in workspace_dir.rglob("*"):
if ".git" in entry.relative_to(workspace_dir).parts:
# Defensive — admin should never ship a nested `.git/` inside
# `workspace/`, but ignore if they do (git plumbing of any
# nested submodule shouldn't reach the analyst).
continue
if entry.is_symlink():
rel = entry.relative_to(workspace_dir).as_posix()
raise TemplateValidationError(
f"symlinks are not allowed in the template repo: "
f"{_WORKSPACE_SUBDIR}/{rel}"
)
rel_posix = entry.relative_to(workspace_dir).as_posix()
if ".." in rel_posix.split("/"):
raise TemplateValidationError(
f"path contains '..' segment: {_WORKSPACE_SUBDIR}/{rel_posix}"
)
if entry.is_absolute() and not str(entry).startswith(str(workspace_dir)):
raise TemplateValidationError(
f"path escapes template root: {_WORKSPACE_SUBDIR}/{rel_posix}"
)
if rel_posix in _RESERVED_PATHS:
raise TemplateValidationError(
f"path {_WORKSPACE_SUBDIR}/{rel_posix} is reserved by Agnes "
"(written by `agnes init` after a successful run). "
"Remove it from your template repo."
)
def sync_template(
url: str,
branch: Optional[str] = None,
token_env: Optional[str] = None,
) -> dict:
"""Shallow-clone (first run) or fetch+reset (subsequent runs) the
template repo into ``${DATA_DIR}/initial-workspace/``.
Returns ``{commit_sha, path, file_count}``. Raises ``RuntimeError`` on
git failure (token-redacted) or ``TemplateValidationError`` when the
cloned tree contains an Agnes-reserved or structurally unsafe path.
Serialized via the module-level ``_sync_lock`` so two parallel admin
clicks don't race the working directory.
"""
if not url:
raise ValueError("initial-workspace template: url is required")
token = os.environ.get(token_env, "") if token_env else ""
target = get_initial_workspace_dir()
auth_url = _authenticated_url(url, token)
is_git = (target / ".git").is_dir()
action = "update" if is_git else "clone"
with _sync_lock:
try:
if not is_git:
if target.exists():
shutil.rmtree(target)
target.parent.mkdir(parents=True, exist_ok=True)
clone_args = ["clone", "--depth", "1"]
if branch:
clone_args += ["--branch", branch]
clone_args += [auth_url, str(target)]
_run_git(clone_args)
else:
_run_git(["remote", "set-url", "origin", auth_url], cwd=target)
ref = branch or "HEAD"
_run_git(["fetch", "--depth", "1", "origin", ref], cwd=target)
_run_git(["reset", "--hard", "FETCH_HEAD"], cwd=target)
sha = _run_git(["rev-parse", "HEAD"], cwd=target).stdout.strip()
except subprocess.CalledProcessError as e:
stderr = _redact(e.stderr or "", token).strip()
raise RuntimeError(f"git {action} failed: {stderr}") from None
except subprocess.TimeoutExpired:
raise RuntimeError(
f"git {action} timed out after {GIT_TIMEOUT_SEC}s"
) from None
# Run the strict validator AFTER the working tree is settled.
# A failure here leaves the working dir on disk so the admin can
# inspect with `git -C ${DATA_DIR}/initial-workspace/ log` what
# got cloned — we don't auto-rollback.
validate_template_tree(target)
files = list_template_files()
logger.info(
"initial-workspace %s sha=%s files=%d",
action, sha, len(files),
)
return {"commit_sha": sha, "path": str(target), "file_count": len(files)}
def list_template_files() -> List[str]:
"""Walk ``<initial-workspace>/workspace/``, exclude ``.git/``, return
sorted POSIX-style relative paths. Deterministic order so
``build_zip`` and the status endpoint return stable content / ETag.
Paths are returned RELATIVE TO the workspace subdir (NOT the repo
root) — that's the layout the analyst's local workspace will mirror
after extraction. A repo file at ``workspace/CLAUDE.md`` shows up
here as ``"CLAUDE.md"``.
Returns an empty list when the working tree does not exist (i.e.
template is registered but never synced) OR when the ``workspace/``
subdir is missing (admin's repo doesn't follow the convention —
``sync_template`` would have already failed, this is defense in
depth for callers that bypass sync).
"""
target = get_initial_workspace_dir()
if not target.exists():
return []
workspace_dir = target / _WORKSPACE_SUBDIR
if not workspace_dir.is_dir():
return []
out: List[str] = []
for entry in workspace_dir.rglob("*"):
if not entry.is_file():
continue
rel_parts = entry.relative_to(workspace_dir).parts
if ".git" in rel_parts:
continue
out.append("/".join(rel_parts))
out.sort()
return out
def build_zip() -> bytes:
"""Build an in-memory zip of ``<initial-workspace>/workspace/``,
excluding ``.git/`` and anything outside the ``workspace/`` subdir.
Re-runs ``validate_template_tree`` first as defense in depth —
sync_template already validates, but a manual edit on disk between
sync and zip-fetch should still fail-closed.
Entry names are relative to ``workspace/`` so the analyst's
extraction lands files directly at workspace root (e.g.
``workspace/CLAUDE.md`` in the repo → ``CLAUDE.md`` at workspace
root after extraction).
Returns the zip bytes. Caller computes ``ETag`` from the bytes (or
from ``last_commit_sha`` for a cheaper stable identifier).
"""
target = get_initial_workspace_dir()
validate_template_tree(target)
workspace_dir = target / _WORKSPACE_SUBDIR
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
for rel in list_template_files():
zf.write(workspace_dir / rel, arcname=rel)
return buf.getvalue()
def delete_template_dir() -> bool:
"""Remove the working copy at ``${DATA_DIR}/initial-workspace/``.
Returns True iff the directory existed and was removed.
"""
target = get_initial_workspace_dir()
if not target.exists():
return False
shutil.rmtree(target)
return True