diff --git a/internal/worker/direct.go b/internal/worker/direct.go index 70d7bf1..93b4763 100644 --- a/internal/worker/direct.go +++ b/internal/worker/direct.go @@ -47,6 +47,29 @@ func hostBaseEnv() []string { return base } +// prepareTaskGitConfig returns the path to use as the task's global git config +// (GIT_CONFIG_GLOBAL) along with a cleanup function. Redirecting only git's +// global config keeps writes like `git config --global url..insteadOf` out of +// the developer's real ~/.gitconfig (and $XDG_CONFIG_HOME/git/config) without +// repointing HOME for every tool the agent runs. +func prepareTaskGitConfig(workspaceDir string, usingTargetDir bool) (string, func(), error) { + if !usingTargetDir { + return filepath.Join(workspaceDir, ".gitconfig"), func() {}, nil + } + + // In shared target-dir mode the workspace is the user's real checkout, so keep + // the throwaway global config in a temporary directory outside of it. + dir, err := os.MkdirTemp("", "oz-gitconfig-") + if err != nil { + return "", nil, fmt.Errorf("failed to create temporary git config directory: %w", err) + } + return filepath.Join(dir, ".gitconfig"), func() { + if err := os.RemoveAll(dir); err != nil { + log.Warnf(context.Background(), "Failed to remove temporary git config dir %s: %v", dir, err) + } + }, nil +} + // DirectBackendConfig holds configuration specific to the direct (non-containerized) backend. type DirectBackendConfig struct { WorkspaceRoot string @@ -124,18 +147,24 @@ func (b *DirectBackend) ExecuteTask(ctx context.Context, params *TaskParams) err } log.Infof(ctx, "Created workspace: %s", workspaceDir) } + gitConfigPath, cleanupGitConfig, err := prepareTaskGitConfig(workspaceDir, usingTargetDir) + if err != nil { + return err + } + defer cleanupGitConfig() + gitConfigEnv := []string{fmt.Sprintf("GIT_CONFIG_GLOBAL=%s", gitConfigPath)} defer func() { if usingTargetDir { // Don't clean up the shared target directory. - b.runTeardownIfConfigured(ctx, taskID, workspaceDir) + b.runTeardownIfConfigured(ctx, taskID, workspaceDir, gitConfigPath) return } if b.config.NoCleanup { log.Infof(ctx, "Skipping cleanup for workspace: %s", workspaceDir) return } - b.cleanup(ctx, taskID, workspaceDir) + b.cleanup(ctx, taskID, workspaceDir, gitConfigPath) }() // 2. Create temp environment file for setup script to write to. @@ -160,6 +189,7 @@ func (b *DirectBackend) ExecuteTask(ctx context.Context, params *TaskParams) err envVars = append(envVars, fmt.Sprintf("%s=%s", key, value)) } envVars = mergeEnvVars(envVars, harnessEnvVars(workspaceDir, params)) + envVars = mergeEnvVars(envVars, gitConfigEnv) // 4. Run setup command if configured. if b.config.SetupCommand != "" { @@ -187,6 +217,7 @@ func (b *DirectBackend) ExecuteTask(ctx context.Context, params *TaskParams) err setupScriptVars = append(setupScriptVars, fmt.Sprintf("%s=%s", key, value)) } envVars = mergeEnvVars(envVars, setupScriptVars) + envVars = mergeEnvVars(envVars, gitConfigEnv) // 6. Invoke oz CLI with base args. // Start from a minimal host base (HOME, TMPDIR, PATH) and overlay task env vars, @@ -235,12 +266,13 @@ func (b *DirectBackend) PreservesTasksOnShutdown() bool { } // runTeardownIfConfigured runs the teardown command if one is configured. -func (b *DirectBackend) runTeardownIfConfigured(ctx context.Context, taskID, workspaceDir string) { +func (b *DirectBackend) runTeardownIfConfigured(ctx context.Context, taskID, workspaceDir, gitConfigPath string) { if b.config.TeardownCommand == "" { return } teardownEnv := []string{ fmt.Sprintf("OZ_WORKSPACE_ROOT=%s", workspaceDir), + fmt.Sprintf("GIT_CONFIG_GLOBAL=%s", gitConfigPath), "OZ_WORKER_BACKEND=direct", fmt.Sprintf("OZ_RUN_ID=%s", taskID), } @@ -255,8 +287,8 @@ func (b *DirectBackend) runTeardownIfConfigured(ctx context.Context, taskID, wor } // cleanup runs the teardown command (if configured) and removes the workspace directory. -func (b *DirectBackend) cleanup(ctx context.Context, taskID, workspaceDir string) { - b.runTeardownIfConfigured(ctx, taskID, workspaceDir) +func (b *DirectBackend) cleanup(ctx context.Context, taskID, workspaceDir, gitConfigPath string) { + b.runTeardownIfConfigured(ctx, taskID, workspaceDir, gitConfigPath) log.Infof(ctx, "Removing workspace: %s", workspaceDir) if err := os.RemoveAll(workspaceDir); err != nil { diff --git a/internal/worker/direct_test.go b/internal/worker/direct_test.go index e5a3494..5f0709e 100644 --- a/internal/worker/direct_test.go +++ b/internal/worker/direct_test.go @@ -1,6 +1,9 @@ package worker import ( + "context" + "os" + "os/exec" "path/filepath" "strings" "testing" @@ -74,3 +77,327 @@ func envMap(values []string) map[string]string { } return result } + +func TestDirectBackendSetupCommandDoesNotReadHostGlobalGitConfig(t *testing.T) { + requireGit(t) + + hostHome := t.TempDir() + t.Setenv("HOME", hostHome) + + testDir := t.TempDir() + originPath := filepath.Join(testDir, "origin.git") + runGit(t, nil, "init", "--bare", originPath) + + rewriteURL := "rewrite-test://repo" + fileURL := gitFileURL(originPath) + runGit(t, []string{"HOME=" + hostHome}, "config", "--global", "--add", "url."+fileURL+".insteadOf", rewriteURL) + + ozPath := filepath.Join(testDir, "oz") + if err := os.WriteFile(ozPath, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatalf("failed to write fake oz script: %v", err) + } + + workspaceRoot := filepath.Join(testDir, "workspaces") + backend, err := NewDirectBackend(context.Background(), DirectBackendConfig{ + WorkspaceRoot: workspaceRoot, + OzPath: ozPath, + SetupCommand: `git ls-remote "$TEST_REWRITE_URL"`, + Env: map[string]string{ + "TEST_REWRITE_URL": rewriteURL, + }, + }) + if err != nil { + t.Fatalf("failed to create direct backend: %v", err) + } + + if err := backend.ExecuteTask(context.Background(), &TaskParams{TaskID: "task-unseeded"}); err == nil { + t.Fatal("expected setup git command to fail because host global git config is hidden") + } + + backend, err = NewDirectBackend(context.Background(), DirectBackendConfig{ + WorkspaceRoot: workspaceRoot, + OzPath: ozPath, + NoCleanup: true, + SetupCommand: strings.Join([]string{ + `git config --global --add url."$TEST_FILE_REPO_URL".insteadOf "$TEST_REWRITE_URL"`, + `git ls-remote "$TEST_REWRITE_URL"`, + }, "\n"), + Env: map[string]string{ + "TEST_FILE_REPO_URL": fileURL, + "TEST_REWRITE_URL": rewriteURL, + }, + }) + if err != nil { + t.Fatalf("failed to create seeded direct backend: %v", err) + } + if err := backend.ExecuteTask(context.Background(), &TaskParams{TaskID: "task-seeded"}); err != nil { + t.Fatalf("expected setup git command to succeed after seeding isolated git config: %v", err) + } +} + +func TestDirectBackendSetupCommandReceivesIsolatedGitConfig(t *testing.T) { + requireGit(t) + + hostHome := t.TempDir() + t.Setenv("HOME", hostHome) + + testDir := t.TempDir() + cfgCapture := filepath.Join(testDir, "setup_git_config_global.txt") + ozPath := filepath.Join(testDir, "oz") + if err := os.WriteFile(ozPath, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatalf("failed to write fake oz script: %v", err) + } + + workspaceRoot := filepath.Join(testDir, "workspaces") + backend, err := NewDirectBackend(context.Background(), DirectBackendConfig{ + WorkspaceRoot: workspaceRoot, + OzPath: ozPath, + NoCleanup: true, + SetupCommand: strings.Join([]string{ + `printf '%s' "$GIT_CONFIG_GLOBAL" > "$SETUP_CFG_CAPTURE"`, + `git config --global user.email setup@example.com`, + }, "\n"), + Env: map[string]string{ + "SETUP_CFG_CAPTURE": cfgCapture, + }, + }) + if err != nil { + t.Fatalf("failed to create direct backend: %v", err) + } + if err := backend.ExecuteTask(context.Background(), &TaskParams{TaskID: "task-setup"}); err != nil { + t.Fatalf("failed to execute task: %v", err) + } + + wantCfg := filepath.Join(workspaceRoot, "task-setup", ".gitconfig") + if got, err := os.ReadFile(cfgCapture); err != nil { + t.Fatalf("failed to read captured setup GIT_CONFIG_GLOBAL: %v", err) + } else if string(got) != wantCfg { + t.Fatalf("setup GIT_CONFIG_GLOBAL = %q, want %q", string(got), wantCfg) + } + data, err := os.ReadFile(wantCfg) + if err != nil { + t.Fatalf("isolated git config was not written by setup: %v", err) + } + if !strings.Contains(string(data), "setup@example.com") { + t.Fatalf("isolated git config missing setup user.email:\\n%s", data) + } + if _, err := os.Stat(filepath.Join(hostHome, ".gitconfig")); !os.IsNotExist(err) { + t.Fatalf("host ~/.gitconfig should not exist; stat err = %v", err) + } +} + +func TestDirectBackendSetupEnvFileCannotOverrideGitConfigGlobal(t *testing.T) { + testDir := t.TempDir() + mainCfgCapture := filepath.Join(testDir, "main_git_config_global.txt") + overrideCfg := filepath.Join(testDir, "override.gitconfig") + ozPath := filepath.Join(testDir, "oz") + script := `#!/bin/sh +set -eu +printf '%s' "$GIT_CONFIG_GLOBAL" > "$MAIN_CFG_CAPTURE" +git config --global user.email main@example.com +` + if err := os.WriteFile(ozPath, []byte(script), 0o755); err != nil { + t.Fatalf("failed to write fake oz script: %v", err) + } + + workspaceRoot := filepath.Join(testDir, "workspaces") + backend, err := NewDirectBackend(context.Background(), DirectBackendConfig{ + WorkspaceRoot: workspaceRoot, + OzPath: ozPath, + NoCleanup: true, + SetupCommand: `printf 'GIT_CONFIG_GLOBAL=%s\n' "$OVERRIDE_CFG" > "$OZ_ENVIRONMENT_FILE"`, + Env: map[string]string{ + "MAIN_CFG_CAPTURE": mainCfgCapture, + "OVERRIDE_CFG": overrideCfg, + }, + }) + if err != nil { + t.Fatalf("failed to create direct backend: %v", err) + } + if err := backend.ExecuteTask(context.Background(), &TaskParams{TaskID: "task-envfile"}); err != nil { + t.Fatalf("failed to execute task: %v", err) + } + + wantCfg := filepath.Join(workspaceRoot, "task-envfile", ".gitconfig") + if got, err := os.ReadFile(mainCfgCapture); err != nil { + t.Fatalf("failed to read captured main GIT_CONFIG_GLOBAL: %v", err) + } else if string(got) != wantCfg { + t.Fatalf("main GIT_CONFIG_GLOBAL = %q, want %q", string(got), wantCfg) + } + data, err := os.ReadFile(wantCfg) + if err != nil { + t.Fatalf("isolated git config was not written by main oz process: %v", err) + } + if !strings.Contains(string(data), "main@example.com") { + t.Fatalf("isolated git config missing main user.email:\\n%s", data) + } + if _, err := os.Stat(overrideCfg); !os.IsNotExist(err) { + t.Fatalf("setup-provided GIT_CONFIG_GLOBAL override should not be used; stat err = %v", err) + } +} + +func TestDirectBackendGitConfigIsolationSmoke(t *testing.T) { + requireGit(t) + + hostHome := t.TempDir() + t.Setenv("HOME", hostHome) + + testDir := t.TempDir() + originPath := filepath.Join(testDir, "origin.git") + runGit(t, nil, "init", "--bare", originPath) + + rewriteURL := "rewrite-smoke://repo" + fileURL := gitFileURL(originPath) + ozPath := filepath.Join(testDir, "oz") + script := `#!/bin/sh +set -eu +git ls-remote "$TEST_REWRITE_URL" +` + if err := os.WriteFile(ozPath, []byte(script), 0o755); err != nil { + t.Fatalf("failed to write fake oz script: %v", err) + } + + workspaceRoot := filepath.Join(testDir, "workspaces") + backend, err := NewDirectBackend(context.Background(), DirectBackendConfig{ + WorkspaceRoot: workspaceRoot, + OzPath: ozPath, + NoCleanup: true, + SetupCommand: `git config --global --add url."$TEST_FILE_REPO_URL".insteadOf "$TEST_REWRITE_URL"`, + Env: map[string]string{ + "TEST_FILE_REPO_URL": fileURL, + "TEST_REWRITE_URL": rewriteURL, + }, + }) + if err != nil { + t.Fatalf("failed to create direct backend: %v", err) + } + if err := backend.ExecuteTask(context.Background(), &TaskParams{TaskID: "task-smoke"}); err != nil { + t.Fatalf("expected main oz git command to use setup-seeded isolated git config: %v", err) + } + + wantCfg := filepath.Join(workspaceRoot, "task-smoke", ".gitconfig") + data, err := os.ReadFile(wantCfg) + if err != nil { + t.Fatalf("isolated git config was not written: %v", err) + } + if !strings.Contains(strings.ToLower(string(data)), "insteadof") { + t.Fatalf("isolated git config missing rewrite seeded by setup:\\n%s", data) + } + if _, err := os.Stat(filepath.Join(hostHome, ".gitconfig")); !os.IsNotExist(err) { + t.Fatalf("host ~/.gitconfig should not exist; stat err = %v", err) + } +} +func TestDirectBackendRedirectsGlobalGitConfig(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available on PATH") + } + + hostHome := t.TempDir() + t.Setenv("HOME", hostHome) + + testDir := t.TempDir() + homeCapture := filepath.Join(testDir, "home.txt") + cfgCapture := filepath.Join(testDir, "git_config_global.txt") + ozPath := filepath.Join(testDir, "oz") + script := `#!/bin/sh +set -eu +printf '%s' "$HOME" > "$OZ_HOME_CAPTURE" +printf '%s' "$GIT_CONFIG_GLOBAL" > "$OZ_CFG_CAPTURE" +git config --global --add url."https://x-access-token:tok@github.com/".insteadOf "ssh://git@github.com/" +` + if err := os.WriteFile(ozPath, []byte(script), 0o755); err != nil { + t.Fatalf("failed to write fake oz script: %v", err) + } + + workspaceRoot := filepath.Join(testDir, "workspaces") + backend, err := NewDirectBackend(context.Background(), DirectBackendConfig{ + WorkspaceRoot: workspaceRoot, + OzPath: ozPath, + NoCleanup: true, + Env: map[string]string{ + "OZ_HOME_CAPTURE": homeCapture, + "OZ_CFG_CAPTURE": cfgCapture, + }, + }) + if err != nil { + t.Fatalf("failed to create direct backend: %v", err) + } + + if err := backend.ExecuteTask(context.Background(), &TaskParams{TaskID: "task-1"}); err != nil { + t.Fatalf("failed to execute task: %v", err) + } + + if got, err := os.ReadFile(homeCapture); err != nil { + t.Fatalf("failed to read captured HOME: %v", err) + } else if string(got) != hostHome { + t.Fatalf("HOME = %q, want host home %q (HOME must not be repointed)", string(got), hostHome) + } + + wantCfg := filepath.Join(workspaceRoot, "task-1", ".gitconfig") + if got, err := os.ReadFile(cfgCapture); err != nil { + t.Fatalf("failed to read captured GIT_CONFIG_GLOBAL: %v", err) + } else if string(got) != wantCfg { + t.Fatalf("GIT_CONFIG_GLOBAL = %q, want %q", string(got), wantCfg) + } + data, err := os.ReadFile(wantCfg) + if err != nil { + t.Fatalf("isolated git config was not written: %v", err) + } + if !strings.Contains(strings.ToLower(string(data)), "insteadof") { + t.Fatalf("isolated git config missing insteadOf rewrite:\n%s", data) + } + + if _, err := os.Stat(filepath.Join(hostHome, ".gitconfig")); !os.IsNotExist(err) { + t.Fatalf("host ~/.gitconfig should not exist; stat err = %v", err) + } + if _, err := os.Stat(filepath.Join(hostHome, ".config", "git", "config")); !os.IsNotExist(err) { + t.Fatalf("host XDG git config should not exist; stat err = %v", err) + } +} + +func TestDirectBackendRejectsUnsafeTaskID(t *testing.T) { + testDir := t.TempDir() + ozPath := filepath.Join(testDir, "oz") + if err := os.WriteFile(ozPath, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatalf("failed to write fake oz script: %v", err) + } + + workspaceRoot := filepath.Join(testDir, "workspaces") + backend, err := NewDirectBackend(context.Background(), DirectBackendConfig{ + WorkspaceRoot: workspaceRoot, + OzPath: ozPath, + }) + if err != nil { + t.Fatalf("failed to create direct backend: %v", err) + } + + for _, badID := range []string{"../escape", "a/../../escape", "/abs/path", "..", ""} { + if err := backend.ExecuteTask(context.Background(), &TaskParams{TaskID: badID}); err == nil { + t.Fatalf("expected error for unsafe task ID %q, got nil", badID) + } + } + if _, err := os.Stat(filepath.Join(testDir, "escape")); !os.IsNotExist(err) { + t.Fatalf("traversal path should not exist; stat err = %v", err) + } +} + +func requireGit(t *testing.T) { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available on PATH") + } +} + +func gitFileURL(path string) string { + return "file://" + filepath.ToSlash(path) +} + +func runGit(t *testing.T, env []string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Env = append(os.Environ(), env...) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %s failed: %v\n%s", strings.Join(args, " "), err, output) + } +}