package cli import ( "context" "iop/apps/node/internal/runtime" "iop/packages/go/config" "os" "os/exec" "strings" "testing" "time" ) func TestCLIOneshotExecutorCwd(t *testing.T) { c := New(config.CLIConf{ Profiles: map[string]config.CLIProfileConf{ "oneshot-pwd": { Command: "pwd", }, }, }, nil) tmpDir := t.TempDir() spec := runtime.ExecutionSpec{ Target: "oneshot-pwd", Workspace: tmpDir, } sink := &testSink{} err := c.Execute(context.Background(), spec, sink) if err != nil { t.Fatalf("Execute failed: %v", err) } var output string for _, ev := range sink.events { if ev.Type == runtime.EventTypeDelta { output += ev.Delta } } resolved, err := os.Readlink(tmpDir) if err != nil { resolved = tmpDir } if !strings.Contains(output, tmpDir) && !strings.Contains(output, resolved) { t.Errorf("expected output to contain workspace path %q, got: %q", tmpDir, output) } } func TestCLIPersistentNonTerminalCwd(t *testing.T) { c := New(config.CLIConf{ Profiles: map[string]config.CLIProfileConf{ "persistent-pwd": { Command: "sh", Persistent: true, }, }, }, nil) tmpDir := t.TempDir() spec := runtime.ExecutionSpec{ Target: "persistent-pwd", Workspace: tmpDir, SessionID: "sess-cwd-nt", SessionMode: runtime.SessionModeCreateIfMissing, Input: map[string]any{"prompt": "pwd\n"}, } sink := &testSink{} err := c.Execute(context.Background(), spec, sink) if err != nil { t.Fatalf("Execute failed: %v", err) } defer func() { _ = c.Stop(context.Background()) }() var output string success := false resolved, err := os.Readlink(tmpDir) if err != nil { resolved = tmpDir } // Wait up to 2 seconds for output for i := 0; i < 20; i++ { output = "" for _, ev := range sink.events { if ev.Type == runtime.EventTypeDelta { output += ev.Delta } } if strings.Contains(output, tmpDir) || strings.Contains(output, resolved) { success = true break } time.Sleep(100 * time.Millisecond) } if !success { t.Errorf("expected output to contain workspace path %q, got: %q", tmpDir, output) } } func TestCLIPersistentTerminalCwd(t *testing.T) { c := New(config.CLIConf{ Profiles: map[string]config.CLIProfileConf{ "persistent-term-pwd": { Command: "sh", Persistent: true, Terminal: true, }, }, }, nil) tmpDir := t.TempDir() spec := runtime.ExecutionSpec{ Target: "persistent-term-pwd", Workspace: tmpDir, SessionID: "sess-cwd-term", SessionMode: runtime.SessionModeCreateIfMissing, Input: map[string]any{"prompt": "pwd"}, } sink := &testSink{} err := c.Execute(context.Background(), spec, sink) if err != nil { t.Fatalf("Execute failed: %v", err) } defer func() { _ = c.Stop(context.Background()) }() var output string success := false resolved, err := os.Readlink(tmpDir) if err != nil { resolved = tmpDir } // Wait up to 2 seconds for output for i := 0; i < 20; i++ { output = "" for _, ev := range sink.events { if ev.Type == runtime.EventTypeDelta { output += ev.Delta } } if strings.Contains(output, tmpDir) || strings.Contains(output, resolved) { success = true break } time.Sleep(100 * time.Millisecond) } if !success { t.Errorf("expected output to contain workspace path %q, got: %q", tmpDir, output) } } func TestCLIWorkspacePreflightFailures(t *testing.T) { // 1. prepareWorkspaceDir helper tests t.Run("Helper", func(t *testing.T) { // Blank workspace dir, err := prepareWorkspaceDir("") if err != nil || dir != "" { t.Errorf("expected empty dir and nil error for blank workspace, got dir=%q, err=%v", dir, err) } dir, err = prepareWorkspaceDir(" ") if err != nil || dir != "" { t.Errorf("expected empty dir and nil error for whitespace workspace, got dir=%q, err=%v", dir, err) } // Not found directory nonExistentPath := "/nonexistent/path/for/workspace/test" dir, err = prepareWorkspaceDir(nonExistentPath) if err == nil || !strings.Contains(err.Error(), "cli adapter: workspace not found") { t.Errorf("expected 'workspace not found' error, got dir=%q, err=%v", dir, err) } // File is not a directory tmpFile, err := os.CreateTemp("", "test-workspace-file-*") if err != nil { t.Fatalf("failed to create temp file: %v", err) } defer os.Remove(tmpFile.Name()) _ = tmpFile.Close() dir, err = prepareWorkspaceDir(tmpFile.Name()) if err == nil || !strings.Contains(err.Error(), "cli adapter: workspace is not a directory") { t.Errorf("expected 'workspace is not a directory' error, got dir=%q, err=%v", dir, err) } // Inaccessible directory if os.Geteuid() != 0 { // Create a directory and make it inaccessible inaccessibleDir := t.TempDir() + "/inaccessible" if err := os.Mkdir(inaccessibleDir, 0000); err != nil { t.Fatalf("failed to create inaccessible dir: %v", err) } dir, err = prepareWorkspaceDir(inaccessibleDir) if err == nil || !strings.Contains(err.Error(), "cli adapter: workspace inaccessible") { t.Errorf("expected 'workspace inaccessible' error, got dir=%q, err=%v", dir, err) } // Restore permission for cleanup _ = os.Chmod(inaccessibleDir, 0700) } else { t.Log("running as root; skipping inaccessible workspace helper test") } }) // 2. Execute path preflight error propagation t.Run("ExecuteOneshot", func(t *testing.T) { c := New(config.CLIConf{ Profiles: map[string]config.CLIProfileConf{ "oneshot-pwd": { Command: "pwd", }, }, }, nil) spec := runtime.ExecutionSpec{ RunID: "oneshot-run-id", Target: "oneshot-pwd", Workspace: "/nonexistent/path/for/workspace/test", } sink := &testSink{} err := c.Execute(context.Background(), spec, sink) if err == nil { t.Fatal("expected execute to fail for nonexistent workspace") } if !strings.Contains(err.Error(), "cli adapter: workspace not found") { t.Errorf("expected 'workspace not found' error, got: %v", err) } // Verify sink EventTypeError event hasErrorEvent := false for _, ev := range sink.events { if ev.Type == runtime.EventTypeError { hasErrorEvent = true if ev.RunID != spec.RunID { t.Errorf("expected EventTypeError RunID to be %q, got %q", spec.RunID, ev.RunID) } if !strings.Contains(ev.Error, "cli adapter: workspace not found") { t.Errorf("expected EventTypeError Error to contain 'workspace not found', got %q", ev.Error) } } } if !hasErrorEvent { t.Errorf("expected sink to contain EventTypeError event, but it did not") } }) t.Run("ExecutePersistent", func(t *testing.T) { c := New(config.CLIConf{ Profiles: map[string]config.CLIProfileConf{ "persistent-pwd": { Command: "sh", Persistent: true, }, }, }, nil) spec := runtime.ExecutionSpec{ RunID: "persistent-run-id", Target: "persistent-pwd", Workspace: "/nonexistent/path/for/workspace/test", SessionID: "sess-preflight-fail", SessionMode: runtime.SessionModeCreateIfMissing, Input: map[string]any{"prompt": "pwd\n"}, } sink := &testSink{} err := c.Execute(context.Background(), spec, sink) if err == nil { t.Fatal("expected execute to fail for nonexistent workspace") } if !strings.Contains(err.Error(), "cli adapter: workspace not found") { t.Errorf("expected 'workspace not found' error, got: %v", err) } // Verify sink EventTypeError event hasErrorEvent := false for _, ev := range sink.events { if ev.Type == runtime.EventTypeError { hasErrorEvent = true if ev.RunID != spec.RunID { t.Errorf("expected EventTypeError RunID to be %q, got %q", spec.RunID, ev.RunID) } if !strings.Contains(ev.Error, "cli adapter: workspace not found") { t.Errorf("expected EventTypeError Error to contain 'workspace not found', got %q", ev.Error) } } } if !hasErrorEvent { t.Errorf("expected sink to contain EventTypeError event, but it did not") } }) t.Run("ExecuteCodexAppServer", func(t *testing.T) { c := New(config.CLIConf{ Profiles: map[string]config.CLIProfileConf{ "codex-app": { Mode: modeCodexAppServer, Command: "codex", Persistent: true, Args: []string{"--app-server"}, }, }, }, nil) spec := runtime.ExecutionSpec{ RunID: "codex-run-id", Target: "codex-app", Workspace: "/nonexistent/path/for/workspace/test", SessionID: "sess-codex-preflight-fail", SessionMode: runtime.SessionModeCreateIfMissing, Input: map[string]any{"prompt": "some prompt"}, } sink := &testSink{} err := c.Execute(context.Background(), spec, sink) if err == nil { t.Fatal("expected execute to fail for nonexistent workspace") } if !strings.Contains(err.Error(), "cli adapter: start codex app-server") || !strings.Contains(err.Error(), "cli adapter: workspace not found") { t.Errorf("expected 'start codex app-server' and 'workspace not found' errors, got: %v", err) } // Verify sink EventTypeError event hasErrorEvent := false for _, ev := range sink.events { if ev.Type == runtime.EventTypeError { hasErrorEvent = true if ev.RunID != spec.RunID { t.Errorf("expected EventTypeError RunID to be %q, got %q", spec.RunID, ev.RunID) } if !strings.Contains(ev.Error, "cli adapter: start codex app-server") || !strings.Contains(ev.Error, "cli adapter: workspace not found") { t.Errorf("expected EventTypeError Error to contain 'start codex app-server' and 'workspace not found', got %q", ev.Error) } } } if !hasErrorEvent { t.Errorf("expected sink to contain EventTypeError event, but it did not") } }) t.Run("ExecuteOpencodeSSE", func(t *testing.T) { c := New(config.CLIConf{ Profiles: map[string]config.CLIProfileConf{ "opencode-sse-target": { Mode: modeOpencodeSSE, Command: "opencode", Persistent: true, Args: []string{"serve"}, // triggers opencode-sse mode }, }, }, nil) spec := runtime.ExecutionSpec{ RunID: "opencode-run-id", Target: "opencode-sse-target", Workspace: "/nonexistent/path/for/workspace/test", SessionID: "sess-opencode-preflight-fail", SessionMode: runtime.SessionModeCreateIfMissing, Input: map[string]any{"prompt": "some prompt"}, } sink := &testSink{} err := c.Execute(context.Background(), spec, sink) if err == nil { t.Fatal("expected execute to fail for nonexistent workspace") } if !strings.Contains(err.Error(), "cli adapter: start opencode server") || !strings.Contains(err.Error(), "cli adapter: workspace not found") { t.Errorf("expected 'start opencode server' and 'workspace not found' errors, got: %v", err) } // Verify sink EventTypeError event hasErrorEvent := false for _, ev := range sink.events { if ev.Type == runtime.EventTypeError { hasErrorEvent = true if ev.RunID != spec.RunID { t.Errorf("expected EventTypeError RunID to be %q, got %q", spec.RunID, ev.RunID) } if !strings.Contains(ev.Error, "cli adapter: start opencode server") || !strings.Contains(ev.Error, "cli adapter: workspace not found") { t.Errorf("expected EventTypeError Error to contain 'start opencode server' and 'workspace not found', got %q", ev.Error) } } } if !hasErrorEvent { t.Errorf("expected sink to contain EventTypeError event, but it did not") } }) } func TestCLIProcessExitFailureRemainsDistinct(t *testing.T) { c := New(config.CLIConf{ Profiles: map[string]config.CLIProfileConf{ "fail-oneshot": { Command: "sh", Args: []string{"-c", "exit 7"}, }, }, }, nil) tmpDir := t.TempDir() spec := runtime.ExecutionSpec{ Target: "fail-oneshot", Workspace: tmpDir, } sink := &testSink{} err := c.Execute(context.Background(), spec, sink) if err == nil { t.Fatal("expected command execution to fail") } errStr := err.Error() // Command failure should NOT look like a workspace error. if strings.Contains(errStr, "workspace not found") || strings.Contains(errStr, "workspace is not a directory") || strings.Contains(errStr, "workspace inaccessible") { t.Errorf("process exit failure should be distinct from workspace preflight error, got: %q", errStr) } if !strings.Contains(errStr, "exit status 7") && !strings.Contains(errStr, "exit code 7") { t.Errorf("expected error to mention exit status/code 7, got: %q", errStr) } } // TestBuildCmdAppliesEnvAndCwd locks the pre-refactor dir-before-env order // for the context one-shot lifecycle: workspace is applied before env, so // exec.Cmd.Environ() picks up PWD= alongside the custom env. func TestBuildCmdAppliesEnvAndCwd(t *testing.T) { tmpDir := t.TempDir() subDir := tmpDir + "/sub" if err := os.Mkdir(subDir, 0755); err != nil { t.Fatalf("failed to create sub dir: %v", err) } // 1. Happy path: valid command, valid workspace, valid env cmd, err := buildCmd(context.Background(), "echo", []string{"hello"}, []string{"FOO=bar"}, subDir) if err != nil { t.Fatalf("buildCmd failed: %v", err) } wantCmd := exec.CommandContext(context.Background(), "echo", "hello") wantCmd.Dir = subDir wantCmd.Env = append(wantCmd.Environ(), "FOO=bar") if cmd.Dir != wantCmd.Dir { t.Errorf("expected cmd.Dir to be %q, got %q", wantCmd.Dir, cmd.Dir) } if cmd.Path != wantCmd.Path { t.Errorf("expected cmd.Path %q, got %q", wantCmd.Path, cmd.Path) } if len(cmd.Args) != len(wantCmd.Args) { t.Fatalf("expected cmd.Args %v, got %v", wantCmd.Args, cmd.Args) } for i := range wantCmd.Args { if cmd.Args[i] != wantCmd.Args[i] { t.Errorf("expected cmd.Args %v, got %v", wantCmd.Args, cmd.Args) break } } assertEnvEqual(t, wantCmd.Env, cmd.Env) // 2. Invalid workspace path _, err = buildCmd(context.Background(), "echo", []string{"hello"}, nil, "/nonexistent/workspace/path") if err == nil { t.Error("expected buildCmd to fail with invalid workspace") } if !strings.Contains(err.Error(), "cli adapter: workspace not found") { t.Errorf("expected 'workspace not found' error, got %v", err) } } // TestBuildCmdWithoutContextAppliesEnvAndCwd locks the pre-refactor // env-before-dir order for the codex app-server, opencode local server, and // non-terminal persistent lifecycles: env is applied before workspace, so // exec.Cmd.Environ() keeps the parent PWD instead of being overridden to // match workspace. func TestBuildCmdWithoutContextAppliesEnvAndCwd(t *testing.T) { tmpDir := t.TempDir() subDir := tmpDir + "/sub" if err := os.Mkdir(subDir, 0755); err != nil { t.Fatalf("failed to create sub dir: %v", err) } // 1. Happy path: valid command, valid workspace, valid env cmd, err := buildCmdWithoutContext("echo", []string{"hello"}, []string{"FOO=bar"}, subDir) if err != nil { t.Fatalf("buildCmdWithoutContext failed: %v", err) } wantCmd := exec.Command("echo", "hello") wantCmd.Env = append(wantCmd.Environ(), "FOO=bar") wantCmd.Dir = subDir if cmd.Dir != wantCmd.Dir { t.Errorf("expected cmd.Dir to be %q, got %q", wantCmd.Dir, cmd.Dir) } if cmd.Path != wantCmd.Path { t.Errorf("expected cmd.Path %q, got %q", wantCmd.Path, cmd.Path) } if len(cmd.Args) != len(wantCmd.Args) { t.Fatalf("expected cmd.Args %v, got %v", wantCmd.Args, cmd.Args) } for i := range wantCmd.Args { if cmd.Args[i] != wantCmd.Args[i] { t.Errorf("expected cmd.Args %v, got %v", wantCmd.Args, cmd.Args) break } } assertEnvEqual(t, wantCmd.Env, cmd.Env) // cmd.Env must NOT carry PWD=: env is computed before Dir is // set, so PWD (if present at all) must still match the parent's PWD. for _, e := range cmd.Env { if e == "PWD="+subDir { t.Errorf("expected cmd.Env to not override PWD to workspace %q, got env %v", subDir, cmd.Env) } } // 2. Invalid workspace path _, err = buildCmdWithoutContext("echo", []string{"hello"}, nil, "/nonexistent/workspace/path") if err == nil { t.Error("expected buildCmdWithoutContext to fail with invalid workspace") } if !strings.Contains(err.Error(), "cli adapter: workspace not found") { t.Errorf("expected 'workspace not found' error, got %v", err) } } func assertEnvEqual(t *testing.T, want, got []string) { t.Helper() if len(want) != len(got) { t.Errorf("expected cmd.Env length %d, got %d\nwant: %v\ngot: %v", len(want), len(got), want, got) return } for i := range want { if want[i] != got[i] { t.Errorf("expected cmd.Env[%d] to be %q, got %q\nwant: %v\ngot: %v", i, want[i], got[i], want, got) } } }