Files
goclaw/internal/upgrade/hook_workspace_normalize_test.go
Duy /zuey/ d4463cde6f 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).
2026-05-27 11:52:25 +07:00

67 lines
2.1 KiB
Go

package upgrade
import (
"os"
"testing"
)
func TestIsStaleWorkspace(t *testing.T) {
cases := []struct {
name string
in string
want bool
}{
{"docker era app workspace", "/app/workspace/clax", true},
{"docker era root", "/app/workspace", true},
{"docker era trailing slash", "/app/workspace/", true},
{"tilde literal", "~/.goclaw/x-workspace", true},
{"tilde only", "~", true},
{"absolute host path", "/var/lib/goclaw/workspace/clax", false},
{"current dot", ".", false},
{"empty", "", false},
{"whitespace", " ", false},
{"custom absolute (preserve)", "/srv/agents/clax", false},
{"relative non-tilde", "data/workspace", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := isStaleWorkspace(tc.in); got != tc.want {
t.Fatalf("isStaleWorkspace(%q) = %v, want %v", tc.in, got, tc.want)
}
})
}
}
func TestResolveWorkspaceBase_EnvWins(t *testing.T) {
t.Setenv("GOCLAW_WORKSPACE", "/var/lib/goclaw/workspace")
if got := resolveWorkspaceBase(); got != "/var/lib/goclaw/workspace" {
t.Fatalf("resolveWorkspaceBase() = %q, want /var/lib/goclaw/workspace", got)
}
}
func TestResolveWorkspaceBase_EnvTilde(t *testing.T) {
t.Setenv("GOCLAW_WORKSPACE", "~/custom/ws")
home, _ := os.UserHomeDir()
want := home + "/custom/ws"
if got := resolveWorkspaceBase(); got != want {
t.Fatalf("resolveWorkspaceBase() = %q, want %q", got, want)
}
}
func TestResolveWorkspaceBase_StripsTrailingSlash(t *testing.T) {
t.Setenv("GOCLAW_WORKSPACE", "/var/lib/goclaw/workspace/")
if got := resolveWorkspaceBase(); got != "/var/lib/goclaw/workspace" {
t.Fatalf("resolveWorkspaceBase() = %q, want trimmed", got)
}
}
func TestResolveWorkspaceBase_FallbackOnMissingConfig(t *testing.T) {
t.Setenv("GOCLAW_WORKSPACE", "")
t.Setenv("GOCLAW_CONFIG", "/nonexistent/path/config.json")
// Should not return empty even if config file missing — falls back through
// config.Load's IsNotExist branch (which returns defaults) or to default.
if got := resolveWorkspaceBase(); got == "" {
t.Fatal("resolveWorkspaceBase() returned empty for missing config; expected fallback")
}
}