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:
parent
89bdf29d6f
commit
61d5908817
@ -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()
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user