fix(exec): surface real cwd error + auto-normalize stale workspace paths (#77)

* fix(exec): surface chdir error instead of misleading fork/exec

Linux Go forkAndExecInChild conflates chdir + execve failures into one
PathError naming only argv0. When agent workspace points to a stale
directory, exec reports "fork/exec /usr/bin/gh: no such file" even though
the binary exists — the real failure is chdir on cmd.Dir.

Two fixes:
- loop_context.go: fall back to l.workspace when per-user MkdirAll fails
  (3 sites: user workspace, dispatched team, auto-resolved team)
- shell.go + credentialed_exec.go: preflight cwd via validateExecCwd
  before exec.Command, so the error names the directory, not the binary

Regression test TestValidateExecCwd covers empty / existing / missing /
file-instead-of-dir.

* feat(upgrade): auto-normalize stale agent workspace paths

Detects non-portable workspace values left by deployment migrations
(Docker → bare-metal, host path drift) and rewrites them to the current
configured base. Registered as data hook 072_normalize_agent_workspaces;
runs automatically after `migrate up`.

Stale patterns:
- /app/workspace/* (Docker container path persisted on bare-metal)
- ~ prefix (Go doesn't expand tildes; MkdirAll creates literal ~ dir)

Base resolved with gateway precedence: GOCLAW_WORKSPACE env >
config.Agents.Defaults.Workspace > default ~/.goclaw/workspace.
Idempotent (skips rows already at proposed value). Conservative
(leaves absolute non-stale paths untouched as intentional custom config).
This commit is contained in:
Duy /zuey/ authored and GitHub committed 2026-05-27 11:52:25 +07:00
1 parent 93461e9863
commit d4463cde6f
7 files changed
+324 -6

No files matched your search

+24 -6
View File
@@ -177,7 +177,15 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
ctx = store.WithSharedSessions(ctx)
}
if err := os.MkdirAll(effectiveWorkspace, 0755); err != nil {
slog.Warn("failed to create user workspace directory", "workspace", effectiveWorkspace, "user", req.UserID, "error", err)
// Stale stored workspace (e.g. Docker-era /app/workspace/ on bare-metal host)
// would propagate as cmd.Dir into exec tools, where Linux's clone+chdir+execve
// failure surfaces as a misleading "fork/exec PATH: no such file or directory"
// — same message users would see for a missing binary. Fall back to the system
// default workspace (already created at startup) so tools keep working while
// the warning surfaces the data drift for operators.
slog.Warn("failed to create user workspace directory; falling back to system default",
"workspace", effectiveWorkspace, "fallback", l.workspace, "user", req.UserID, "error", err)
effectiveWorkspace = l.workspace
}
ctx = tools.WithToolWorkspace(ctx, effectiveWorkspace)
} else if l.workspace != "" {
@@ -187,10 +195,15 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
// Team workspace: dispatched task overrides default workspace.
if req.TeamWorkspace != "" {
if err := os.MkdirAll(req.TeamWorkspace, 0755); err != nil {
slog.Warn("failed to create team workspace directory", "workspace", req.TeamWorkspace, "error", err)
// See note above on loop_context user workspace fallback. A broken
// req.TeamWorkspace would otherwise become cmd.Dir and surface as
// "fork/exec PATH: no such file or directory" from any tool exec.
slog.Warn("failed to create team workspace directory; keeping previous workspace",
"workspace", req.TeamWorkspace, "error", err)
} else {
ctx = tools.WithToolTeamWorkspace(ctx, req.TeamWorkspace)
ctx = tools.WithToolWorkspace(ctx, req.TeamWorkspace)
}
ctx = tools.WithToolTeamWorkspace(ctx, req.TeamWorkspace)
ctx = tools.WithToolWorkspace(ctx, req.TeamWorkspace)
}
if req.TeamID != "" {
ctx = tools.WithToolTeamID(ctx, req.TeamID)
@@ -235,9 +248,14 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
tools.UserChatLayer(wsChat, shared),
)
if err := os.MkdirAll(wsDir, 0750); err != nil {
slog.Warn("failed to create team workspace directory", "workspace", wsDir, "error", err)
// See note above on loop_context user workspace fallback. Skip the
// team workspace context-set so downstream tools don't inherit a
// path that would fail with a misleading "fork/exec PATH: ENOENT".
slog.Warn("failed to create team workspace directory; skipping team workspace ctx",
"workspace", wsDir, "error", err)
} else {
ctx = tools.WithToolTeamWorkspace(ctx, wsDir)
}
ctx = tools.WithToolTeamWorkspace(ctx, wsDir)
// Team root (no UserChatLayer): lets any team agent — leader or member —
// read files produced by peers under different chat/user scopes within
// the same team. Writes still default to wsDir above; team root only