package cli import ( "bufio" "context" "encoding/json" "fmt" "io" runtime "iop/packages/go/agentruntime" "iop/packages/go/config" "os" "path/filepath" "strings" "sync/atomic" "testing" "time" ) // fakeAppServer is a minimal fake of codex app-server --stdio. // It responds to requests by dispatching to a handler func. type fakeAppServer struct { dec *json.Decoder enc *json.Encoder handler func(method string, id int64, params map[string]any) (map[string]any, error) } func newFakeAppServer(r io.Reader, w io.Writer) *fakeAppServer { return &fakeAppServer{ dec: json.NewDecoder(r), enc: json.NewEncoder(w), } } func (f *fakeAppServer) serveOne() error { var req struct { ID int64 `json:"id"` Method string `json:"method"` Params map[string]any `json:"params"` } if err := f.dec.Decode(&req); err != nil { return err } // Notifications (id == 0) get no response. if req.ID == 0 { return nil } result, rpcErr := f.handler(req.Method, req.ID, req.Params) resp := map[string]any{"id": req.ID} if rpcErr != nil { resp["error"] = map[string]any{"code": -32000, "message": rpcErr.Error()} } else { resp["result"] = result } return f.enc.Encode(resp) } // makeStdioPipe creates an in-process pipe pair that looks like a subprocess stdio. // Returns (stdinReader, stdinWriter, stdoutReader, stdoutWriter). func makeStdioPipes() (io.Reader, io.WriteCloser, io.ReadCloser, io.WriteCloser) { stdinR, stdinW := io.Pipe() stdoutR, stdoutW := io.Pipe() return stdinR, stdinW, stdoutR, stdoutW } type captureWriter struct { w io.WriteCloser buf *strings.Builder } func (c *captureWriter) Write(p []byte) (int, error) { c.buf.Write(p) return c.w.Write(p) } func (c *captureWriter) Close() error { return c.w.Close() } // TestCodexAppServerRPC_InitializeShape verifies the initialize request: // - has "id", "method", and "params" fields // - does NOT have a "jsonrpc" field // - method == "initialize" func TestCodexAppServerRPC_InitializeShape(t *testing.T) { stdinR, stdinW := io.Pipe() stdoutR, stdoutW := io.Pipe() var capturedReq map[string]json.RawMessage serverDone := make(chan error, 1) go func() { defer close(serverDone) dec := json.NewDecoder(stdinR) // 1. initialize request if err := dec.Decode(&capturedReq); err != nil { serverDone <- fmt.Errorf("decode initialize: %w", err) return } var id int64 _ = json.Unmarshal(capturedReq["id"], &id) resp := map[string]any{"id": id, "result": map[string]any{ "protocolVersion": "2024-11-05", "capabilities": map[string]any{}, }} _ = json.NewEncoder(stdoutW).Encode(resp) // 2. initialized notification (no response) var notif map[string]json.RawMessage _ = dec.Decode(¬if) // 3. thread/start var tsReq map[string]json.RawMessage if err := dec.Decode(&tsReq); err != nil { serverDone <- fmt.Errorf("decode thread/start: %w", err) return } var tsID int64 _ = json.Unmarshal(tsReq["id"], &tsID) _ = json.NewEncoder(stdoutW).Encode(map[string]any{ "id": tsID, "result": map[string]any{"threadId": "thread-abc"}, }) }() // Build a proc directly (bypass exec.Command) using our pipes. proc := &codexAppServerProc{ stdin: stdinW, stdout: bufio.NewScanner(stdoutR), notifCh: make(chan appServerNotification, 8), done: make(chan struct{}), } proc.stdout.Buffer(make([]byte, 64*1024), 4*1024*1024) go proc.readLoop() threadID, err := codexAppServerInit(context.Background(), proc) if err != nil { t.Fatalf("codexAppServerInit: %v", err) } // Close stdin so the fake server goroutine can finish. _ = stdinW.Close() _ = stdoutW.Close() if threadID != "thread-abc" { t.Errorf("threadID = %q, want %q", threadID, "thread-abc") } // Verify initialize request shape. if _, hasJSONRPC := capturedReq["jsonrpc"]; hasJSONRPC { t.Error("initialize request must NOT contain 'jsonrpc' field") } var method string _ = json.Unmarshal(capturedReq["method"], &method) if method != "initialize" { t.Errorf("method = %q, want %q", method, "initialize") } if _, hasID := capturedReq["id"]; !hasID { t.Error("initialize request must have 'id' field") } if _, hasParams := capturedReq["params"]; !hasParams { t.Error("initialize request must have 'params' field") } } // TestCodexAppServerRPC_NoJSONRPCField confirms that send() never emits "jsonrpc". func TestCodexAppServerRPC_NoJSONRPCField(t *testing.T) { stdinR, stdinW := io.Pipe() stdoutR, stdoutW := io.Pipe() go func() { dec := json.NewDecoder(stdinR) var raw map[string]json.RawMessage if err := dec.Decode(&raw); err != nil { return } var id int64 _ = json.Unmarshal(raw["id"], &id) _ = json.NewEncoder(stdoutW).Encode(map[string]any{ "id": id, "result": map[string]any{}, }) _ = stdinR.Close() _ = stdoutW.Close() }() proc := &codexAppServerProc{ stdin: stdinW, stdout: bufio.NewScanner(stdoutR), notifCh: make(chan appServerNotification, 8), done: make(chan struct{}), } proc.stdout.Buffer(make([]byte, 64*1024), 4*1024*1024) go proc.readLoop() // Capture raw bytes written to stdin. var written strings.Builder origStdin := proc.stdin proc.stdin = &captureWriter{w: origStdin, buf: &written} _, _ = proc.send(context.Background(), "ping", nil) _ = stdinW.Close() line := strings.TrimSpace(written.String()) var decoded map[string]json.RawMessage if err := json.Unmarshal([]byte(line), &decoded); err != nil { t.Fatalf("parse written line: %v", err) } if _, ok := decoded["jsonrpc"]; ok { t.Error("send() must NOT include 'jsonrpc' field in request") } } // TestCodexAppServerProcessLifecycle_CloseSafe verifies that proc.close() is safe // to call multiple times (no panic on duplicate close of done channel). func TestCodexAppServerProcessLifecycle_CloseSafe(t *testing.T) { stdinR, stdinW := io.Pipe() stdoutR, _ := io.Pipe() _ = stdinR proc := &codexAppServerProc{ stdin: stdinW, stdout: bufio.NewScanner(stdoutR), notifCh: make(chan appServerNotification, 8), done: make(chan struct{}), } // close() twice must not panic. proc.close() proc.close() } // TestCodexAppServerProcessLifecycle_ExitSurfaces verifies that send() returns an // error when the process stdout closes (simulating process exit). func TestCodexAppServerProcessLifecycle_ExitSurfaces(t *testing.T) { // Use a devnull reader for stdin so the write goroutine doesn't block. stdinR, stdinW := io.Pipe() go io.Copy(io.Discard, stdinR) // drain stdin so write doesn't block stdoutR, stdoutW := io.Pipe() proc := &codexAppServerProc{ stdin: stdinW, stdout: bufio.NewScanner(stdoutR), notifCh: make(chan appServerNotification, 8), done: make(chan struct{}), } proc.stdout.Buffer(make([]byte, 64*1024), 4*1024*1024) go proc.readLoop() // Close stdout to signal process exit after a brief moment. go func() { _ = stdoutW.Close() }() _, err := proc.send(context.Background(), "initialize", nil) if err == nil { t.Fatal("expected error when process exits, got nil") } } // TestCodexAppServerSessionResolve verifies that two Execute calls with the same // target/sessionID reuse the same codexAppServerSession (without starting a real process). func TestCodexAppServerSessionResolve(t *testing.T) { e := &codexAppServerExecutor{ cli: &CLI{}, sessions: make(map[sessionKey]*codexAppServerSession), } // Pre-seed a session so resolveCodexAppServerSession returns it without spawning. key := sessionKey{target: "codex-as", sessionID: runtime.DefaultSessionID} existing := &codexAppServerSession{key: key, threadID: "tid-1"} e.sessions[key] = existing spec := runtime.ExecutionSpec{ Target: "codex-as", SessionID: "", } profile := config.CLIProfileConf{Mode: modeCodexAppServer, Command: "false"} got, err := e.resolveCodexAppServerSession(context.Background(), spec, profile) if err != nil { t.Fatalf("resolveCodexAppServerSession: %v", err) } if got != existing { t.Error("expected same session pointer to be returned") } } // TestCodexAppServerRequireExisting verifies that SessionModeRequireExisting returns // an error when no session exists. func TestCodexAppServerRequireExisting(t *testing.T) { e := &codexAppServerExecutor{ cli: &CLI{}, sessions: make(map[sessionKey]*codexAppServerSession), } spec := runtime.ExecutionSpec{ Target: "codex-as", SessionID: "missing", SessionMode: runtime.SessionModeRequireExisting, } profile := config.CLIProfileConf{Mode: modeCodexAppServer} _, err := e.resolveCodexAppServerSession(context.Background(), spec, profile) if err == nil { t.Fatal("expected error for SessionModeRequireExisting with no session, got nil") } } // TestCodexAppServerSend_RequestIDMonotone verifies that successive send() calls // use strictly increasing id values. func TestCodexAppServerSend_RequestIDMonotone(t *testing.T) { var counter atomic.Int64 var ids []int64 stdinR, stdinW := io.Pipe() stdoutR, stdoutW := io.Pipe() go func() { dec := json.NewDecoder(stdinR) enc := json.NewEncoder(stdoutW) for i := 0; i < 3; i++ { var raw map[string]json.RawMessage if err := dec.Decode(&raw); err != nil { return } var id int64 _ = json.Unmarshal(raw["id"], &id) counter.Add(1) _ = enc.Encode(map[string]any{"id": id, "result": map[string]any{}}) } _ = stdoutW.Close() }() proc := &codexAppServerProc{ stdin: stdinW, stdout: bufio.NewScanner(stdoutR), notifCh: make(chan appServerNotification, 8), done: make(chan struct{}), } proc.stdout.Buffer(make([]byte, 64*1024), 4*1024*1024) go proc.readLoop() for i := 0; i < 3; i++ { id := proc.nextID.Load() + 1 ids = append(ids, id) if _, err := proc.send(context.Background(), fmt.Sprintf("method-%d", i), nil); err != nil { t.Fatalf("send %d: %v", i, err) } } _ = stdinW.Close() for i := 1; i < len(ids); i++ { if ids[i] <= ids[i-1] { t.Errorf("ids not monotone: %v", ids) } } } // TestCodexAppServerThreadMissingID verifies that codexAppServerInit returns an error // when thread/start responds without a thread id field. func TestCodexAppServerThreadMissingID(t *testing.T) { stdinR, stdinW := io.Pipe() stdoutR, stdoutW := io.Pipe() go func() { dec := json.NewDecoder(stdinR) enc := json.NewEncoder(stdoutW) // initialize request → respond with capabilities var req map[string]json.RawMessage _ = dec.Decode(&req) var id int64 _ = json.Unmarshal(req["id"], &id) _ = enc.Encode(map[string]any{ "id": id, "result": map[string]any{"protocolVersion": "2024-11-05", "capabilities": map[string]any{}}, }) // initialized notification (no response needed) _ = dec.Decode(&req) // thread/start → respond with empty result (no threadId, no id) _ = dec.Decode(&req) var tsID int64 _ = json.Unmarshal(req["id"], &tsID) _ = enc.Encode(map[string]any{ "id": tsID, "result": map[string]any{}, }) _ = stdoutW.Close() }() proc := &codexAppServerProc{ stdin: stdinW, stdout: bufio.NewScanner(stdoutR), notifCh: make(chan appServerNotification, 8), done: make(chan struct{}), } proc.stdout.Buffer(make([]byte, 64*1024), 4*1024*1024) go proc.readLoop() _, err := codexAppServerInit(context.Background(), proc) _ = stdinW.Close() if err == nil { t.Fatal("expected error when thread/start returns no thread id, got nil") } } // TestCodexAppServerSessionThreadMapping verifies that same IOP session reuses // the same thread id and different sessions get different thread ids. func TestCodexAppServerSessionThreadMapping(t *testing.T) { e := &codexAppServerExecutor{ cli: &CLI{}, sessions: make(map[sessionKey]*codexAppServerSession), } key1 := sessionKey{target: "codex-as", sessionID: "sess-A"} key2 := sessionKey{target: "codex-as", sessionID: "sess-B"} e.sessions[key1] = &codexAppServerSession{key: key1, threadID: "thread-1"} e.sessions[key2] = &codexAppServerSession{key: key2, threadID: "thread-2"} // Same session must return same pointer (same thread id). spec := runtime.ExecutionSpec{Target: "codex-as", SessionID: "sess-A"} profile := config.CLIProfileConf{Mode: modeCodexAppServer, Command: "false"} got, err := e.resolveCodexAppServerSession(context.Background(), spec, profile) if err != nil { t.Fatalf("resolveCodexAppServerSession: %v", err) } if got.threadID != "thread-1" { t.Errorf("expected thread-1, got %q", got.threadID) } // Different session must return different thread id. spec2 := runtime.ExecutionSpec{Target: "codex-as", SessionID: "sess-B"} got2, err := e.resolveCodexAppServerSession(context.Background(), spec2, profile) if err != nil { t.Fatalf("resolveCodexAppServerSession sess-B: %v", err) } if got2.threadID != "thread-2" { t.Errorf("expected thread-2, got %q", got2.threadID) } if got.threadID == got2.threadID { t.Error("different sessions must have different thread ids") } } // TestCodexAppServerTurnStartRequestShape verifies that codexAppServerTurnStart sends // a turn/start request with the required TurnStartParams shape: // { threadId, input: [{ type, text, text_elements }] } func TestCodexAppServerTurnStartRequestShape(t *testing.T) { stdinR, stdinW := io.Pipe() stdoutR, stdoutW := io.Pipe() var capturedParams map[string]json.RawMessage serverDone := make(chan struct{}) go func() { defer close(serverDone) dec := json.NewDecoder(stdinR) enc := json.NewEncoder(stdoutW) var req map[string]json.RawMessage if err := dec.Decode(&req); err != nil { return } capturedParams = req var id int64 _ = json.Unmarshal(req["id"], &id) _ = enc.Encode(map[string]any{ "id": id, "result": map[string]any{"turn": map[string]any{"id": "turn-shape-test"}}, }) _ = stdoutW.Close() }() proc := &codexAppServerProc{ stdin: stdinW, stdout: bufio.NewScanner(stdoutR), notifCh: make(chan appServerNotification, 8), done: make(chan struct{}), } proc.stdout.Buffer(make([]byte, 64*1024), 4*1024*1024) go proc.readLoop() turnID, err := codexAppServerTurnStart(context.Background(), proc, "thread-shape-1", "hello codex") _ = stdinW.Close() <-serverDone if err != nil { t.Fatalf("codexAppServerTurnStart: %v", err) } if turnID != "turn-shape-test" { t.Errorf("turnID = %q, want %q", turnID, "turn-shape-test") } // Verify threadId field. var threadID string _ = json.Unmarshal(capturedParams["params"], &map[string]json.RawMessage{}) var paramsMap map[string]json.RawMessage _ = json.Unmarshal(capturedParams["params"], ¶msMap) _ = json.Unmarshal(paramsMap["threadId"], &threadID) if threadID != "thread-shape-1" { t.Errorf("threadId = %q, want %q", threadID, "thread-shape-1") } // Verify input array shape. var inputArr []map[string]json.RawMessage if err := json.Unmarshal(paramsMap["input"], &inputArr); err != nil { t.Fatalf("parse input: %v", err) } if len(inputArr) != 1 { t.Fatalf("input length = %d, want 1", len(inputArr)) } item := inputArr[0] var typ string _ = json.Unmarshal(item["type"], &typ) if typ != "text" { t.Errorf("input[0].type = %q, want %q", typ, "text") } var text string _ = json.Unmarshal(item["text"], &text) if text != "hello codex" { t.Errorf("input[0].text = %q, want %q", text, "hello codex") } // text_elements must be present (required by UserInput contract), value must be an array. rawTE, ok := item["text_elements"] if !ok { t.Fatal("input[0].text_elements field missing") } var textElements []any if err := json.Unmarshal(rawTE, &textElements); err != nil { t.Fatalf("input[0].text_elements is not an array: %v", err) } } // TestCodexAppServerTurnStartRequestShape_EmptyPrompt verifies that an empty prompt // still sends input with a text item (explicit design: always send input array). func TestCodexAppServerTurnStartRequestShape_EmptyPrompt(t *testing.T) { stdinR, stdinW := io.Pipe() stdoutR, stdoutW := io.Pipe() var capturedParams map[string]json.RawMessage serverDone := make(chan struct{}) go func() { defer close(serverDone) dec := json.NewDecoder(stdinR) enc := json.NewEncoder(stdoutW) var req map[string]json.RawMessage if err := dec.Decode(&req); err != nil { return } capturedParams = req var id int64 _ = json.Unmarshal(req["id"], &id) _ = enc.Encode(map[string]any{ "id": id, "result": map[string]any{"turn": map[string]any{"id": "turn-empty"}}, }) _ = stdoutW.Close() }() proc := &codexAppServerProc{ stdin: stdinW, stdout: bufio.NewScanner(stdoutR), notifCh: make(chan appServerNotification, 8), done: make(chan struct{}), } proc.stdout.Buffer(make([]byte, 64*1024), 4*1024*1024) go proc.readLoop() _, err := codexAppServerTurnStart(context.Background(), proc, "thread-empty", "") _ = stdinW.Close() <-serverDone if err != nil { t.Fatalf("codexAppServerTurnStart: %v", err) } var paramsMap map[string]json.RawMessage _ = json.Unmarshal(capturedParams["params"], ¶msMap) // input must be present even for empty prompt. if _, ok := paramsMap["input"]; !ok { t.Fatal("input field missing for empty prompt") } var inputArr []map[string]json.RawMessage if err := json.Unmarshal(paramsMap["input"], &inputArr); err != nil { t.Fatalf("parse input: %v", err) } if len(inputArr) != 1 { t.Fatalf("input length = %d, want 1", len(inputArr)) } if _, ok := inputArr[0]["text_elements"]; !ok { t.Fatal("input[0].text_elements missing for empty prompt") } } // TestCodexAppServerThreadStartResponseShape verifies that codexAppServerInit parses // the actual ThreadStartResponse shape: result.thread.id. func TestCodexAppServerThreadStartResponseShape(t *testing.T) { stdinR, stdinW := io.Pipe() stdoutR, stdoutW := io.Pipe() go func() { dec := json.NewDecoder(stdinR) enc := json.NewEncoder(stdoutW) // initialize var req map[string]json.RawMessage _ = dec.Decode(&req) var id int64 _ = json.Unmarshal(req["id"], &id) _ = enc.Encode(map[string]any{"id": id, "result": map[string]any{ "protocolVersion": "2024-11-05", "capabilities": map[string]any{}, }}) // initialized notification _ = dec.Decode(&req) // thread/start → actual shape: result.thread.id _ = dec.Decode(&req) var tsID int64 _ = json.Unmarshal(req["id"], &tsID) _ = enc.Encode(map[string]any{ "id": tsID, "result": map[string]any{"thread": map[string]any{"id": "thread-nested-id"}}, }) _ = stdoutW.Close() }() proc := &codexAppServerProc{ stdin: stdinW, stdout: bufio.NewScanner(stdoutR), notifCh: make(chan appServerNotification, 8), done: make(chan struct{}), } proc.stdout.Buffer(make([]byte, 64*1024), 4*1024*1024) go proc.readLoop() threadID, err := codexAppServerInit(context.Background(), proc) _ = stdinW.Close() if err != nil { t.Fatalf("codexAppServerInit: %v", err) } if threadID != "thread-nested-id" { t.Errorf("threadID = %q, want %q", threadID, "thread-nested-id") } } // TestCodexAppServerTurnStartResponseShape verifies that codexAppServerTurnStart parses // the actual TurnStartResponse shape: result.turn.id. func TestCodexAppServerTurnStartResponseShape(t *testing.T) { stdinR, stdinW := io.Pipe() stdoutR, stdoutW := io.Pipe() go func() { dec := json.NewDecoder(stdinR) enc := json.NewEncoder(stdoutW) var req map[string]json.RawMessage _ = dec.Decode(&req) var id int64 _ = json.Unmarshal(req["id"], &id) _ = enc.Encode(map[string]any{ "id": id, "result": map[string]any{"turn": map[string]any{"id": "turn-nested-id"}}, }) _ = stdoutW.Close() }() proc := &codexAppServerProc{ stdin: stdinW, stdout: bufio.NewScanner(stdoutR), notifCh: make(chan appServerNotification, 8), done: make(chan struct{}), } proc.stdout.Buffer(make([]byte, 64*1024), 4*1024*1024) go proc.readLoop() turnID, err := codexAppServerTurnStart(context.Background(), proc, "thread-1", "hello") _ = stdinW.Close() if err != nil { t.Fatalf("codexAppServerTurnStart: %v", err) } if turnID != "turn-nested-id" { t.Errorf("turnID = %q, want %q", turnID, "turn-nested-id") } } // TestCodexAppServerTurnStartMissingID verifies that a missing turn id returns an error. func TestCodexAppServerTurnStartMissingID(t *testing.T) { stdinR, stdinW := io.Pipe() stdoutR, stdoutW := io.Pipe() go func() { dec := json.NewDecoder(stdinR) enc := json.NewEncoder(stdoutW) var req map[string]json.RawMessage _ = dec.Decode(&req) var id int64 _ = json.Unmarshal(req["id"], &id) _ = enc.Encode(map[string]any{"id": id, "result": map[string]any{}}) _ = stdoutW.Close() }() proc := &codexAppServerProc{ stdin: stdinW, stdout: bufio.NewScanner(stdoutR), notifCh: make(chan appServerNotification, 8), done: make(chan struct{}), } proc.stdout.Buffer(make([]byte, 64*1024), 4*1024*1024) go proc.readLoop() _, err := codexAppServerTurnStart(context.Background(), proc, "thread-1", "") _ = stdinW.Close() if err == nil { t.Fatal("expected error when turn id is missing, got nil") } } // TestCodexAppServerInit_ThreadStartHasParams verifies that codexAppServerInit sends // thread/start with a "params" field (not null/absent). Real Codex app-server returns // -32600 "missing field `params`" if the field is omitted. func TestCodexAppServerInit_ThreadStartHasParams(t *testing.T) { stdinR, stdinW := io.Pipe() stdoutR, stdoutW := io.Pipe() var threadStartReq map[string]json.RawMessage serverDone := make(chan struct{}) go func() { defer close(serverDone) dec := json.NewDecoder(stdinR) enc := json.NewEncoder(stdoutW) // initialize var req map[string]json.RawMessage _ = dec.Decode(&req) var id int64 _ = json.Unmarshal(req["id"], &id) _ = enc.Encode(map[string]any{"id": id, "result": map[string]any{ "protocolVersion": "2024-11-05", "capabilities": map[string]any{}, }}) // initialized notification (no response) _ = dec.Decode(&req) // thread/start — capture full request _ = dec.Decode(&threadStartReq) var tsID int64 _ = json.Unmarshal(threadStartReq["id"], &tsID) _ = enc.Encode(map[string]any{ "id": tsID, "result": map[string]any{"thread": map[string]any{"id": "thread-params-check"}}, }) _ = stdoutW.Close() }() proc := &codexAppServerProc{ stdin: stdinW, stdout: bufio.NewScanner(stdoutR), notifCh: make(chan appServerNotification, 8), done: make(chan struct{}), } proc.stdout.Buffer(make([]byte, 64*1024), 4*1024*1024) go proc.readLoop() threadID, err := codexAppServerInit(context.Background(), proc) _ = stdinW.Close() <-serverDone if err != nil { t.Fatalf("codexAppServerInit: %v", err) } if threadID != "thread-params-check" { t.Errorf("threadID = %q, want %q", threadID, "thread-params-check") } // "params" must be present in thread/start request — real server rejects null/absent. rawParams, ok := threadStartReq["params"] if !ok { t.Fatal("thread/start request must include 'params' field") } // params must be a JSON object (not null). var paramsObj map[string]json.RawMessage if err := json.Unmarshal(rawParams, ¶msObj); err != nil { t.Fatalf("thread/start 'params' must be a JSON object, got: %s", string(rawParams)) } } func TestCodexAppServerProcCwd(t *testing.T) { tmpDir := t.TempDir() workspace := t.TempDir() scriptPath := filepath.Join(tmpDir, "fake-codex-server.sh") scriptContent := fmt.Sprintf(`#!/bin/sh pwd > "%s/cwd.txt" echo '{"id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{}}}' `, workspace) if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil { t.Fatalf("write fake script: %v", err) } profile := config.CLIProfileConf{ Command: scriptPath, Args: []string{}, } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() proc, err := startCodexAppServerProc(ctx, profile, workspace) if err != nil { t.Fatalf("startCodexAppServerProc: %v", err) } defer proc.close() markerPath := filepath.Join(workspace, "cwd.txt") cwdBytes := readFileEventually(t, markerPath, 5*time.Second) cwd := strings.TrimSpace(string(cwdBytes)) resolvedWorkspace, err := os.Readlink(workspace) if err != nil { resolvedWorkspace = workspace } if cwd != workspace && cwd != resolvedWorkspace { t.Errorf("expected process cwd to be %q or %q, got %q", workspace, resolvedWorkspace, cwd) } } func readFileEventually(t *testing.T, path string, timeout time.Duration) []byte { t.Helper() deadline := time.Now().Add(timeout) var lastErr error for { b, err := os.ReadFile(path) if err == nil { return b } lastErr = err if time.Now().After(deadline) { t.Fatalf("failed to read file %s within %s: %v", path, timeout, lastErr) } time.Sleep(10 * time.Millisecond) } }