diff --git a/README.md b/README.md index 1fd4408..183d690 100644 --- a/README.md +++ b/README.md @@ -139,8 +139,8 @@ env_prefixes = ["TF_", "AWS_", "ARM_"] # only these host vars enter the contain Semantics include `command`, `args_prefix`, `path_next`, `path_equals`, `path_last`, `path_last_if_any`, `env_names`, `env_prefixes`, `env_set`, -`project_markers`, `state_group`, `project_volumes`, `shared_volumes` and -`host_mounts`. Unknown keys **fail validation** instead of being silently +`project_markers`, `state_group`, `project_volumes`, `shared_volumes`, +`host_mounts` and `cwd_mode`. Unknown keys **fail validation** instead of being silently ignored, and a `schema_version` newer than the binary supports fails closed. Edit the file, then run `cb install` to reconcile shims. @@ -189,6 +189,29 @@ claimed by `host_mounts`, on any provider. > implicitly to an auto-generated shim. A profile that genuinely needs a host > mount must declare it explicitly. +### Isolated launcher mode (`cwd_mode`) + +`cwd_mode` defaults to `"project"`, which preserves the normal behavior of +walking up from the current working directory to find project markers and +bind-mounting the project into the container at `/workspace`. Set it to +`"isolated"` for tools that may be launched from an arbitrary working directory +that is not itself meaningful — for example, a background service or a GUI/MCP +launcher that inherits `C:\Windows\System32` as its CWD and invokes a shim from +there. In isolated mode, ContainerBin skips project-root detection entirely, +sets `--workdir /root`, and does not bind-mount the host CWD at all. Any +argument that looks like a host path is still mapped, but because no path can be +"inside the project" it always uses the existing external-mount path and lands +under `/cb/mounts/N`. `cwd_mode = "isolated"` cannot be combined with: + +- `project_volumes` (a project-scoped volume conceptually requires a project identity); +- `project_markers` (dead configuration once project-root detection is skipped); +- `provider = "python"` (the python provider has its own project/compat venv split that + isolated mode would otherwise silently collapse onto the shared global compat environment). + +`shared_volumes`, `host_mounts` and environment allowlisting all work exactly as they do in +project mode. Exposed profiles created by `cb expose` from an npm-shaped source profile do +**not** inherit that source's `cwd_mode`. + ## Windows path mapping ContainerBin translates Windows paths in arguments to container paths and diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 30e05a4..626e854 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -60,13 +60,24 @@ func Trace(reg registry.Registry, args []string) error { if err != nil { return err } - root, found := pathmap.FindProjectRoot(cwd, pathmap.ProjectMarkersFor(t)) - if !found { - root = cwd - } + raw := append([]string(nil), args[1:]...) normalized := pathmap.NormalizeToolArgs(t, raw) - workspaceRoot := pathmap.WorkspaceRootFor(t, root) + + var root, workspaceRoot string + var found bool + if t.CwdMode == "isolated" { + root = dockerrun.IsolatedRoot + workspaceRoot = "/root" + found = false + } else { + root, found = pathmap.FindProjectRoot(cwd, pathmap.ProjectMarkersFor(t)) + if !found { + root = cwd + } + workspaceRoot = pathmap.WorkspaceRootFor(t, root) + } + mapped, mounts, err := pathmap.MapToolArgs(t, root, cwd, workspaceRoot, raw) if err != nil { return err @@ -75,13 +86,23 @@ func Trace(reg registry.Registry, args []string) error { fmt.Printf("image: %s\n", t.Image) fmt.Printf("provider: %s\n", t.Provider) fmt.Printf("cwd: %s\n", cwd) - fmt.Printf("root: %s\n", root) - fmt.Printf("workspace: %s\n", workspaceRoot) + if t.CwdMode == "isolated" { + fmt.Printf("cwd_mode: isolated\n") + fmt.Printf("workdir: /root\n") + fmt.Printf("project_bind_mount: (none)\n") + } else { + fmt.Printf("root: %s\n", root) + fmt.Printf("workspace: %s\n", workspaceRoot) + } fmt.Printf("raw: %#v\n", raw) fmt.Printf("normalized: %#v\n", normalized) fmt.Printf("mapped: %#v\n", mapped) if len(mounts) == 0 { - fmt.Printf("mounts: (none beyond %s)\n", workspaceRoot) + if t.CwdMode == "isolated" { + fmt.Printf("mounts: (none beyond explicit host/cb mounts)\n") + } else { + fmt.Printf("mounts: (none beyond %s)\n", workspaceRoot) + } } else { fmt.Printf("mounts: %#v\n", mounts) } @@ -297,10 +318,21 @@ func Inspect(reg registry.Registry, args []string) error { if err != nil { return err } - root, found := pathmap.FindProjectRoot(cwd, pathmap.ProjectMarkersFor(t)) - if !found { - root = cwd + + var root, workspaceRoot string + var found bool + if t.CwdMode == "isolated" { + root = "" + found = false + workspaceRoot = "/root" + } else { + root, found = pathmap.FindProjectRoot(cwd, pathmap.ProjectMarkersFor(t)) + if !found { + root = cwd + } + workspaceRoot = pathmap.WorkspaceRootFor(t, root) } + fmt.Printf("name: %s\nimage: %s\nprovider: %s\n", t.Name, t.Image, t.Provider) lock, lockPath, lerr := lockfile.LoadForRegistry() if lerr != nil { @@ -318,7 +350,17 @@ func Inspect(reg registry.Registry, args []string) error { if len(t.Command) > 0 { fmt.Printf("command: %#v\n", t.Command) } - fmt.Printf("cwd: %s\nroot: %s\nworkspace: %s\n", cwd, root, pathmap.WorkspaceRootFor(t, root)) + if t.CwdMode == "isolated" { + fmt.Printf("cwd_mode: isolated\n") + } + fmt.Printf("cwd: %s\n", cwd) + if t.CwdMode == "isolated" { + fmt.Printf("workdir: /root\n") + fmt.Printf("project_bind_mount: (none)\n") + } else { + fmt.Printf("root: %s\n", root) + fmt.Printf("workspace: %s\n", workspaceRoot) + } if t.StateGroup != "" { fmt.Printf("state_group: %s\n", t.StateGroup) } @@ -327,7 +369,7 @@ func Inspect(reg registry.Registry, args []string) error { if e != nil { return e } - fmt.Printf("project_volume: %s -> %s\n", pathmap.StatefulProjectVolumeID(t.StateGroup, logical, root, found), pathmap.StatefulWorkspaceDestination(dst, pathmap.WorkspaceRootFor(t, root))) + fmt.Printf("project_volume: %s -> %s\n", pathmap.StatefulProjectVolumeID(t.StateGroup, logical, root, found), pathmap.StatefulWorkspaceDestination(dst, workspaceRoot)) } for _, spec := range t.SharedVolumes { logical, dst, e := registry.ParseVolumeBinding(spec) diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index ac3951c..95c8ec5 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -272,6 +272,145 @@ host_mounts = ["%USERPROFILE%/does-not-exist:/root/missing:ro"] } } +func TestInspectDefaultOmitsCwdMode(t *testing.T) { + dir := t.TempDir() + old, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + defer os.Chdir(old) + + reg, err := registry.ParseTOML(`[tools.demo] +image = "demo:1" +provider = "stateless" +`) + if err != nil { + t.Fatalf("parse registry: %v", err) + } + + out, err := captureStdout(func() error { return Inspect(reg, []string{"demo"}) }) + if err != nil { + t.Fatalf("capture: %v", err) + } + if strings.Contains(out, "cwd_mode") { + t.Fatalf("default inspect output should not contain cwd_mode:\n%s", out) + } +} + +func TestInspectPrintsCwdModeIsolated(t *testing.T) { + dir := t.TempDir() + old, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + defer os.Chdir(old) + + reg, err := registry.ParseTOML(`[tools.demo] +image = "demo:1" +provider = "stateful" +state_group = "g" +cwd_mode = "isolated" +shared_volumes = ["cache:/root/.cache"] +`) + if err != nil { + t.Fatalf("parse registry: %v", err) + } + + out, err := captureStdout(func() error { return Inspect(reg, []string{"demo"}) }) + if err != nil { + t.Fatalf("capture: %v", err) + } + if !strings.Contains(out, "cwd_mode: isolated") { + t.Fatalf("inspect output missing cwd_mode: isolated:\n%s", out) + } + if !strings.Contains(out, "workdir: /root") { + t.Fatalf("inspect output missing /root workdir:\n%s", out) + } + if !strings.Contains(out, "project_bind_mount: (none)") { + t.Fatalf("inspect output missing no-project-mount marker:\n%s", out) + } + if strings.Contains(out, "\nroot:") { + t.Fatalf("isolated inspect output should not print a project root:\n%s", out) + } + if strings.Contains(out, "\nworkspace:") { + t.Fatalf("isolated inspect output should not print a workspace:\n%s", out) + } +} + +func TestTraceDefaultStillPrintsRootAndWorkspace(t *testing.T) { + dir := t.TempDir() + old, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + defer os.Chdir(old) + if err := os.WriteFile(filepath.Join(dir, ".git"), []byte{}, 0644); err != nil { + t.Fatal(err) + } + + reg, err := registry.ParseTOML(`[tools.demo] +image = "demo:1" +provider = "stateless" +`) + if err != nil { + t.Fatalf("parse registry: %v", err) + } + + out, err := captureStdout(func() error { return Trace(reg, []string{"demo"}) }) + if err != nil { + t.Fatalf("capture: %v", err) + } + if !strings.Contains(out, "\nroot:") { + t.Fatalf("default trace output missing root line:\n%s", out) + } + if !strings.Contains(out, "\nworkspace:") { + t.Fatalf("default trace output missing workspace line:\n%s", out) + } + if strings.Contains(out, "cwd_mode") { + t.Fatalf("default trace output should not contain cwd_mode:\n%s", out) + } +} + +func TestTraceIsolatedNoProjectMount(t *testing.T) { + dir := t.TempDir() + old, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + defer os.Chdir(old) + + reg, err := registry.ParseTOML(`[tools.demo] +image = "demo:1" +provider = "stateful" +state_group = "g" +cwd_mode = "isolated" +shared_volumes = ["cache:/root/.cache"] +`) + if err != nil { + t.Fatalf("parse registry: %v", err) + } + + out, err := captureStdout(func() error { return Trace(reg, []string{"demo"}) }) + if err != nil { + t.Fatalf("capture: %v", err) + } + if !strings.Contains(out, "cwd_mode: isolated") { + t.Fatalf("trace output missing cwd_mode: isolated:\n%s", out) + } + if !strings.Contains(out, "workdir: /root") { + t.Fatalf("trace output missing /root workdir:\n%s", out) + } + if !strings.Contains(out, "project_bind_mount: (none)") { + t.Fatalf("trace output missing no-project-mount marker:\n%s", out) + } + if strings.Contains(out, "\nroot:") { + t.Fatalf("isolated trace output should not print a project root:\n%s", out) + } + if strings.Contains(out, "\nworkspace:") { + t.Fatalf("isolated trace output should not print a workspace:\n%s", out) + } +} + func TestTraceHostMountUNCWouldFail(t *testing.T) { if runtime.GOOS != "windows" { t.Skip("UNC resolution is only meaningful on Windows") diff --git a/internal/dockerrun/dockerrun.go b/internal/dockerrun/dockerrun.go index 0ed61d4..c97c48b 100644 --- a/internal/dockerrun/dockerrun.go +++ b/internal/dockerrun/dockerrun.go @@ -18,6 +18,22 @@ import ( "github.com/AviBackToBlack/container-bin/internal/registry" ) +// isolatedRoot is a sentinel project root for cwd_mode = "isolated". +// It is intentionally not a real Windows path: the drive letter "?" is not a +// valid Windows volume, so pathmap.pathWithin can never match any host path +// that pathmap.CanonicalPath could produce. MapToolArgs therefore treats every +// path argument as outside the project and routes it through the existing +// external /cb/mounts/N path, exactly as the design requires. +const IsolatedRoot = "?:\\no-project" + +type runContext struct { + cwd string + root string + workspaceRoot string + containerWD string + found bool +} + // interactiveTerminal is intentionally conservative: Docker gets a TTY only // when both stdin and stdout are character devices. Pipes/redirection and // process-captured output remain plain -i, preserving automation semantics. @@ -39,50 +55,108 @@ func RunTool(t registry.Tool, userArgs []string) (int, error) { if err != nil { return 1, err } + + ctx, err := resolveRunContext(t, cwd) + if err != nil { + return 1, err + } + + imageRef, err := lockfile.RuntimeImageForTool(t) + if err != nil { + return 1, err + } + + args, err := buildDockerArgs(t, userArgs, ctx, imageRef, interactiveTerminal()) + if err != nil { + return 1, err + } + + if err := ensureDockerVolumes(t, ctx); err != nil { + return 1, err + } + + cmd := exec.Command("docker", args...) + cmd.Stdin, cmd.Stdout, cmd.Stderr, cmd.Env = os.Stdin, os.Stdout, os.Stderr, os.Environ() + err = cmd.Run() + if err == nil { + return 0, nil + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return exitErr.ExitCode(), nil + } + return 1, err +} + +func resolveRunContext(t registry.Tool, cwd string) (runContext, error) { + if t.CwdMode == "isolated" { + return runContext{ + cwd: cwd, + root: IsolatedRoot, + workspaceRoot: "/root", + containerWD: "/root", + found: false, + }, nil + } + root, found := pathmap.FindProjectRoot(cwd, pathmap.ProjectMarkersFor(t)) if !found { root = cwd } rel, err := filepath.Rel(root, cwd) if err != nil { - return 1, err + return runContext{}, err } workspaceRoot := pathmap.WorkspaceRootFor(t, root) containerWD := workspaceRoot if rel != "." { containerWD += "/" + filepath.ToSlash(rel) } + return runContext{ + cwd: cwd, + root: root, + workspaceRoot: workspaceRoot, + containerWD: containerWD, + found: found, + }, nil +} - mappedUserArgs, pathMounts, err := pathmap.MapToolArgs(t, root, cwd, workspaceRoot, userArgs) - if err != nil { - return 1, err - } - imageRef, err := lockfile.RuntimeImageForTool(t) +func buildDockerArgs(t registry.Tool, userArgs []string, ctx runContext, imageRef string, tty bool) ([]string, error) { + mappedUserArgs, pathMounts, err := pathmap.MapToolArgs(t, ctx.root, ctx.cwd, ctx.workspaceRoot, userArgs) if err != nil { - return 1, err + return nil, err } + args := []string{"run", "--rm", "-i"} - if interactiveTerminal() { + if tty { args = append(args, "-t") } - args = append(args, "--workdir", containerWD) - rootSpec, err := MountSpec("bind", root, workspaceRoot) - if err != nil { - return 1, err + args = append(args, "--workdir", ctx.containerWD) + + // In project mode the host CWD (or discovered project root) is bind-mounted + // into the container workspace. In isolated mode no such bind mount exists. + if t.CwdMode != "isolated" { + rootSpec, err := MountSpec("bind", ctx.root, ctx.workspaceRoot) + if err != nil { + return nil, err + } + args = append(args, "--mount", rootSpec) } - args = append(args, "--mount", rootSpec) + for _, m := range pathMounts { mount, err := MountSpec("bind", m.Source, m.Target) if err != nil { - return 1, err + return nil, err } args = append(args, "--mount", mount) } + hostMountArgs, err := buildHostMountArgs(t.HostMounts) if err != nil { - return 1, err + return nil, err } args = append(args, hostMountArgs...) + for _, name := range selectedHostEnv(t) { args = append(args, "-e", name) } @@ -92,28 +166,14 @@ func RunTool(t registry.Tool, userArgs []string) (int, error) { switch t.Provider { case "python": - envID := pathmap.PythonEnvID(root, found) - pyLabels := map[string]string{"cb.managed": "true", "cb.owner": "python313/venv"} - if found { - pyLabels["cb.kind"] = "project" - pyLabels["cb.project_path"] = root - pyLabels["cb.project_hash"] = pathmap.VolumeHash(root) - } else { - pyLabels["cb.kind"] = "compat" - } + envID := pathmap.PythonEnvID(ctx.root, ctx.found) venvSpec, err := MountSpec("volume", envID, "/venv") if err != nil { - return 1, err + return nil, err } pipCacheSpec, err := MountSpec("volume", "cb-pip-cache", "/root/.cache/pip") if err != nil { - return 1, err - } - if err := dockervol.EnsureManaged(envID, pyLabels); err != nil { - return 1, err - } - if err := dockervol.EnsureManaged("cb-pip-cache", map[string]string{"cb.managed": "true", "cb.kind": "shared", "cb.owner": "python313/pip-cache"}); err != nil { - return 1, err + return nil, err } args = append(args, "--mount", venvSpec, @@ -133,31 +193,25 @@ func RunTool(t registry.Tool, userArgs []string) (int, error) { for _, spec := range t.ProjectVolumes { name, dst, err := registry.ParseVolumeBinding(spec) if err != nil { - return 1, err + return nil, err } - vol := pathmap.StatefulProjectVolumeID(t.StateGroup, name, root, found) - dst = pathmap.StatefulWorkspaceDestination(dst, workspaceRoot) + vol := pathmap.StatefulProjectVolumeID(t.StateGroup, name, ctx.root, ctx.found) + dst = pathmap.StatefulWorkspaceDestination(dst, ctx.workspaceRoot) volSpec, err := MountSpec("volume", vol, dst) if err != nil { - return 1, err - } - if err := dockervol.EnsureManaged(vol, map[string]string{"cb.managed": "true", "cb.kind": "project", "cb.owner": t.StateGroup + "/" + name, "cb.project_path": root, "cb.project_hash": pathmap.VolumeHash(root)}); err != nil { - return 1, err + return nil, err } args = append(args, "--mount", volSpec) } for _, spec := range t.SharedVolumes { name, dst, err := registry.ParseVolumeBinding(spec) if err != nil { - return 1, err + return nil, err } vol := pathmap.StatefulSharedVolumeID(t.StateGroup, name) volSpec, err := MountSpec("volume", vol, dst) if err != nil { - return 1, err - } - if err := dockervol.EnsureManaged(vol, map[string]string{"cb.managed": "true", "cb.kind": "shared", "cb.owner": t.StateGroup + "/" + name}); err != nil { - return 1, err + return nil, err } args = append(args, "--mount", volSpec) } @@ -171,20 +225,53 @@ func RunTool(t registry.Tool, userArgs []string) (int, error) { args = append(args, t.ArgsPrefix...) args = append(args, mappedUserArgs...) default: - return 1, fmt.Errorf("unsupported provider %q", t.Provider) + return nil, fmt.Errorf("unsupported provider %q", t.Provider) } - cmd := exec.Command("docker", args...) - cmd.Stdin, cmd.Stdout, cmd.Stderr, cmd.Env = os.Stdin, os.Stdout, os.Stderr, os.Environ() - err = cmd.Run() - if err == nil { - return 0, nil - } - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - return exitErr.ExitCode(), nil + return args, nil +} + +func ensureDockerVolumes(t registry.Tool, ctx runContext) error { + switch t.Provider { + case "python": + envID := pathmap.PythonEnvID(ctx.root, ctx.found) + pyLabels := map[string]string{"cb.managed": "true", "cb.owner": "python313/venv"} + if ctx.found { + pyLabels["cb.kind"] = "project" + pyLabels["cb.project_path"] = ctx.root + pyLabels["cb.project_hash"] = pathmap.VolumeHash(ctx.root) + } else { + pyLabels["cb.kind"] = "compat" + } + if err := dockervol.EnsureManaged(envID, pyLabels); err != nil { + return err + } + if err := dockervol.EnsureManaged("cb-pip-cache", map[string]string{"cb.managed": "true", "cb.kind": "shared", "cb.owner": "python313/pip-cache"}); err != nil { + return err + } + case "stateful": + for _, spec := range t.ProjectVolumes { + name, _, err := registry.ParseVolumeBinding(spec) + if err != nil { + return err + } + vol := pathmap.StatefulProjectVolumeID(t.StateGroup, name, ctx.root, ctx.found) + if err := dockervol.EnsureManaged(vol, map[string]string{"cb.managed": "true", "cb.kind": "project", "cb.owner": t.StateGroup + "/" + name, "cb.project_path": ctx.root, "cb.project_hash": pathmap.VolumeHash(ctx.root)}); err != nil { + return err + } + } + for _, spec := range t.SharedVolumes { + name, _, err := registry.ParseVolumeBinding(spec) + if err != nil { + return err + } + vol := pathmap.StatefulSharedVolumeID(t.StateGroup, name) + if err := dockervol.EnsureManaged(vol, map[string]string{"cb.managed": "true", "cb.kind": "shared", "cb.owner": t.StateGroup + "/" + name}); err != nil { + return err + } + } } - return 1, err + return nil } func selectedHostEnv(t registry.Tool) []string { diff --git a/internal/dockerrun/dockerrun_test.go b/internal/dockerrun/dockerrun_test.go index 9767739..e037455 100644 --- a/internal/dockerrun/dockerrun_test.go +++ b/internal/dockerrun/dockerrun_test.go @@ -3,10 +3,12 @@ package dockerrun import ( "os" "path/filepath" + "reflect" "runtime" "strings" "testing" + "github.com/AviBackToBlack/container-bin/internal/pathmap" "github.com/AviBackToBlack/container-bin/internal/registry" ) @@ -178,6 +180,271 @@ host_mounts = ["%USERPROFILE%/.claude:/root/.claude:ro"] } } +func TestBuildDockerArgsDefaultProjectModeIsUnchanged(t *testing.T) { + dir := t.TempDir() + root, err := pathmap.CanonicalPath(dir) + if err != nil { + t.Fatalf("canonical path: %v", err) + } + ctx := runContext{ + cwd: root, + root: root, + workspaceRoot: "/workspace", + containerWD: "/workspace", + found: false, + } + tool := registry.Tool{Name: "demo", Image: "demo:1", Provider: "stateless"} + got, err := buildDockerArgs(tool, nil, ctx, "demo:1", false) + if err != nil { + t.Fatalf("buildDockerArgs: %v", err) + } + want := []string{ + "run", "--rm", "-i", + "--workdir", "/workspace", + "--mount", "type=bind,src=" + root + ",dst=/workspace", + "demo:1", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("default project args mismatch:\ngot %#v\nwant %#v", got, want) + } +} + +func TestBuildDockerArgsEmptyCwdModeMatchesProject(t *testing.T) { + dir := t.TempDir() + root, err := pathmap.CanonicalPath(dir) + if err != nil { + t.Fatalf("canonical path: %v", err) + } + ctx := runContext{ + cwd: root, + root: root, + workspaceRoot: "/workspace", + containerWD: "/workspace", + found: false, + } + empty := registry.Tool{Name: "demo", Image: "demo:1", Provider: "stateless", CwdMode: ""} + project := registry.Tool{Name: "demo", Image: "demo:1", Provider: "stateless", CwdMode: "project"} + emptyArgs, err := buildDockerArgs(empty, nil, ctx, "demo:1", false) + if err != nil { + t.Fatalf("buildDockerArgs empty: %v", err) + } + projectArgs, err := buildDockerArgs(project, nil, ctx, "demo:1", false) + if err != nil { + t.Fatalf("buildDockerArgs project: %v", err) + } + if !reflect.DeepEqual(emptyArgs, projectArgs) { + t.Fatalf("empty and project cwd_mode differ:\nempty %#v\nproject %#v", emptyArgs, projectArgs) + } +} + +// TestResolveRunContextIsolated exercises resolveRunContext's own isolated +// branch directly (Copilot flagged that the other isolated tests all +// hand-build a runContext, the same gap GLM caught for the default-mode +// tests in round 2 -- a regression in resolveRunContext's isolated branch +// could silently restore a real root/workdir while those builder-only tests +// stayed green). +func TestResolveRunContextIsolated(t *testing.T) { + dir := t.TempDir() + cwd, err := pathmap.CanonicalPath(dir) + if err != nil { + t.Fatalf("canonical path: %v", err) + } + tool := registry.Tool{Name: "demo", Image: "demo:1", Provider: "stateless", CwdMode: "isolated"} + ctx, err := resolveRunContext(tool, cwd) + if err != nil { + t.Fatalf("resolveRunContext: %v", err) + } + want := runContext{ + cwd: cwd, + root: IsolatedRoot, + workspaceRoot: "/root", + containerWD: "/root", + found: false, + } + if !reflect.DeepEqual(ctx, want) { + t.Fatalf("resolveRunContext isolated mismatch:\ngot %+v\nwant %+v", ctx, want) + } + + args, err := buildDockerArgs(tool, nil, ctx, "demo:1", false) + if err != nil { + t.Fatalf("buildDockerArgs: %v", err) + } + wantArgs := []string{"run", "--rm", "-i", "--workdir", "/root", "demo:1"} + if !reflect.DeepEqual(args, wantArgs) { + t.Fatalf("end-to-end isolated args = %#v, want %#v", args, wantArgs) + } + if strings.Contains(strings.Join(args, " "), cwd) { + t.Fatalf("end-to-end isolated args must not contain the host CWD; got %q", args) + } +} + +func TestBuildDockerArgsIsolatedNoCwdMount(t *testing.T) { + dir := t.TempDir() + cwd, err := pathmap.CanonicalPath(dir) + if err != nil { + t.Fatalf("canonical path: %v", err) + } + ctx := runContext{ + cwd: cwd, + root: IsolatedRoot, + workspaceRoot: "/root", + containerWD: "/root", + found: false, + } + tool := registry.Tool{Name: "demo", Image: "demo:1", Provider: "stateless", CwdMode: "isolated"} + args, err := buildDockerArgs(tool, nil, ctx, "demo:1", false) + if err != nil { + t.Fatalf("buildDockerArgs: %v", err) + } + want := []string{ + "run", "--rm", "-i", + "--workdir", "/root", + "demo:1", + } + if !reflect.DeepEqual(args, want) { + t.Fatalf("isolated args = %#v, want %#v", args, want) + } + joined := strings.Join(args, " ") + if strings.Contains(joined, cwd) { + t.Fatalf("isolated args must not contain the host CWD; got %q", joined) + } +} + +func TestBuildDockerArgsIsolatedExternalPath(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows path mapping is required for external-path assertions") + } + + rootDir := t.TempDir() + root, err := pathmap.CanonicalPath(rootDir) + if err != nil { + t.Fatalf("canonical root: %v", err) + } + extDir := t.TempDir() + if err := os.WriteFile(filepath.Join(extDir, "file.txt"), []byte{}, 0644); err != nil { + t.Fatalf("write external file: %v", err) + } + ext, err := pathmap.CanonicalPath(extDir) + if err != nil { + t.Fatalf("canonical ext: %v", err) + } + + ctx := runContext{ + cwd: root, + root: IsolatedRoot, + workspaceRoot: "/root", + containerWD: "/root", + found: false, + } + tool := registry.Tool{Name: "demo", Image: "demo:1", Provider: "stateless", CwdMode: "isolated"} + args, err := buildDockerArgs(tool, []string{filepath.Join(ext, "file.txt")}, ctx, "demo:1", false) + if err != nil { + t.Fatalf("buildDockerArgs: %v", err) + } + + want := []string{ + "run", "--rm", "-i", + "--workdir", "/root", + "--mount", "type=bind,src=" + ext + ",dst=/cb/mounts/0", + "demo:1", + "/cb/mounts/0/file.txt", + } + if !reflect.DeepEqual(args, want) { + t.Fatalf("isolated external args mismatch:\ngot %#v\nwant %#v", args, want) + } +} + +func TestResolveRunContextDefaultNoMarker(t *testing.T) { + dir := t.TempDir() + cwd, err := pathmap.CanonicalPath(dir) + if err != nil { + t.Fatalf("canonical path: %v", err) + } + + tool := registry.Tool{Name: "demo", Image: "demo:1", Provider: "stateless"} + ctx, err := resolveRunContext(tool, cwd) + if err != nil { + t.Fatalf("resolveRunContext: %v", err) + } + + want := runContext{ + cwd: cwd, + root: cwd, + workspaceRoot: "/workspace", + containerWD: "/workspace", + found: false, + } + if !reflect.DeepEqual(ctx, want) { + t.Fatalf("resolveRunContext no-marker mismatch:\ngot %+v\nwant %+v", ctx, want) + } +} + +func TestResolveRunContextNestedUnderMarker(t *testing.T) { + parent := t.TempDir() + if err := os.WriteFile(filepath.Join(parent, ".git"), []byte{}, 0644); err != nil { + t.Fatalf("write .git marker: %v", err) + } + child := filepath.Join(parent, "subdir") + if err := os.MkdirAll(child, 0755); err != nil { + t.Fatalf("mkdir child: %v", err) + } + + cwd, err := pathmap.CanonicalPath(child) + if err != nil { + t.Fatalf("canonical child: %v", err) + } + parentCanon, err := pathmap.CanonicalPath(parent) + if err != nil { + t.Fatalf("canonical parent: %v", err) + } + + tool := registry.Tool{Name: "demo", Image: "demo:1", Provider: "stateless"} + ctx, err := resolveRunContext(tool, cwd) + if err != nil { + t.Fatalf("resolveRunContext: %v", err) + } + + want := runContext{ + cwd: cwd, + root: parentCanon, + workspaceRoot: "/workspace", + containerWD: "/workspace/subdir", + found: true, + } + if !reflect.DeepEqual(ctx, want) { + t.Fatalf("resolveRunContext nested mismatch:\ngot %+v\nwant %+v", ctx, want) + } +} + +func TestDefaultModeEndToEndArgsUnchanged(t *testing.T) { + dir := t.TempDir() + cwd, err := pathmap.CanonicalPath(dir) + if err != nil { + t.Fatalf("canonical path: %v", err) + } + + tool := registry.Tool{Name: "demo", Image: "demo:1", Provider: "stateless"} + ctx, err := resolveRunContext(tool, cwd) + if err != nil { + t.Fatalf("resolveRunContext: %v", err) + } + + args, err := buildDockerArgs(tool, nil, ctx, "demo:1", false) + if err != nil { + t.Fatalf("buildDockerArgs: %v", err) + } + + want := []string{ + "run", "--rm", "-i", + "--workdir", "/workspace", + "--mount", "type=bind,src=" + cwd + ",dst=/workspace", + "demo:1", + } + if !reflect.DeepEqual(args, want) { + t.Fatalf("default end-to-end args mismatch:\ngot %#v\nwant %#v", args, want) + } +} + func TestBuildHostMountArgsCanonicalizesBeforeStat(t *testing.T) { homeDir := t.TempDir() if err := os.MkdirAll(filepath.Join(homeDir, ".claude"), 0755); err != nil { diff --git a/internal/registry/registry.go b/internal/registry/registry.go index 183f28e..1d1530f 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -37,6 +37,7 @@ type Tool struct { ProjectVolumes []string // NAME:CONTAINER_PATH, project-scoped named volumes SharedVolumes []string // NAME:CONTAINER_PATH, shared named volumes HostMounts []string // SOURCE:/CONTAINER_PATH:ro|rw, explicit host bind mounts + CwdMode string // "project" or "isolated"; empty means "project" } type Registry struct { @@ -64,6 +65,7 @@ schema_version = 1 # project_volumes => ["name:/container/path"] scoped by project root # shared_volumes => ["name:/container/path"] shared across projects # host_mounts => ["%USERPROFILE%\\.claude:/root/.claude:ro"] explicit host bind mounts +# cwd_mode => "project" (default) or "isolated" (ignore CWD, no project bind mount) [tools.python] image = "python:3.13-slim" @@ -409,6 +411,12 @@ func ParseTOML(s string) (Registry, error) { return reg, fmt.Errorf("line %d host_mounts: %w", lineNo, err) } t.HostMounts = v + case "cwd_mode": + v, err := toml.ParseQuoted(value) + if err != nil { + return reg, fmt.Errorf("line %d cwd_mode: %w", lineNo, err) + } + t.CwdMode = strings.ToLower(v) default: return reg, fmt.Errorf("line %d: unsupported key %q", lineNo, key) } @@ -445,6 +453,20 @@ func ParseTOML(s string) (Registry, error) { default: return reg, fmt.Errorf("tool %q: unsupported provider %q", name, t.Provider) } + switch t.CwdMode { + case "", "project", "isolated": + default: + return reg, fmt.Errorf("tool %q: cwd_mode must be \"project\" or \"isolated\"", name) + } + if t.CwdMode == "isolated" && len(t.ProjectVolumes) > 0 { + return reg, fmt.Errorf("tool %q: cwd_mode = \"isolated\" cannot declare project_volumes", name) + } + if t.CwdMode == "isolated" && t.Provider == "python" { + return reg, fmt.Errorf("tool %q: cwd_mode = \"isolated\" is not supported for the python provider", name) + } + if t.CwdMode == "isolated" && len(t.ProjectMarkers) > 0 { + return reg, fmt.Errorf("tool %q: cwd_mode = \"isolated\" cannot declare project_markers", name) + } if err := validateHostMounts(t); err != nil { return reg, fmt.Errorf("tool %q: %w", name, err) } diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go index afd773d..e5a121d 100644 --- a/internal/registry/registry_test.go +++ b/internal/registry/registry_test.go @@ -651,6 +651,112 @@ state_group = "ygroup" } } +func TestCwdModeParsing(t *testing.T) { + cases := []struct { + mode string + want string + }{ + {"", ""}, + {"project", "project"}, + {"PROJECT", "project"}, + {"isolated", "isolated"}, + {"Isolated", "isolated"}, + } + for _, tc := range cases { + src := `[tools.x] +image = "x:1" +provider = "stateless" +` + if tc.mode != "" { + src += "cwd_mode = " + toml.Quote(tc.mode) + "\n" + } + reg, err := ParseTOML(src) + if err != nil { + t.Fatalf("mode %q: %v", tc.mode, err) + } + if got := reg.Tools["x"].CwdMode; got != tc.want { + t.Fatalf("mode %q: CwdMode = %q, want %q", tc.mode, got, tc.want) + } + } +} + +func TestCwdModeInvalidRejected(t *testing.T) { + _, err := ParseTOML(`[tools.x] +image = "x:1" +provider = "stateless" +cwd_mode = "limbo" +`) + if err == nil { + t.Fatal("expected error for invalid cwd_mode") + } + if !strings.Contains(err.Error(), `cwd_mode must be "project" or "isolated"`) { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestIsolatedRejectsProjectVolumes(t *testing.T) { + _, err := ParseTOML(`[tools.x] +image = "x:1" +provider = "stateful" +state_group = "g" +cwd_mode = "isolated" +project_volumes = ["data:/workspace/data"] +shared_volumes = ["cache:/root/.cache"] +`) + if err == nil { + t.Fatal("expected error for project_volumes with cwd_mode = isolated") + } + if !strings.Contains(err.Error(), `cwd_mode = "isolated" cannot declare project_volumes`) { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestIsolatedAllowsSharedVolumes(t *testing.T) { + reg, err := ParseTOML(`[tools.x] +image = "x:1" +provider = "stateful" +state_group = "g" +cwd_mode = "isolated" +shared_volumes = ["cache:/root/.cache"] +`) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if reg.Tools["x"].CwdMode != "isolated" { + t.Fatalf("CwdMode = %q, want isolated", reg.Tools["x"].CwdMode) + } +} + +func TestIsolatedRejectsPythonProvider(t *testing.T) { + _, err := ParseTOML(`[tools.x] +image = "python:3.13-slim" +provider = "python" +role = "python" +cwd_mode = "isolated" +`) + if err == nil { + t.Fatal("expected error for python provider with cwd_mode = isolated") + } + if !strings.Contains(err.Error(), `cwd_mode = "isolated" is not supported for the python provider`) { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestIsolatedRejectsProjectMarkers(t *testing.T) { + _, err := ParseTOML(`[tools.x] +image = "x:1" +provider = "stateless" +cwd_mode = "isolated" +project_markers = [".git"] +`) + if err == nil { + t.Fatal("expected error for project_markers with cwd_mode = isolated") + } + if !strings.Contains(err.Error(), `cwd_mode = "isolated" cannot declare project_markers`) { + t.Fatalf("unexpected error: %v", err) + } +} + func TestHostMountDefaultTOMLComment(t *testing.T) { // The default TOML must still parse and the host_mounts comment must not // create an extra tool or confuse the parser.