Feature — Environments (Settings → Environments)
Branch: session/feat-environments. Implementation notes for review.
What shipped
Named, reusable Environments a user manages under Settings and assigns
per-Agent: pip/npm package lists, networking posture (unrestricted vs.
limited + egress allow-list), draft/published lifecycle, and an
available-in-all-projects flag. Consumed v1 by the claude-managed-agent
pipeline plugin (CMA environment networking + a first-session package
bootstrap step); carried elsewhere as an advisory, serializable
runtimeEnvironment object on pipeline execution contexts.
Data model
environmentstable (packages/agent/src/entities/environment.entity.ts):id, userId, name(120), slug(80, unique per user), description?, pipPackages simple-json, npmPackages simple-json, networkingMode varchar(16) 'unrestricted'|'limited', allowedHosts simple-json?, allowPackageManagers bool default true, status varchar(16) 'draft'|'published' default 'draft', availableInAllProjects bool default true, tenantId?, organizationId?, timestamps. Registered in_entities-inventory.ts+_entity-names.ts+ entities barrel.agents.environmentId uuid NULL— FK ON DELETE SET NULL (belt-and-braces; the service refuses deletion first).- Migrations (portable Table API, idempotent guards, both
up/down):apps/api/src/migrations/1786810000000-CreateEnvironments.tsapps/api/src/migrations/1786810001000-AddAgentEnvironmentId.ts
Server rules
- Package specs and hosts are validated with strict allow-list regexes at
THREE layers (DTO →
EnvironmentsService→ consuming plugin), because they later reach install commands. Canonical validators live inpackages/plugin/src/pipeline/runtime-environment.ts(isValidPipPackageSpec/isValidNpmPackageSpec/isValidAllowedHost/normalizeRuntimePackageList). No whitespace, quotes, or shell metacharacters can validate; comparison operators must be followed by a digit (anti->out.txt); composed install commands additionally single-quote every spec. - Assignment rule (server-side, in
AgentsService.create/update): an Environment may be assigned only when it belongs to the same user (cross-user/unknown → 404) and is published (draft → 422 with a clear message). The UI picker filters to published rows as the matching affordance. - DELETE refused with 409 while any Agent references the row.
- Unrestricted rows normalise
allowedHoststo NULL so mode and hosts can never disagree. - Slug uniqueness enforced by DB index; lost create races surface as the
same named 409 via
isUniqueConstraintError. - Activity rows: additive
ENVIRONMENT_CREATED/UPDATED/PUBLISHED/DELETEDenum entries, emitted best-effort from the controller.
Endpoints (apps/api/src/environments/, JWT, user-scoped)
GET api/environments?status=draft|publishedPOST api/environmentsGET api/environments/:idPATCH api/environments/:idPOST api/environments/:id/publishDELETE api/environments/:id(409 while referenced)
Registered in api.module.ts as EnvironmentsApiModule.
CreateAgentDto/UpdateAgentDto gained optional environmentId
(AgentDto exposes it; the agents validation-authz e2e matrix pins
unknown-property 400s and is unaffected by an added optional field).
Pipeline carrier + consumption
@ever-works/plugin:RuntimeEnvironmentData+ optionalruntimeEnvironmentonStepExecutionContext, optionalagentId/runtimeEnvironmentonPipelineExecutionOptions.FullPipelineExecutorService:options.runtimeEnvironmentwins; otherwiseoptions.agentIdresolves through an@Optional()EnvironmentsService(resolveRuntimeEnvironmentForAgent: agent →environmentId→ published, same-owner row → plain carrier). FAILS CLOSED (changed in PR review): "no Environment" — no agentId, no wired service, nothing assigned — still resolves toundefinedand the run proceeds exactly as before Environments existed, but a resolution ERROR now propagates andexecute()returns a failed PipelineResult. Continuing there would have handed the run the plugin's fallback egress posture (CLAUDE_MANAGED_AGENT_EGRESS_HOSTSorunrestricted) in place of the assigned Environment's restrictions.PipelineModuleimports the (leaf) agent-sideEnvironmentsModule; the module pin spec was updated (3 imports).claude-managed-agent: with a carrier present, the CMA environment is created with{type:'limited', allowed_hosts, allow_package_managers}or an explicit{type:'unrestricted'}, and a bootstrap message (pip install '…' …/npm install -g '…' …, re-validated + quoted) is sent and awaited as the FIRST session turn. Without a carrier, theCLAUDE_MANAGED_AGENT_EGRESS_HOSTSenv-var fallback and the message sequence are preserved byte-for-byte (test-pinned).
Web UI
- Settings → Environments (
/settings/environments): list (Name, Networking, Status chip, Updated) + dialog editor (name, description, available-in-all-projects toggle, one-per-line pip/npm textareas (newline is the ONLY separator: a comma is legal INSIDE one pip specifier,pandas>=2.0,<3.0), networking radio with hosts textarea + allow-package-managers toggle when limited, Save draft / Save & publish), per-row Publish/Edit/Delete with delete confirmation. New "Environments" tab insettings-layout-client.tsx(below Job Runtime). Files:apps/web/src/lib/api/environments.ts,apps/web/src/app/actions/settings/environments.ts,.../settings/environments/page.tsx,apps/web/src/components/settings/EnvironmentsSettings.tsx. - Agent Settings → Runtime card: "Environment" SearchableSelect over
the user's published Environments + "None (default)", persisting
environmentIdthrough the existingupdateAgentActionPATCH. - i18n:
dashboard.settings.tabs.environments+dashboard.settings.environments.*added to all 21 locale files (English copy everywhere, per convention; JSON round-trip-safe insert).
Tests
cd packages/agent && npx jest --testPathPattern='src/environments/__tests__'— service CRUD / publish / delete-guard / resolver (17 tests).cd packages/agent && npx jest --testPathPattern='(runtime-environment-injection|agents.service.environment)'— executor carrier forwarding + assignment-rule specs.cd packages/agent && npx jest --testPathPattern='src/(pipeline/pipeline.module|database/database.module|database/database.config|agents/__tests__/agents.service)'— pin/drift suites updated + green.cd packages/plugin && npx vitest run src/pipeline/__tests__/runtime-environment.spec.ts— validator table tests (69 tests, incl. shell-injection samples).cd packages/plugins/claude-managed-agent && npx vitest run— client networking payloads + helper + full plugin execute spec (with-carrier vs. absent-carrier byte-for-byte).cd apps/api && npx jest --testPathPattern='src/agents/'— includesagents.controller.environment.spec.ts, which pins that the controller's explicit body→input mapping forwardsenvironmentIdon both POST and PATCH (see the second continuation pass below).cd apps/web && npx vitest run src/components/agents/AgentCard.unit.spec.tsx— web unit fixture covers the widenedAgenttype.
Continuation pass (2026-08-14, finishing session)
Verified the interrupted session's work end-to-end and fixed the two defects the web type-check surfaced:
EnvironmentsSettings.tsxreferencededitor.createTitle/editor.editTitlei18n keys that were never added — added thedashboard.settings.environments.editorblock (English copy) to all 21 locale files.AgentCard.unit.spec.tsx'smakeAgentfixture lacked the new requiredAgent.environmentIdfield — set tonull.
Verification run: turbo build + type-check green for
@ever-works/plugin, @ever-works/agent,
@ever-works/claude-managed-agent-plugin, ever-works-api,
ever-works-web; all feature Jest/Vitest suites plus the pinned
module/drift suites pass (see commands above).
Second continuation pass (2026-08-14, closing session)
Independent re-verification of the whole feature against the brief. The first continuation pass's claim of "green end-to-end" held for build, type-check and every named suite, but three real defects survived it:
agents.environmentIdwas write-only-in-theory —AgentsController.create/updatemap the request body to the service input field by field (an explicit whitelist), andenvironmentIdwas never copied.CreateAgentDto/UpdateAgentDtoaccepted it, validation passed, the API answered 200/201 — and the assignment was silently dropped, so the Agent-settings picker could never persist a choice. This is the known "whitelist drops new columns" bug class; type-check cannot see it because every field is optional. Fixed in both mappings and pinned by the newapps/api/src/agents/agents.controller.environment.spec.ts(5 tests: forward on POST, omitted→nullon POST, forward on PATCH, explicitnullclears, omitted staysundefined).- Formatting would have failed CI —
pnpm format:checkruns Prettier over the whole repo on every CI run, and 12 of the branch's files were unformatted (note thatpackages/agent/.prettierrc— spaces, width 100, trailing commas — overrides the root tabs/120 config for the agent package). Reformatted; only branch-introduced lines changed. - An emptied description could not be cleared — the editor sent
description: undefinedfor a blank field, which the PATCH treats as "leave untouched". It now sendsnull, andCreateEnvironmentDto.descriptionis typedstring | nullwith a note that@IsOptional()skips validation fornullas well asundefined, sonullreaches the service and clears the column.
Nothing else was rewritten: the prior sessions' structure (leaf
EnvironmentsModule, shared allow-list validators in
@ever-works/plugin, executor-level resolution) is sound and was kept.
Divergences from the brief (code won)
- The existing env-var fallback sends
{type:'allowlist', hosts}(H-25 code), not the brief's{type:'limited', allowed_hosts}; the fallback was left untouched (byte-for-byte pin) and the brief'slimitedshape is used only for the new Environment-driven path. - "Wire the resolution where the agent-run assembles plugin context":
no current production path executes a pipeline plugin on behalf of an
Agent — agent runs go through the AI tool loop
(
AgentRunService.runToolLoop), and work generation (DataGeneratorService.executePipeline) carries no agent. The resolution is therefore wired intoFullPipelineExecutorServicebehindoptions.agentId(+ a pre-resolvedoptions.runtimeEnvironmentescape hatch), so the first orchestrator that dispatches a pipeline for an Agent gets Environments for free. Until then the carrier is populated only by callers that opt in.
Known follow-ups
- Pass
agentIdfrom a real agent-driven pipeline dispatch once one exists (e.g. a future Managed-Agents task runner). - Per-project narrowing UI for
availableInAllProjects = false(flag is persisted; no narrowing surface yet). - Playwright e2e for the settings CRUD + agent picker (unit/service coverage only in this PR).
- Activity feed rendering for
environment_*rows uses the generic row renderer; no dedicated icon/label mapping yet. resolveRuntimeEnvironmentForAgentdoes not filter byavailableInAllProjects(needs a project/work context to mean anything).