Skip to content

fix(deploy-local): write templates to host-mapped path (#950) - #971

Merged
vybe merged 4 commits into
devfrom
fix/950-deploy-local-agent-empty-workspace
May 28, 2026
Merged

fix(deploy-local): write templates to host-mapped path (#950)#971
vybe merged 4 commits into
devfrom
fix/950-deploy-local-agent-empty-workspace

Conversation

@dolho

@dolho dolho commented May 28, 2026

Copy link
Copy Markdown
Contributor

Summary

deploy_local_agent (the /trinity:onboard archive path) silently created empty agents on every stock deployment. Root cause in src/backend/services/agent_service/deploy.py:

  1. Probe /agent-configs/templates for writability → fails (intentional :ro mount).
  2. Fall back to ./config/agent-templates/<version> → resolves to /app/config/agent-templates/<version> INSIDE the backend container, with no host mapping.
  3. create_agent_internal then computes the new agent's bind source as ${HOST_TEMPLATES_PATH}/<version> — a host path that doesn't exist — so /template comes up empty.
  4. Agent's startup.sh finds nothing to copy → empty /home/developer.

Fix

Write deployed-local templates to /data/deployed-templates/<version>. /data is already host-bound via TRINITY_DATA_PATH, writable, and owned by UID 1000 (post-#874 / #969). The curated catalog at /agent-configs/templates stays read-only — operators' source of truth is preserved; only the deployed-local store changes.

Diff

  • deploy.py: drop the probe + silent fallback. Always write to DEPLOYED_TEMPLATES_DIR_IN_BACKEND (/data/deployed-templates). On OSError, fail with HTTP 500 + code=DEPLOYED_TEMPLATES_DIR_UNWRITABLE pointing the operator at docs/migrations/NON_ROOT_CONTAINERS_2026-05.md. No more silent container-only writes.
  • crud.py: extend local: 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 picks HOST_TEMPLATES_PATH or HOST_DEPLOYED_TEMPLATES_PATH accordingly.
  • compose × 2: 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. No new mounts — /data already exists.
  • tests/test_deploy_local.py: regression assertion. After deploy, poll /api/agents/{name}/files and require template.yaml + CLAUDE.md to 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-local writes to a host-mapped location (/data/deployed-templates)
  • Fallback no longer silently writes container-only — fails fast with a structured error code
  • After deploy, workspace contains template.yaml, CLAUDE.md, etc. (verified by augmented integration test)
  • Versioned redeploys (<base>-2) work the same way — same store, same lookup
  • Integration test covers archive → deploy → verify-content on stock compose
  • Regression guard: probe-and-fallback pattern removed entirely; reintroducing it would surface in code review and re-fail the new integration assertion
  • Tightening is_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_archive against a stock docker-compose.yml stack — passes (was passing before too, but agent was empty; now polled file check fails closed if regressed)
  • Manual: /trinity:onboard from a non-GitHub agent → deploy → open the new agent's Files tab and confirm template content is present
  • Redeploy same agent: <base>-2 created, files present, no leftover from previous version

Notes

Related to #950

🤖 Generated with Claude Code

`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>
Comment thread src/backend/services/agent_service/crud.py Fixed
Comment thread src/backend/services/agent_service/crud.py Dismissed
Comment thread src/backend/services/agent_service/crud.py Fixed
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>
@dolho

dolho commented May 28, 2026

Copy link
Copy Markdown
Contributor Author

Update — refactored to put_archive (works on dev compose too)

Manual end-to-end test on the local docker-compose.yml stack surfaced a second bug class that my original fix would NOT have caught: the bind-mount transport for local: templates assumes the backend's /data and the new agent's host bind resolve to the same host path. That's true in docker-compose.prod.yml (host bind for /data) but false in docker-compose.yml (named volume for /data). So the original PR fixed prod but would have shipped empty agents on dev — same failure mode, different root cause.

New approach (pushed as 784ffdf6): drop the bind-mount transport for deploy-local entirely. 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 a .trinity-initialized marker (included in the same tar) and skips its /template/home/developer copy. No host-path dependency — same uniform path on dev and prod.

Verified live on local stack

$ curl -X POST /api/agents/deploy-local … → 200 OK
$ docker exec agent-test-950-xxx ls -la /home/developer
drwxr-xr-x developer developer .
drwxr-xr-x developer developer .claude
-rw-r--r-- developer developer .trinity-initialized
-rw-r--r-- developer developer CLAUDE.md          (45 bytes)
-rw-r--r-- developer developer template.yaml      (86 bytes)
$ curl /api/agents/test-950-xxx/files
{... "template.yaml", "CLAUDE.md", and .claude/skills/test/SKILL.md all visible ...}
$ curl /api/agents/test-950-xxx/files/download?path=template.yaml
name: test-950-xxx
display_name: 950 Test
resources: { cpu: "1", memory: "1g" }

Agent boots clean (no permission errors creating .cache/, content/, etc.) because the workspace volume's root dir is chowned to 1000:1000 by the same ephemeral container.

Diff vs prior commit

  • deploy.py: new _prepopulate_workspace_from_template helper
  • crud.py: drop the now-unused deployed-templates branch in template_volume setup (still looks up template.yaml from /data/deployed-templates/ for redeploys, but no longer tries to bind)
  • compose × 2: drop HOST_DEPLOYED_TEMPLATES_PATH env var (no code reads it anymore)
  • tests/test_deploy_local.py: explicitly start the agent after deploy before polling /files (deploy returns the agent stopped)
  • feature flow doc: documents the put_archive approach

Curated templates (/agent-configs/templates/...) are untouched — they keep the existing bind-mount transport because the curated catalog IS bound from the same host path into both backend and agent containers.

🤖 Generated with Claude Code

@dolho

dolho commented May 28, 2026

Copy link
Copy Markdown
Contributor Author

/review report — fine to merge

Critical findings: 0
Informational findings: 4 (all minor/cosmetic, none blocks merge)

Pass 1 (critical / blockers)

  • SQL & data safety — no DB code
  • Race / concurrency — volumes.create() is idempotent on name collision; version_name uniquely generated upstream
  • Auth boundary — no new endpoints, helper is module-internal with internally-controlled args
  • Credential exposure — error messages carry version_name (sanitized) + a path constant; put_archive streams to the docker socket with no log emit

Pass 2 (informational)

[I1] 1000 (UID/GID) hardcoded 4× in _prepopulate_workspace_from_template. Matches the platform-wide UID convention (#874 / invariant #17) but a module-level _AGENT_UID = 1000 would centralize the contract. Cosmetic; defer if you don't want to extend the diff.

[I2] Broad except Exception as e around put_archive / wait / chown. Catches programmer errors and reports as WORKSPACE_PREPOP_FAILED. The intent IS "any failure during pre-pop → fail the deploy cleanly with the underlying message in {e}," so this is operator-friendly. Flagged for awareness only.

[I3] No test asserting the .trinity-initialized marker is present. The integration test verifies template files arrive in the workspace but doesn't check the marker. If a future change drops the marker from the tar, the agent's startup.sh would attempt its /template/home/developer copy on top of already-populated files. Cheap fix: grep the API response with show_hidden=true. Low priority — exercised on every deploy in code review.

[I4] Sync docker SDK calls inside async def. put_archive, containers.{create,wait}, images.pull, plus tarfile.add — all block the event loop for ~3–5s per deploy on warm cache (longer on first alpine pull). Consistent with the rest of deploy_local_agent_logic (which already uses shutil.copytree sync), so not a new sin. Wrap in asyncio.to_thread if event-loop blocking ever becomes a problem.

Scope check

CLEAN. 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 — clean

Conditional side effects (return-value + exit-code checks; cleanup in finally) · magic numbers (alpine:3.20 pin is explicit, timeout=30 is reasonable for empty-dir chown) · dead code · error handling (transient.remove in nested try/except: pass is correct — removal failure isn't actionable) · frontend (none) · performance (one-shot, ~3–5s, acceptable for interactive deploy) · enum completeness (no enums; new error codes are local to the response shape)

🤖 Generated by /review (Claude Opus 4.7)

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>
@dolho

dolho commented May 28, 2026

Copy link
Copy Markdown
Contributor Author

CodeQL fix — py/path-injection resolved (0bb4dc2)

CodeQL on PR #971 flagged two py/path-injection alerts in crud.py:

  • L282 (template_path / "template.yaml")
  • L379 (curated_path.exists())

Both reachable via local:../../... in the template field of POST /api/agents. The pre-existing code had the same issue; the alerts surfaced because the diff touched those lines. Fixed forward.

Added _validate_local_template_name requiring [a-zA-Z0-9][a-zA-Z0-9_.-]* with no .. substring; called from both flagged sites. Rejection: HTTP 400 + code=INVALID_LOCAL_TEMPLATE_NAME.

Live verified on local stack:

  • POST /api/agents with template=local:../../etc/passwd400 INVALID_LOCAL_TEMPLATE_NAME
  • POST /api/agents with a valid name → proceeds normally

CodeQL will re-scan on the new commit; both alerts should auto-close.

🤖 Generated with Claude Code

…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
Comment thread src/backend/services/agent_service/crud.py Dismissed
Comment thread src/backend/services/agent_service/crud.py Dismissed
@dolho

dolho commented May 28, 2026

Copy link
Copy Markdown
Contributor Author

CodeQL status — green

After the canonical-barrier refactor (0f8089d4):

  • 174, 176 — auto-closed by CodeQL re-scan (lines were rewritten to go through the validated path).
  • 175, 177, 178 — dismissed as false positives per project memory ("CodeQL path-injection FP-prone on confined file endpoints; dismiss via API"). CodeQL's taint tracker doesn't follow through _safe_local_template_path's function indirection even though the helper body uses the canonical regex + resolve() + is_relative_to(root) barrier pattern.

CodeQL check on PR 971 now passes. Live behaviour:

  • local:../../etc/passwd → 400 INVALID_LOCAL_TEMPLATE_NAME
  • valid name → proceeds normally

🤖 Generated with Claude Code

@dolho
dolho requested a review from vybe May 28, 2026 14:52

@vybe vybe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

@vybe
vybe merged commit de6f8a3 into dev May 28, 2026
13 checks passed
vybe pushed a commit that referenced this pull request Jun 2, 2026
…-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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants