fix(workspace files API): write claude-code config to /configs, sudo for root-owned base

Root cause of the user-visible 500 ("install: cannot create directory
'/opt/configs': Permission denied") on PUT
/workspaces/<id>/files/config.yaml:

1. Path map fall-through. claude-code wasn't in workspaceFilePathPrefix,
   so resolveWorkspaceFilePath returned the default `/opt/configs/...`.
   That directory doesn't exist on the workspace EC2 — cloud-init in
   provisioner/userdata_containerized.go runs `mkdir -p /configs` only.
   Even if the SSH write had succeeded at /opt/configs, the docker
   container's bind-mount is host:/configs → container:/configs,
   so the file would have been invisible to the runtime.

2. /configs ownership. cloud-init runs as root, so /configs is
   root-owned. The SSH-as-ubuntu install command can't write into it
   without sudo. Hermes wasn't affected because its base path
   (/home/ubuntu/.hermes) is ubuntu-owned.

Two-line fix:

- Add `claude-code: /configs` to the runtime → base-path map and flip
  the default fall-through from `/opt/configs` to `/configs`. Leave the
  pre-existing langgraph/external entries pointing at /opt/configs
  pending a migration audit (no user report on those today, and
  flipping them would silently relocate any files those runtimes
  already wrote).
- Prefix the remote install command with `sudo -n` so the write
  succeeds under the standard EC2 ubuntu/passwordless-sudo posture.
  `-n` (non-interactive) ensures clean failure if that ever changes,
  rather than a hang waiting for a password prompt.

Tests:
- TestResolveWorkspaceFilePath_KnownRuntimes adds claude-code +
  CLAUDE-CODE coverage and updates the empty/unknown default cases
  to expect /configs. The langgraph/external rows stay green
  (unchanged values), confirming the scope of the rename.

Verification:
- go build ./... clean
- go test ./internal/handlers/ green
- The user-reported bug
  (PUT /workspaces/57fb7043-79a0-4a53-ae4a-efb39deb457f/files/config.yaml
   → 500 EACCES on /opt/configs) is the failure mode this fix addresses
  on both axes (path + sudo).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-05-04 14:29:08 -07:00
parent 89bdf29d6f
commit 61d5908817
2 changed files with 40 additions and 10 deletions

View File

@ -38,13 +38,26 @@ import (
// Keep these stable — changing the base path for an existing runtime
// without a migration shim will make previously-saved files disappear from
// the runtime's POV.
//
// Path source-of-truth: cloud-init in
// `molecule-controlplane/internal/provisioner/userdata_containerized.go`
// runs `mkdir -p /configs` and writes the canonical config.yaml there.
// The workspace container bind-mounts host `/configs` to read it back.
// Files written anywhere else on the host are invisible to the runtime,
// so `claude-code` (and any future containerized runtime) must point here.
//
// `/configs` is root-owned (cloud-init runs as root); the SSH-as-ubuntu
// install command at the call site below uses `sudo` to write into it.
var workspaceFilePathPrefix = map[string]string{
"hermes": "/home/ubuntu/.hermes",
"langgraph": "/opt/configs",
"external": "/opt/configs",
// Default for unknown / future runtimes is /opt/configs — most
// conservative place that doesn't collide with system or runtime-
// private directories.
"hermes": "/home/ubuntu/.hermes",
"claude-code": "/configs",
"langgraph": "/opt/configs",
"external": "/opt/configs",
// Default for unknown / future runtimes is /configs — matches the
// containerized user-data layout. The `langgraph` / `external`
// entries pre-date the unified user-data path and are retained
// until a migration audit confirms what the running tenants of
// those runtimes actually have on disk.
}
func resolveWorkspaceFilePath(runtime, relPath string) (string, error) {
@ -53,7 +66,7 @@ func resolveWorkspaceFilePath(runtime, relPath string) (string, error) {
}
base, ok := workspaceFilePathPrefix[strings.ToLower(strings.TrimSpace(runtime))]
if !ok {
base = "/opt/configs"
base = "/configs"
}
return filepath.Join(base, filepath.Clean(relPath)), nil
}
@ -148,6 +161,17 @@ func writeFileViaEIC(ctx context.Context, instanceID, runtime, relPath string, c
// writes the file atomically via temp-file-rename. Permissions 0644
// match the existing tar-unpack defaults on the Docker path.
//
// `sudo -n` (non-interactive) prefix: the canonical containerized
// workspace layout puts /configs at the root, owned by root because
// cloud-init runs as root (see
// molecule-controlplane/internal/provisioner/userdata_containerized.go).
// SSH-as-ubuntu can't write into /configs without escalation.
// Ubuntu has passwordless sudo on EC2 by default; sudo -n fails fast
// (no prompt) if that ever changes, surfacing a clean error instead
// of a hang. The hermes path /home/ubuntu/.hermes is ubuntu-owned
// and doesn't strictly need sudo, but using it uniformly avoids
// per-runtime branching here.
//
// The remote command is fully deterministic — no user-controlled
// input reaches a shell eval (absPath is built from a map + Clean()).
sshArgs := []string{
@ -157,7 +181,7 @@ func writeFileViaEIC(ctx context.Context, instanceID, runtime, relPath string, c
"-o", "ServerAliveInterval=15",
"-p", fmt.Sprintf("%d", localPort),
fmt.Sprintf("%s@127.0.0.1", osUser),
fmt.Sprintf("install -D -m 0644 /dev/stdin %s", shellQuote(absPath)),
fmt.Sprintf("sudo -n install -D -m 0644 /dev/stdin %s", shellQuote(absPath)),
}
sshCmd := exec.CommandContext(ctx, "ssh", sshArgs...)
sshCmd.Env = os.Environ()

View File

@ -18,10 +18,16 @@ func TestResolveWorkspaceFilePath_KnownRuntimes(t *testing.T) {
{"hermes", "config.yaml", "/home/ubuntu/.hermes/config.yaml"},
{"HERMES", "config.yaml", "/home/ubuntu/.hermes/config.yaml"}, // case-insensitive
{"hermes", "nested/a.yaml", "/home/ubuntu/.hermes/nested/a.yaml"},
// claude-code (and any future containerized runtime) lands at /configs —
// the path user-data creates and bind-mounts into the container. Pre-fix
// this fell through to /opt/configs which doesn't exist on workspace EC2s
// and would 500 with EACCES on save (the bug that motivated this gate).
{"claude-code", "config.yaml", "/configs/config.yaml"},
{"CLAUDE-CODE", "config.yaml", "/configs/config.yaml"}, // case-insensitive
{"langgraph", "config.yaml", "/opt/configs/config.yaml"},
{"external", "skills.json", "/opt/configs/skills.json"},
{"", "config.yaml", "/opt/configs/config.yaml"}, // empty → default
{"unknown", "config.yaml", "/opt/configs/config.yaml"}, // unknown → default
{"", "config.yaml", "/configs/config.yaml"}, // empty → default
{"unknown", "config.yaml", "/configs/config.yaml"}, // unknown → default
}
for _, tc := range cases {
t.Run(tc.runtime+"/"+tc.relPath, func(t *testing.T) {