Repository navigation
fix(api): reject environment folder paths outside the workspace volume - #651
Conversation
AddFolder only required a leading "/", so a path outside the mounted workspace volume (e.g. /home/paca-dev/workspaces) was persisted and mkdir'd into the container's own writable layer. It disappears on the next Pod/container recreation, after which the folder's Path is the cwd of session/new and every attach fails with ACP "session/new: Invalid params: invalid directory path". Require the cleaned path to be the workspace root or under it, the same scope agent-runner already applies to Browse and its mkdir/delete guards, and store the cleaned path. The folder dialog's client-side pre-check now uses that same root instead of "any absolute path".
There was a problem hiding this comment.
ℹ️ Minor suggestions only — the core fix is sound; two notes on the new guard and the comments it adds.
Reviewed changes
- Workspace-root guard on
AddFolder—environment_service.gocleans the incoming path withpath.Cleanand rejects anything not equal to, or under,/home/paca/workspaces, storing the cleaned path. - Error text —
ErrFolderPathInvalidnow names the workspace constraint (mapping to400 ENVIRONMENT_FOLDER_PATH_INVALIDalready existed). - Client pre-check —
folder-create-dialog.tsxvalidates against the same root constant instead ofstartsWith("/"). - Table test —
TestAddFolder_PathMustBeUnderWorkspaceRootpins root, subfolder, trailing slash, outside, prefix-sibling, traversal, relative, and empty cases.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
| // Mirrors agent-runner's environmentHomeRoot | ||
| // (internal/acpbridge/environment_handlers.go), which scopes Browse and | ||
| // the mkdir/delete guards to the same root. | ||
| if path != environmentHomeRoot && !strings.HasPrefix(path, environmentHomeRoot+"/") { |
There was a problem hiding this comment.
Allowing the workspace root here is inconsistent with agent-runner, which refuses to mkdir it: forbiddenFolderTargets lists environmentHomeRoot itself, so the POST /internal/environments/{id}/folders this triggers returns 400. Because the folder row is created first, a running environment ends up with the row persisted while the request errors, and a retry then reports path taken — and the dialog's default path is the root, so this is the default submission. If root-as-folder is intended, agent-runner needs to permit it (or the mkdir should be skipped when path == environmentHomeRoot); otherwise drop the path != environmentHomeRoot carve-out and the "root" test case.
Technical details
# Root carve-out vs. agent-runner's mkdir guard
## Affected sites
- `services/api/internal/service/environment/environment_service.go:1009` — accepts `path == environmentHomeRoot`.
- `services/api/internal/service/environment/environment_service.go:1033-1041` — for a `StatusRunning` env the row is committed (`CreateFolder`, line 1022) *before* the agent-runner call, so the 400 leaves a persisted row.
- `services/agent-runner/internal/acpbridge/environment_handlers.go:597-618` — `forbiddenFolderTargets` includes `environmentHomeRoot`.
- `services/agent-runner/internal/acpbridge/environment_handlers.go:675-679` — `handleCreateEnvironmentFolder` calls `safeFolderTarget` unconditionally, which refuses it.
- `services/api/internal/service/environment/environment_service_test.go:1622` — pins `"root"` as valid; the test's env has `BackendRef == nil`, so the agent-runner leg is never exercised.
## Required outcome
- Root is either rejected consistently at the api boundary, or the full create flow (including the mkdir leg for a running env) succeeds for it.
## Also in this comment block
- `environment_service.go:1006-1008` states agent-runner "scopes Browse and the mkdir/delete guards to the same root." `safeFolderTarget` only refuses a fixed list of top-level directories; it does not scope mkdir to `environmentHomeRoot`. And there is no delete guard/endpoint — `DeleteFolder` is row-only (`environment_service.go:1110`).
## Open questions for the human
- Is a folder whose path is the workspace root a supported object (e.g. as a conversation workdir), or is the dialog default meant to force a subfolder?| // isValidPath already blocks a relative path, so | ||
| // EnvironmentFolderPathInvalid mainly guards a stale check; | ||
| // isValidPath is the same root check the server now enforces (folders | ||
| // outside the mounted workspace vanish on the next Pod recreation and |
There was a problem hiding this comment.
This is not quite "the same root check the server now enforces": a raw startsWith(ENVIRONMENT_HOME_ROOT) also accepts prefix siblings like /home/paca/workspaces-other and uncleaned .. segments, both of which the server rejects after path.Clean. The server is authoritative so nothing breaks, but the comment overstates the equivalence and EnvironmentFolderPathInvalid is reachable from a client-valid input — consider softening the wording rather than tightening the check.
pikann
left a comment
There was a problem hiding this comment.
LGTM! Thank you for the contribution! 🚀

Fixes #650.
Symptom: every attach to a durable environment failed instantly with
acp: session/new: Invalid params: invalid directory path.Cause:
AddFolderaccepted any absolute path and stored it as the folder'sPath, which becomes the conversation workdir andsession/new'scwd. A path outside the mounted workspace volume is mkdir'd into the container's writable layer, so it disappears on the next Pod recreation and the stored folder then points at a nonexistent directory —session/newrejects it forever after. The api'sBrowseand agent-runner's own mkdir/delete guards were already scoped toenvironmentHomeRoot; only the create path was not.Change
services/api/internal/service/environment/environment_service.go— clean the path and requireenvironmentHomeRootor a path under it (theErrFolderPathInvalid→400 ENVIRONMENT_FOLDER_PATH_INVALIDmapping already existed); store the cleaned path.services/api/internal/domain/environment/errors.go— error text now names the real constraint.apps/web/.../folder-create-dialog.tsx— the client-side pre-check uses the same root constant instead of "starts with/".TestAddFolder_PathMustBeUnderWorkspaceRoot: root, subfolder, trailing slash, outside the volume, sibling sharing the prefix (/home/paca/workspaces-other),..traversal out of the root, relative, empty.Verification (local)
go build ./...,go vet ./...clean inservices/api.go test ./internal/service/environment/passes; sabotage run (guard reverted to the oldHasPrefix("/")) fails exactly on the outside / sibling / traversal cases:Expected error with "folder path must be an absolute path" in chain but got nil.npx @biomejs/biome@2.4.12 checkon the changed tsx: clean. Notscgate locally (nonode_modules).golangci-lintis not installed here — CI is the gate for it.cwdoutside the volume →-32602 invalid directory path;cwdunder it →sessionId), see the issue.Existing rows that already point outside the volume are not migrated — the directory they name is gone too, so they have to be corrected by hand.