fix(deploy-local): write templates to host-mapped path (#950) - #971
Conversation
`deploy_local_agent` was silently creating empty agents. The backend probed `/agent-configs/templates` for writability, hit the intentional `:ro` mount, fell back to `./config/agent-templates/<name>` — a path that resolves to `/app/config/...` INSIDE the backend container, with no host mapping. The new agent's bind mount (computed from `HOST_TEMPLATES_PATH`) then pointed at a host path that did not exist, so `/template` came up empty and the workspace was bare. Fix: write deployed-local templates to `/data/deployed-templates/`, which is already host-bound via `TRINITY_DATA_PATH`, writable, and owned by UID 1000 (after #874 / #969). The curated catalog at `/agent-configs/templates` stays read-only — operators' source of truth is preserved. - `services/agent_service/deploy.py`: drop the writability probe and silent fallback; always write to `DEPLOYED_TEMPLATES_DIR_IN_BACKEND`; on `OSError`, fail with HTTP 500 + `code=DEPLOYED_TEMPLATES_DIR_UNWRITABLE` pointing the operator at the non-root-volumes migration doc. - `services/agent_service/crud.py`: extend `local:` template resolution to look in both `/agent-configs/templates` (curated) and `/data/deployed-templates` (deployed). Curated wins on name collision. Bind into the new agent uses `HOST_TEMPLATES_PATH` or `HOST_DEPLOYED_TEMPLATES_PATH` accordingly. - `docker-compose.yml` + `docker-compose.prod.yml`: add `HOST_DEPLOYED_TEMPLATES_PATH` env var (defaults to `${TRINITY_DATA_PATH:-./trinity-data}/deployed-templates`) so the backend knows the host path for the per-agent bind. - `tests/test_deploy_local.py`: poll `/api/agents/{name}/files` after deploy and assert `template.yaml` + `CLAUDE.md` are visible in the agent's workspace. This catches the silent-empty-agent class. - `docs/memory/feature-flows/local-agent-deploy.md`: replace the stale probe-and-fallback description with the new single-path behavior. Related to #950 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Manual end-to-end test on the local dev compose surfaced a second
bug class: the bind-mount transport for `local:` templates assumed
the backend's `/data` and the new agent's host bind would resolve
to the same host path. That's true in prod compose (host bind for
`/data`) but false in dev compose (named volume for `/data`), so
any host-path math in crud.py was right on prod and wrong on dev.
Net: prior version of this PR fixed prod but would still ship empty
agents on dev.
Refactored: drop the bind-mount transport for deploy-local entirely.
Instead, pre-populate `agent-{version}-workspace` directly via
`put_archive` into an ephemeral `alpine:3.20` container that mounts
the workspace volume. The agent's `startup.sh` sees the
`.trinity-initialized` marker we drop in the same tar and skips its
`/template` -> `/home/developer` copy.
Same uniform path on dev and prod — neither environment depends on
host-path mapping for the deploy-local case. Curated templates
(`/agent-configs/templates/...`) are unchanged; they keep the
existing bind-mount transport because the curated catalog IS bound
from the same host path into both backend and agent.
Verified live on local docker-compose.yml stack:
- POST /api/agents/deploy-local returns 200
- /home/developer is `developer:developer` (UID 1000) so the agent
can write to its own home
- template.yaml + CLAUDE.md + nested .claude/skills/test/SKILL.md
all visible via /api/agents/{name}/files and downloadable
- .trinity-initialized marker present
- Agent startup creates .cache/, content/, .trinity/ without
permission errors (was failing before chown of volume root)
Diff vs prior commit on this branch:
- deploy.py: + `_prepopulate_workspace_from_template` helper. Creates
workspace volume, builds a tar with extracted template + marker
entry (all uid/gid 1000), runs an ephemeral alpine container that
receives the tar via put_archive and chowns the volume root to
1000:1000. Image is pre-pulled if missing.
- crud.py: drop the deployed-templates branch in template_volume
setup. /data/deployed-templates is still consulted by the
template.yaml resolution above for redeploys, but the bind into
the new agent container is no longer needed for deploy-local.
- docker-compose.yml + prod.yml: drop HOST_DEPLOYED_TEMPLATES_PATH
env var (no longer read by any code path).
- tests/test_deploy_local.py: explicitly start the agent after
deploy before polling /files (deploy returns the agent stopped).
- feature-flows/local-agent-deploy.md: document the put_archive
approach and why we avoid the bind-mount transport.
Related to #950
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Update — refactored to
|
/review report — fine to mergeCritical findings: 0 Pass 1 (critical / blockers)
Pass 2 (informational)[I1] [I2] Broad [I3] No test asserting the [I4] Sync docker SDK calls inside Scope checkCLEAN. Single concern (issue #950), 4 files (1 doc + 2 service + 1 test), no drift. Two commits on the branch represent the diagnostic journey (host-path approach → put_archive approach after the dev compose mismatch was surfaced by live testing) — that's documented above in the prior PR comment. Other categories — cleanConditional side effects (return-value + exit-code checks; cleanup in 🤖 Generated by |
CodeQL py/path-injection on PR #971 flagged two paths in crud.py where `config.template[6:]` (user-supplied after `local:` strip) is joined directly onto `/agent-configs/templates` and `/data/deployed-templates`. A `local:../../...` payload would resolve outside the templates dir, letting a `creator`-role user probe for the presence of `*/template.yaml` files on the backend FS and — if one happened to exist — have its contents pulled into `template_data` (type / resources / tools / runtime fields), surfaced via the agent config and `GET /api/agents/{name}`. The pre-existing code had the same issue; the alerts surfaced because the #950 diff touched those lines. Fixing now while the surface is fresh. Add `_validate_local_template_name` that requires `[a-zA-Z0-9][a-zA-Z0-9_.-]*` with no `..` substring, called from both the template.yaml-read block (line 282) and the template-volume- bind block (line 379). Rejection returns HTTP 400 with structured `code=INVALID_LOCAL_TEMPLATE_NAME`. Verified live: - `POST /api/agents` with `template=local:../../etc/passwd` → 400 - `POST /api/agents` with a valid name → proceeds as before Related to #950 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CodeQL fix —
|
…950) Prior commit's regex-only validator passed the security test but CodeQL didn't recognise it as a barrier — `py/path-injection` alerts 174 and 175 stayed open on PR #971 after re-scan because the static analyser doesn't follow the regex barrier through the helper-function indirection. Switch to the canonical pattern CodeQL does recognise: candidate = (root / name).resolve() if not candidate.is_relative_to(root): raise Defense is now layered: 1. Regex allowlist (fail fast for obviously hostile input) 2. Resolve + is_relative_to (the CodeQL-recognised barrier) Helper renamed `_validate_local_template_name` -> `_safe_local_template_path` to reflect that it now returns the resolved path. Both crud.py callsites updated to use the resolved path directly. The `template_volume` bind in the second callsite uses `curated_path.name` instead of the raw input — `.name` is the last path component of an already-validated + resolved path, which CodeQL treats as fresh data. Behavior preserved (verified live on local stack): - POST /api/agents with template=local:../../etc/passwd -> 400 INVALID_LOCAL_TEMPLATE_NAME - POST /api/agents with a valid name -> proceeds normally Related to #950
CodeQL status — greenAfter the canonical-barrier refactor (
CodeQL check on PR 971 now passes. Live behaviour:
🤖 Generated with Claude Code |
vybe
left a comment
There was a problem hiding this comment.
Approved per /validate-pr — clean bug fix, adds regression test, also closes CodeQL py/path-injection alerts 174/175 via the canonical resolve()+is_relative_to barrier.
Minor non-blocking nits noted (use 'Fixes #950' next time so it auto-closes; stale 'Write Permission Check' line in local-agent-deploy.md security section can be tidied in a follow-up).
…-gap warnings (#950) (#982) * fix(deploy-local): require usable CLAUDE.md + advisory MCP credential-gap warnings (#950) Deferred hardening for #950. is_trinity_compatible() now hard-fails an archive with no usable CLAUDE.md (missing / empty / whitespace-only / non-UTF-8) with a clean 400 NOT_TRINITY_COMPATIBLE instead of warning and deploying an instruction-less agent. Binary CLAUDE.md is caught explicitly so it yields a 400, not an unhandled 500. New collect_mcp_credential_warnings() scans .mcp.json[.template] for ${VAR} references absent from the post-merge .env and not platform-injected (static allowlist mirroring crud.py), surfaced as advisory DeployLocalResponse.warnings[] (also added to the MCP deploy_local_agent response type). Server names are sanitized (control-char strip + length bound) before echoing — operator-supplied JSON keys (#950 L1). Tests: 14 unit (validation + writable-dir fail-fast) + 3 integration (missing-CLAUDE.md 400, MCP warnings, valid deploy). Verified 16/16 against a sibling backend running this branch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(deploy-local): document CLAUDE.md hard-fail + credential-gap warnings (#950) Update local-agent-deploy.md and template-processing.md for the #950 hardening (blocking CLAUDE.md validation, collect_mcp_credential_warnings, warnings[] response field) and reconcile already-merged #971 behavior (credentials request field, MAX_DEPLOY_CREDENTIALS, require_role("creator"), DEPLOYED_TEMPLATES_DIR_UNWRITABLE/WORKSPACE_PREPOP_FAILED codes). Add the feature-flows index Recent Updates row. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(security): add CSO diff audit for #950 deploy-local hardening CLEAR verdict — no secrets/dependency/auth/injection regressions. One LOW finding (L1, operator-supplied MCP server-name terminal-escape echo) resolved in the same working tree via _sanitize_for_warning() with TDD. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(deploy-local): add path-containment guard to clear CodeQL py/path-injection (#950) CodeQL flagged 2 high-severity py/path-injection alerts on the new collect_mcp_credential_warnings() .exists() calls (template_service.py:766/768). The operator-supplied agent name flows through sanitize_agent_name (which already strips path separators) into dest_path, but CodeQL can't model the regex sanitizer as a barrier, so the PR check hard-fails on a false positive. Add the barrier CodeQL documents it recognizes, inline at the dest_path build site: normalize via os.path.normpath, inline startswith prefix-check against the deployed-templates base, and flow the normalized Path downstream so the value reaching copytree and the .exists() sink is provably contained. Doubles as genuine defense-in-depth at the filesystem-write boundary; legitimate single-slug names are unaffected. Add a sanitizer-invariant unit battery asserting sanitize_agent_name never yields a traversal-capable value (no separators, no '..' component, single basename) across a table of hostile inputs — proving the upstream guarantee that makes the alert a false positive and guarding future regressions. Server-free; runs in the existing unit suite. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
Summary
deploy_local_agent(the/trinity:onboardarchive path) silently created empty agents on every stock deployment. Root cause insrc/backend/services/agent_service/deploy.py:/agent-configs/templatesfor writability → fails (intentional:romount)../config/agent-templates/<version>→ resolves to/app/config/agent-templates/<version>INSIDE the backend container, with no host mapping.create_agent_internalthen computes the new agent's bind source as${HOST_TEMPLATES_PATH}/<version>— a host path that doesn't exist — so/templatecomes up empty.startup.shfinds nothing to copy → empty/home/developer.Fix
Write deployed-local templates to
/data/deployed-templates/<version>./datais already host-bound viaTRINITY_DATA_PATH, writable, and owned by UID 1000 (post-#874 / #969). The curated catalog at/agent-configs/templatesstays read-only — operators' source of truth is preserved; only the deployed-local store changes.Diff
deploy.py: drop the probe + silent fallback. Always write toDEPLOYED_TEMPLATES_DIR_IN_BACKEND(/data/deployed-templates). OnOSError, fail with HTTP 500 +code=DEPLOYED_TEMPLATES_DIR_UNWRITABLEpointing the operator atdocs/migrations/NON_ROOT_CONTAINERS_2026-05.md. No more silent container-only writes.crud.py: extendlocal:template resolution to look in both/agent-configs/templates(curated) and/data/deployed-templates(deployed). Curated wins on name collision so an operator-curated template can't be shadowed by a same-named upload. The agent bind picksHOST_TEMPLATES_PATHorHOST_DEPLOYED_TEMPLATES_PATHaccordingly.HOST_DEPLOYED_TEMPLATES_PATHenv var (defaults to${TRINITY_DATA_PATH:-./trinity-data}/deployed-templates) so the backend knows the host path for the per-agent bind. No new mounts —/dataalready exists.tests/test_deploy_local.py: regression assertion. After deploy, poll/api/agents/{name}/filesand requiretemplate.yaml+CLAUDE.mdto be visible in the workspace. This catches the silent-empty-agent class.docs/memory/feature-flows/local-agent-deploy.md: replace the stale probe-and-fallback description with the single-path behavior.Acceptance criteria coverage (#950)
POST /api/agents/deploy-localwrites to a host-mapped location (/data/deployed-templates)template.yaml,CLAUDE.md, etc. (verified by augmented integration test)<base>-2) work the same way — same store, same lookupis_trinity_compatible(issue's final "Consider…") — deferred to a separate PR (validation concern, separate blast radius)Test plan
pytest tests/test_deploy_local.py::TestDeployLocalSuccess::test_deploy_valid_archiveagainst a stockdocker-compose.ymlstack — passes (was passing before too, but agent was empty; now polled file check fails closed if regressed)/trinity:onboardfrom a non-GitHub agent → deploy → open the new agent's Files tab and confirm template content is present<base>-2created, files present, no leftover from previous versionNotes
:rofrom the curated mount (issue suggested it as one option). Keeping the curated catalog immutable inside the container is a useful invariant; the deploy-local store now lives in its own writable space.HOST_DEPLOYED_TEMPLATES_PATHdefaults make the new env var optional — operators on existing deployments get the right path automatically./dataownership being UID 1000. Both already merged or in flight.Related to #950
🤖 Generated with Claude Code