Unified Agent + reusable Go agent core.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: harden acp callback boundaries

Lyric 720bfaf0 09343342

+577 -98
+22 -6
agent/acp_spawn_tool.go
··· 130 130 } 131 131 switch event.Kind { 132 132 case acpclient.EventKindAgentMessageChunk: 133 - if strings.TrimSpace(event.Text) == "" { 133 + if event.Text == "" { 134 134 return 135 135 } 136 136 EmitEvent(eventCtx, nil, Event{ ··· 148 148 Profile: string(profile), 149 149 Status: normalizeACPEventStatus(event.Status, "running"), 150 150 }) 151 - if strings.TrimSpace(event.Text) != "" { 151 + if event.Text != "" { 152 152 EmitEvent(eventCtx, nil, Event{ 153 153 Kind: EventKindToolOutput, 154 154 ToolName: acpToolDisplayName(event), ··· 159 159 }) 160 160 } 161 161 case acpclient.EventKindToolCallUpdate: 162 - if strings.TrimSpace(event.Text) == "" { 162 + if event.Text == "" { 163 163 return 164 164 } 165 165 EmitEvent(eventCtx, nil, Event{ ··· 213 213 func subtaskResultFromACPResult(taskID string, outputSchema string, result acpclient.RunResult) *SubtaskResult { 214 214 stopReason := strings.TrimSpace(result.StopReason) 215 215 if stopReason == "end_turn" { 216 - return SubtaskResultFromFinal(taskID, outputSchema, &Final{Output: strings.TrimSpace(result.Output)}) 216 + if strings.TrimSpace(outputSchema) != "" { 217 + return SubtaskResultFromFinal(taskID, outputSchema, &Final{Output: result.Output}) 218 + } 219 + out := result.Output 220 + res := &SubtaskResult{ 221 + TaskID: strings.TrimSpace(taskID), 222 + Status: SubtaskStatusDone, 223 + Summary: "subtask completed", 224 + OutputKind: SubtaskOutputKindText, 225 + OutputSchema: "", 226 + Output: out, 227 + Error: "", 228 + } 229 + if summary := summarizeSubtaskText(out); summary != "" { 230 + res.Summary = summary 231 + } 232 + return res 217 233 } 218 234 msg := "acp stop reason: " + stopReason 219 235 if stopReason == "" { 220 236 msg = "acp task failed" 221 237 } 222 238 out := FailedSubtaskResult(taskID, fmt.Errorf("%s", msg)) 223 - out.Output = strings.TrimSpace(result.Output) 224 - if strings.TrimSpace(out.Output.(string)) != "" { 239 + out.Output = result.Output 240 + if strings.TrimSpace(result.Output) != "" { 225 241 out.OutputKind = SubtaskOutputKindText 226 242 } 227 243 if strings.TrimSpace(outputSchema) != "" {
+48
agent/acp_spawn_tool_test.go
··· 91 91 } 92 92 } 93 93 94 + func TestACPSpawnTool_PreservesWhitespaceOutput(t *testing.T) { 95 + t.Parallel() 96 + 97 + dir := t.TempDir() 98 + runner := &execDirectSubtaskRunner{} 99 + 100 + tool := newACPSpawnTool(acpSpawnToolDeps{ 101 + LookupAgent: func(name string) (acpclient.AgentConfig, bool) { 102 + if name != "codex" { 103 + return acpclient.AgentConfig{}, false 104 + } 105 + return acpclient.AgentConfig{ 106 + Name: "codex", 107 + Enable: true, 108 + Type: "stdio", 109 + Command: "helper", 110 + CWD: dir, 111 + ReadRoots: []string{"."}, 112 + WriteRoots: []string{"."}, 113 + }, true 114 + }, 115 + Runner: runner, 116 + RunPrompt: func(_ context.Context, cfg acpclient.PreparedAgentConfig, req acpclient.RunRequest) (acpclient.RunResult, error) { 117 + return acpclient.RunResult{ 118 + SessionID: "sess_1", 119 + StopReason: "end_turn", 120 + Output: " hello \n", 121 + }, nil 122 + }, 123 + }) 124 + 125 + raw, err := tool.Execute(context.Background(), map[string]any{ 126 + "agent": "codex", 127 + "task": "echo exactly", 128 + }) 129 + if err != nil { 130 + t.Fatalf("Execute() error = %v", err) 131 + } 132 + 133 + var result SubtaskResult 134 + if err := json.Unmarshal([]byte(raw), &result); err != nil { 135 + t.Fatalf("json.Unmarshal(result) error = %v", err) 136 + } 137 + if got, _ := result.Output.(string); got != " hello \n" { 138 + t.Fatalf("result.Output = %q, want %q", got, " hello \n") 139 + } 140 + } 141 + 94 142 func TestACPSpawnTool_CanBeDisabled(t *testing.T) { 95 143 t.Parallel() 96 144
+115 -34
internal/acpclient/client.go
··· 1 1 package acpclient 2 2 3 3 import ( 4 + "bufio" 4 5 "bytes" 5 6 "context" 6 7 "encoding/json" ··· 696 697 if s == nil { 697 698 return 698 699 } 699 - if event.Kind == EventKindAgentMessageChunk && strings.TrimSpace(event.Text) != "" { 700 + if event.Kind == EventKindAgentMessageChunk && event.Text != "" { 700 701 s.mu.Lock() 701 702 s.outputs = append(s.outputs, event.Text) 702 703 s.mu.Unlock() ··· 727 728 } 728 729 s.mu.Lock() 729 730 defer s.mu.Unlock() 730 - return strings.TrimSpace(strings.Join(s.outputs, "")) 731 + return strings.Join(s.outputs, "") 731 732 } 732 733 733 734 func handleIncomingRequest(ctx context.Context, cfg PreparedAgentConfig, sessionID string, terminals *terminalManager, msg rpcMessage) (any, *rpcError) { ··· 784 785 return 785 786 } 786 787 text := extractText(update.Content) 787 - if strings.TrimSpace(text) == "" { 788 + if text == "" { 788 789 return 789 790 } 790 791 state.emit(ctx, Event{ ··· 900 901 if err != nil { 901 902 return nil, &rpcError{Code: rpcCodeInvalidParams, Message: err.Error()} 902 903 } 903 - data, err := os.ReadFile(path) 904 + content, err := readTextFileContent(path, req.Line, req.Limit) 904 905 if err != nil { 905 906 return nil, &rpcError{Code: rpcCodeInternalError, Message: err.Error()} 906 907 } 907 908 return map[string]any{ 908 - "content": sliceLines(string(data), req.Line, req.Limit), 909 + "content": content, 909 910 }, nil 910 911 } 911 912 ··· 946 947 if err != nil { 947 948 return "", err 948 949 } 950 + resolvedPath, err := resolveRealPath(absPath) 951 + if err != nil { 952 + return "", err 953 + } 949 954 for _, root := range roots { 950 955 root = strings.TrimSpace(root) 951 956 if root == "" { ··· 955 960 if err != nil { 956 961 continue 957 962 } 958 - if isWithinRoot(absRoot, absPath) { 959 - return absPath, nil 963 + resolvedRoot, err := resolveRealPath(absRoot) 964 + if err != nil { 965 + continue 966 + } 967 + if isWithinRoot(resolvedRoot, resolvedPath) { 968 + return resolvedPath, nil 960 969 } 961 970 } 962 - return "", fmt.Errorf("path %q is outside allowed roots", absPath) 971 + return "", fmt.Errorf("path %q is outside allowed roots", resolvedPath) 963 972 } 964 973 965 974 func isWithinRoot(root string, target string) bool { ··· 975 984 return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) 976 985 } 977 986 978 - func sliceLines(content string, line int, limit int) string { 979 - if line <= 1 && limit <= 0 { 980 - return content 981 - } 982 - lines := strings.SplitAfter(content, "\n") 983 - if len(lines) == 1 && !strings.Contains(content, "\n") { 984 - lines = []string{content} 985 - } 986 - start := 0 987 - if line > 1 { 988 - start = line - 1 989 - if start >= len(lines) { 990 - return "" 991 - } 992 - } 993 - end := len(lines) 994 - if limit > 0 && start+limit < end { 995 - end = start + limit 996 - } 997 - return strings.Join(lines[start:end], "") 998 - } 999 - 1000 987 func extractText(value any) string { 1001 988 var parts []string 1002 989 collectText(&parts, value) 1003 - return strings.TrimSpace(strings.Join(parts, "\n")) 990 + return strings.Join(parts, "\n") 1004 991 } 1005 992 1006 993 func collectText(parts *[]string, value any) { ··· 1008 995 case nil: 1009 996 return 1010 997 case string: 1011 - text := strings.TrimSpace(v) 998 + text := v 1012 999 if text != "" { 1013 1000 *parts = append(*parts, text) 1014 1001 } ··· 1018 1005 } 1019 1006 case map[string]any: 1020 1007 if typ, _ := v["type"].(string); strings.EqualFold(strings.TrimSpace(typ), "text") { 1021 - if text, _ := v["text"].(string); strings.TrimSpace(text) != "" { 1022 - *parts = append(*parts, strings.TrimSpace(text)) 1008 + if text, _ := v["text"].(string); text != "" { 1009 + *parts = append(*parts, text) 1023 1010 } 1024 1011 } 1025 1012 if content, ok := v["content"]; ok { ··· 1057 1044 1058 1045 var timeNowFunc = func() time.Time { 1059 1046 return time.Now() 1047 + } 1048 + 1049 + func readTextFileContent(path string, line int, limit int) (string, error) { 1050 + if line <= 1 && limit <= 0 { 1051 + data, err := os.ReadFile(path) 1052 + if err != nil { 1053 + return "", err 1054 + } 1055 + return string(data), nil 1056 + } 1057 + 1058 + file, err := os.Open(path) 1059 + if err != nil { 1060 + return "", err 1061 + } 1062 + defer func() { _ = file.Close() }() 1063 + 1064 + if line < 1 { 1065 + line = 1 1066 + } 1067 + reader := bufio.NewReader(file) 1068 + currentLine := 1 1069 + remaining := limit 1070 + var out strings.Builder 1071 + 1072 + for { 1073 + chunk, readErr := reader.ReadString('\n') 1074 + if chunk != "" { 1075 + if currentLine >= line && (limit <= 0 || remaining > 0) { 1076 + out.WriteString(chunk) 1077 + if limit > 0 { 1078 + remaining-- 1079 + if remaining == 0 { 1080 + return out.String(), nil 1081 + } 1082 + } 1083 + } 1084 + currentLine++ 1085 + } 1086 + if readErr == io.EOF { 1087 + return out.String(), nil 1088 + } 1089 + if readErr != nil { 1090 + return "", readErr 1091 + } 1092 + } 1093 + } 1094 + 1095 + func resolveRealPath(path string) (string, error) { 1096 + absPath, err := filepath.Abs(path) 1097 + if err != nil { 1098 + return "", err 1099 + } 1100 + resolved, err := filepath.EvalSymlinks(absPath) 1101 + if err == nil { 1102 + return filepath.Clean(resolved), nil 1103 + } 1104 + if !os.IsNotExist(err) { 1105 + return "", err 1106 + } 1107 + base, tail, err := resolveExistingAncestor(absPath) 1108 + if err != nil { 1109 + return "", err 1110 + } 1111 + resolvedBase, err := filepath.EvalSymlinks(base) 1112 + if err != nil { 1113 + return "", err 1114 + } 1115 + resolved = resolvedBase 1116 + for _, part := range tail { 1117 + resolved = filepath.Join(resolved, part) 1118 + } 1119 + return filepath.Clean(resolved), nil 1120 + } 1121 + 1122 + func resolveExistingAncestor(path string) (string, []string, error) { 1123 + current := filepath.Clean(path) 1124 + suffix := make([]string, 0, 4) 1125 + for { 1126 + if _, statErr := os.Lstat(current); statErr == nil { 1127 + for left, right := 0, len(suffix)-1; left < right; left, right = left+1, right-1 { 1128 + suffix[left], suffix[right] = suffix[right], suffix[left] 1129 + } 1130 + return current, suffix, nil 1131 + } else if !os.IsNotExist(statErr) { 1132 + return "", nil, statErr 1133 + } 1134 + parent := filepath.Dir(current) 1135 + if parent == current { 1136 + return "", nil, os.ErrNotExist 1137 + } 1138 + suffix = append(suffix, filepath.Base(current)) 1139 + current = parent 1140 + } 1060 1141 } 1061 1142 1062 1143 func cloneMeta(in map[string]any) map[string]any {
+94
internal/acpclient/client_test.go
··· 579 579 } 580 580 } 581 581 582 + func TestRunPrompt_PreservesWhitespaceInAgentChunks(t *testing.T) { 583 + t.Parallel() 584 + 585 + dir := t.TempDir() 586 + cfg := AgentConfig{ 587 + Name: "helper", 588 + Enable: true, 589 + Type: "stdio", 590 + Command: "helper", 591 + CWD: dir, 592 + ReadRoots: []string{dir}, 593 + WriteRoots: []string{dir}, 594 + } 595 + prepared, err := PrepareAgentConfig(cfg, "") 596 + if err != nil { 597 + t.Fatalf("PrepareAgentConfig() error = %v", err) 598 + } 599 + 600 + var events []Event 601 + result, err := runPromptWithFactory(context.Background(), prepared, RunRequest{ 602 + Prompt: "preserve spacing", 603 + Observer: ObserverFunc(func(_ context.Context, event Event) { 604 + events = append(events, event) 605 + }), 606 + }, fakeACPConnFactory(t, func(dec *json.Decoder, enc *json.Encoder) { 607 + initMsg := decodeTestMessage(t, dec) 608 + encodeTestResponse(t, enc, initMsg.ID, map[string]any{"protocolVersion": protocolVersion}) 609 + 610 + newMsg := decodeTestMessage(t, dec) 611 + encodeTestResponse(t, enc, newMsg.ID, map[string]any{"sessionId": "sess_space"}) 612 + 613 + promptMsg := decodeTestMessage(t, dec) 614 + if promptMsg.Method != methodSessionPrompt { 615 + t.Fatalf("method = %q, want %q", promptMsg.Method, methodSessionPrompt) 616 + } 617 + 618 + for _, chunk := range []string{"Hello", " ", "world\n"} { 619 + encodeTestNotification(t, enc, methodSessionUpdate, map[string]any{ 620 + "sessionId": "sess_space", 621 + "update": map[string]any{ 622 + "sessionUpdate": "agent_message_chunk", 623 + "content": []map[string]any{ 624 + {"type": "text", "text": chunk}, 625 + }, 626 + }, 627 + }) 628 + } 629 + encodeTestResponse(t, enc, promptMsg.ID, map[string]any{"stopReason": "end_turn"}) 630 + })) 631 + if err != nil { 632 + t.Fatalf("RunPrompt() error = %v", err) 633 + } 634 + if result.Output != "Hello world\n" { 635 + t.Fatalf("Output = %q, want %q", result.Output, "Hello world\n") 636 + } 637 + if len(events) != 3 { 638 + t.Fatalf("events len = %d, want 3", len(events)) 639 + } 640 + if events[1].Text != " " { 641 + t.Fatalf("events[1].Text = %q, want single space", events[1].Text) 642 + } 643 + } 644 + 645 + func TestResolveAllowedPath_RejectsSymlinkEscape(t *testing.T) { 646 + t.Parallel() 647 + 648 + if runtime.GOOS == "windows" { 649 + t.Skip("symlink behavior varies on windows") 650 + } 651 + 652 + root := t.TempDir() 653 + allowed := filepath.Join(root, "allowed") 654 + outside := filepath.Join(root, "outside") 655 + if err := os.MkdirAll(allowed, 0o755); err != nil { 656 + t.Fatalf("MkdirAll(allowed) error = %v", err) 657 + } 658 + if err := os.MkdirAll(outside, 0o755); err != nil { 659 + t.Fatalf("MkdirAll(outside) error = %v", err) 660 + } 661 + 662 + target := filepath.Join(outside, "secret.txt") 663 + if err := os.WriteFile(target, []byte("secret"), 0o644); err != nil { 664 + t.Fatalf("WriteFile(target) error = %v", err) 665 + } 666 + link := filepath.Join(allowed, "secret.txt") 667 + if err := os.Symlink(target, link); err != nil { 668 + t.Skipf("Symlink() unavailable: %v", err) 669 + } 670 + 671 + if _, err := resolveAllowedPath(link, []string{allowed}); err == nil { 672 + t.Fatal("resolveAllowedPath() error = nil, want outside allowed roots") 673 + } 674 + } 675 + 582 676 func TestRunPrompt_AuthenticatesWithChatGPTFallback(t *testing.T) { 583 677 t.Parallel() 584 678
+56 -17
internal/acpclient/config.go
··· 29 29 Command string 30 30 Args []string 31 31 Env map[string]string 32 + ProfileCWD string 32 33 CWD string 33 34 ReadRoots []string 34 35 WriteRoots []string ··· 87 88 return PreparedAgentConfig{}, fmt.Errorf("acp agent %q is disabled", strings.TrimSpace(cfg.Name)) 88 89 } 89 90 90 - cwd := strings.TrimSpace(overrideCWD) 91 - if cwd == "" { 92 - cwd = strings.TrimSpace(cfg.CWD) 93 - } 94 - if cwd == "" { 95 - cwd = "." 96 - } 97 - resolvedCWD, err := filepath.Abs(cwd) 91 + profileCWD, err := resolveAbsoluteDir(strings.TrimSpace(cfg.CWD), "") 98 92 if err != nil { 99 93 return PreparedAgentConfig{}, fmt.Errorf("resolve acp cwd: %w", err) 100 94 } 101 - info, err := os.Stat(resolvedCWD) 102 - if err != nil { 103 - return PreparedAgentConfig{}, fmt.Errorf("stat acp cwd: %w", err) 104 - } 105 - if !info.IsDir() { 106 - return PreparedAgentConfig{}, fmt.Errorf("acp cwd %q is not a directory", resolvedCWD) 107 - } 108 95 109 - readRoots, err := resolveRoots(resolvedCWD, cfg.ReadRoots) 96 + readRoots, err := resolveRoots(profileCWD, cfg.ReadRoots) 110 97 if err != nil { 111 98 return PreparedAgentConfig{}, err 112 99 } 113 - writeRoots, err := resolveRoots(resolvedCWD, cfg.WriteRoots) 100 + writeRoots, err := resolveRoots(profileCWD, cfg.WriteRoots) 114 101 if err != nil { 115 102 return PreparedAgentConfig{}, err 116 103 } 104 + allowedRoots := collectAllowedRoots(profileCWD, readRoots, writeRoots) 105 + 106 + resolvedCWD := profileCWD 107 + if strings.TrimSpace(overrideCWD) != "" { 108 + resolvedCWD, err = resolveAbsoluteDir(strings.TrimSpace(overrideCWD), profileCWD) 109 + if err != nil { 110 + return PreparedAgentConfig{}, fmt.Errorf("resolve acp cwd: %w", err) 111 + } 112 + } 113 + if _, err := resolveAllowedPath(resolvedCWD, allowedRoots); err != nil { 114 + return PreparedAgentConfig{}, fmt.Errorf("resolve acp cwd: %w", err) 115 + } 117 116 118 117 return PreparedAgentConfig{ 119 118 Name: strings.TrimSpace(cfg.Name), ··· 121 120 Command: strings.TrimSpace(cfg.Command), 122 121 Args: append([]string(nil), cfg.Args...), 123 122 Env: cloneStringMap(cfg.Env), 123 + ProfileCWD: profileCWD, 124 124 CWD: resolvedCWD, 125 125 ReadRoots: readRoots, 126 126 WriteRoots: writeRoots, ··· 199 199 continue 200 200 } 201 201 out = append(out, value) 202 + } 203 + return out 204 + } 205 + 206 + func resolveAbsoluteDir(raw string, relativeBase string) (string, error) { 207 + path := strings.TrimSpace(raw) 208 + if path == "" { 209 + path = "." 210 + } 211 + if !filepath.IsAbs(path) && strings.TrimSpace(relativeBase) != "" { 212 + path = filepath.Join(relativeBase, path) 213 + } 214 + absPath, err := filepath.Abs(path) 215 + if err != nil { 216 + return "", err 217 + } 218 + info, err := os.Stat(absPath) 219 + if err != nil { 220 + return "", err 221 + } 222 + if !info.IsDir() { 223 + return "", fmt.Errorf("acp cwd %q is not a directory", absPath) 224 + } 225 + return absPath, nil 226 + } 227 + 228 + func collectAllowedRoots(profileCWD string, readRoots []string, writeRoots []string) []string { 229 + seen := map[string]struct{}{} 230 + out := make([]string, 0, 1+len(readRoots)+len(writeRoots)) 231 + for _, root := range append([]string{profileCWD}, append(readRoots, writeRoots...)...) { 232 + root = strings.TrimSpace(root) 233 + if root == "" { 234 + continue 235 + } 236 + if _, ok := seen[root]; ok { 237 + continue 238 + } 239 + seen[root] = struct{}{} 240 + out = append(out, root) 202 241 } 203 242 return out 204 243 }
+66
internal/acpclient/config_test.go
··· 1 1 package acpclient 2 2 3 3 import ( 4 + "os" 4 5 "path/filepath" 5 6 "testing" 6 7 ) ··· 33 34 t.Fatalf("prepared.WriteRoots[0] = %q, want %q", got, filepath.Join(base, "out")) 34 35 } 35 36 } 37 + 38 + func TestPrepareAgentConfig_OverrideCWDDoesNotReanchorRelativeRoots(t *testing.T) { 39 + t.Parallel() 40 + 41 + base := t.TempDir() 42 + override := filepath.Join(base, "worktrees", "child") 43 + if err := os.MkdirAll(override, 0o755); err != nil { 44 + t.Fatalf("MkdirAll(override) error = %v", err) 45 + } 46 + 47 + cfg := AgentConfig{ 48 + Name: "codex", 49 + Enable: true, 50 + Type: "stdio", 51 + Command: "helper", 52 + CWD: base, 53 + ReadRoots: []string{"src"}, 54 + WriteRoots: []string{"out"}, 55 + } 56 + 57 + prepared, err := PrepareAgentConfig(cfg, "worktrees/child") 58 + if err != nil { 59 + t.Fatalf("PrepareAgentConfig() error = %v", err) 60 + } 61 + if prepared.ProfileCWD != base { 62 + t.Fatalf("prepared.ProfileCWD = %q, want %q", prepared.ProfileCWD, base) 63 + } 64 + if prepared.CWD != override { 65 + t.Fatalf("prepared.CWD = %q, want %q", prepared.CWD, override) 66 + } 67 + if got := prepared.ReadRoots[0]; got != filepath.Join(base, "src") { 68 + t.Fatalf("prepared.ReadRoots[0] = %q, want %q", got, filepath.Join(base, "src")) 69 + } 70 + if got := prepared.WriteRoots[0]; got != filepath.Join(base, "out") { 71 + t.Fatalf("prepared.WriteRoots[0] = %q, want %q", got, filepath.Join(base, "out")) 72 + } 73 + } 74 + 75 + func TestPrepareAgentConfig_RejectsOverrideOutsideAllowedRoots(t *testing.T) { 76 + t.Parallel() 77 + 78 + root := t.TempDir() 79 + base := filepath.Join(root, "profile") 80 + outside := filepath.Join(root, "outside") 81 + if err := os.MkdirAll(base, 0o755); err != nil { 82 + t.Fatalf("MkdirAll(base) error = %v", err) 83 + } 84 + if err := os.MkdirAll(outside, 0o755); err != nil { 85 + t.Fatalf("MkdirAll(outside) error = %v", err) 86 + } 87 + 88 + cfg := AgentConfig{ 89 + Name: "codex", 90 + Enable: true, 91 + Type: "stdio", 92 + Command: "helper", 93 + CWD: base, 94 + ReadRoots: []string{"."}, 95 + WriteRoots: []string{"."}, 96 + } 97 + 98 + if _, err := PrepareAgentConfig(cfg, outside); err == nil { 99 + t.Fatal("PrepareAgentConfig() error = nil, want outside allowed roots") 100 + } 101 + }
+17 -27
internal/acpclient/terminal.go
··· 22 22 methodTerminalKill = "terminal/kill" 23 23 methodTerminalRelease = "terminal/release" 24 24 defaultTerminalOutputSize = 256 * 1024 25 + maxTerminalOutputSize = 1024 * 1024 25 26 ) 26 27 27 28 type terminalManager struct { ··· 111 112 if err != nil { 112 113 return nil, &rpcError{Code: rpcCodeInvalidParams, Message: err.Error()} 113 114 } 114 - outputLimit := req.OutputByteLimit 115 - if outputLimit <= 0 { 116 - outputLimit = defaultTerminalOutputSize 117 - } 115 + outputLimit := clampTerminalOutputLimit(req.OutputByteLimit) 118 116 119 117 cmd := exec.Command(command, cleanStrings(req.Args)...) 120 118 cmd.Dir = cwd ··· 385 383 if !info.IsDir() { 386 384 return "", fmt.Errorf("cwd %q is not a directory", absCWD) 387 385 } 388 - for _, root := range terminalAllowedRoots(cfg) { 389 - if isWithinRoot(root, absCWD) { 390 - return absCWD, nil 391 - } 386 + resolvedCWD, err := resolveAllowedPath(absCWD, terminalAllowedRoots(cfg)) 387 + if err != nil { 388 + return "", err 392 389 } 393 - return "", fmt.Errorf("cwd %q is outside allowed roots", absCWD) 390 + return resolvedCWD, nil 394 391 } 395 392 396 393 func terminalAllowedRoots(cfg PreparedAgentConfig) []string { 397 - seen := map[string]struct{}{} 398 - var roots []string 399 - for _, root := range append([]string{cfg.CWD}, append(cfg.ReadRoots, cfg.WriteRoots...)...) { 400 - root = strings.TrimSpace(root) 401 - if root == "" { 402 - continue 403 - } 404 - absRoot, err := filepath.Abs(root) 405 - if err != nil { 406 - continue 407 - } 408 - if _, ok := seen[absRoot]; ok { 409 - continue 410 - } 411 - seen[absRoot] = struct{}{} 412 - roots = append(roots, absRoot) 413 - } 414 - return roots 394 + return collectAllowedRoots(cfg.ProfileCWD, cfg.ReadRoots, cfg.WriteRoots) 415 395 } 416 396 417 397 func mergeTerminalEnv(extra []terminalEnvVar) []string { ··· 463 443 } 464 444 return &value 465 445 } 446 + 447 + func clampTerminalOutputLimit(requested int) int { 448 + if requested <= 0 { 449 + return defaultTerminalOutputSize 450 + } 451 + if requested > maxTerminalOutputSize { 452 + return maxTerminalOutputSize 453 + } 454 + return requested 455 + }
+55
internal/acpclient/terminal_test.go
··· 1 + package acpclient 2 + 3 + import ( 4 + "os" 5 + "path/filepath" 6 + "runtime" 7 + "testing" 8 + ) 9 + 10 + func TestClampTerminalOutputLimit(t *testing.T) { 11 + t.Parallel() 12 + 13 + if got := clampTerminalOutputLimit(0); got != defaultTerminalOutputSize { 14 + t.Fatalf("clampTerminalOutputLimit(0) = %d, want %d", got, defaultTerminalOutputSize) 15 + } 16 + if got := clampTerminalOutputLimit(defaultTerminalOutputSize / 2); got != defaultTerminalOutputSize/2 { 17 + t.Fatalf("clampTerminalOutputLimit(small) = %d, want %d", got, defaultTerminalOutputSize/2) 18 + } 19 + if got := clampTerminalOutputLimit(maxTerminalOutputSize * 4); got != maxTerminalOutputSize { 20 + t.Fatalf("clampTerminalOutputLimit(huge) = %d, want %d", got, maxTerminalOutputSize) 21 + } 22 + } 23 + 24 + func TestResolveTerminalCWD_RejectsSymlinkEscape(t *testing.T) { 25 + t.Parallel() 26 + 27 + if runtime.GOOS == "windows" { 28 + t.Skip("symlink behavior varies on windows") 29 + } 30 + 31 + root := t.TempDir() 32 + allowed := filepath.Join(root, "allowed") 33 + outside := filepath.Join(root, "outside") 34 + if err := os.MkdirAll(allowed, 0o755); err != nil { 35 + t.Fatalf("MkdirAll(allowed) error = %v", err) 36 + } 37 + if err := os.MkdirAll(outside, 0o755); err != nil { 38 + t.Fatalf("MkdirAll(outside) error = %v", err) 39 + } 40 + 41 + escape := filepath.Join(allowed, "escape") 42 + if err := os.Symlink(outside, escape); err != nil { 43 + t.Skipf("Symlink() unavailable: %v", err) 44 + } 45 + 46 + cfg := PreparedAgentConfig{ 47 + ProfileCWD: allowed, 48 + CWD: allowed, 49 + ReadRoots: []string{allowed}, 50 + WriteRoots: []string{allowed}, 51 + } 52 + if _, err := resolveTerminalCWD(escape, cfg); err == nil { 53 + t.Fatal("resolveTerminalCWD() error = nil, want outside allowed roots") 54 + } 55 + }
+11 -7
wrappers/acp/claude/src/lib.mjs
··· 59 59 if (item.type !== "text" || typeof item.text !== "string") { 60 60 continue; 61 61 } 62 - const text = item.text.trim(); 62 + const text = item.text; 63 63 if (text !== "") { 64 64 parts.push(text); 65 65 } 66 66 } 67 - return parts.join("\n").trim(); 67 + return parts.join("\n"); 68 68 } 69 69 70 70 export function buildBackendArgs() { ··· 137 137 } 138 138 139 139 if (normalizeString(event.type) === "result") { 140 - const resultText = normalizeString(event.result); 140 + const resultText = stringOrEmpty(event.result); 141 141 const delta = computeTextDelta(state.emittedText, resultText); 142 142 if (delta !== "") { 143 143 state.emittedText += delta; ··· 373 373 ); 374 374 } 375 375 const prompt = collectACPText(payload.prompt); 376 - if (prompt === "") { 376 + if (prompt.trim() === "") { 377 377 throw new JsonRpcFailure( 378 378 RPC_INVALID_PARAMS, 379 379 "session/prompt requires text content" ··· 544 544 if (item.type !== "text" || typeof item.text !== "string") { 545 545 continue; 546 546 } 547 - const text = item.text.trim(); 547 + const text = item.text; 548 548 if (text !== "") { 549 549 parts.push(text); 550 550 } 551 551 } 552 - return parts.join("\n").trim(); 552 + return parts.join("\n"); 553 553 } 554 554 555 555 function extractStreamTextDelta(event) { ··· 558 558 } 559 559 const delta = isRecord(event.delta) ? event.delta : null; 560 560 if (delta && normalizeString(delta.type).toLowerCase() === "text_delta") { 561 - return normalizeString(delta.text); 561 + return stringOrEmpty(delta.text); 562 562 } 563 563 return ""; 564 564 } ··· 622 622 623 623 function normalizeString(value) { 624 624 return typeof value === "string" ? value.trim() : ""; 625 + } 626 + 627 + function stringOrEmpty(value) { 628 + return typeof value === "string" ? value : ""; 625 629 } 626 630 627 631 function normalizeToolList(value) {
+32
wrappers/acp/claude/test/lib.test.mjs
··· 34 34 assert.equal(text, "hello\nworld"); 35 35 }); 36 36 37 + test("collectACPText preserves surrounding whitespace", () => { 38 + const text = collectACPText([{ type: "text", text: " hello \n" }]); 39 + 40 + assert.equal(text, " hello \n"); 41 + }); 42 + 37 43 test("buildClaudePromptFlags includes streaming and permission flags", () => { 38 44 const args = buildClaudePromptFlags("Say Hello", { 39 45 permissionMode: "dontAsk", ··· 119 125 assert.equal(outcome.updates.length, 1); 120 126 assert.match(outcome.error.message, /authentication failed/); 121 127 }); 128 + 129 + test("processClaudeEvent preserves whitespace deltas", () => { 130 + const state = createPromptState(); 131 + 132 + const partial = processClaudeEvent( 133 + { 134 + type: "stream_event", 135 + event: { 136 + delta: { type: "text_delta", text: " " } 137 + } 138 + }, 139 + state 140 + ); 141 + assert.equal(partial.updates[0].content[0].text, " "); 142 + 143 + const assistant = processClaudeEvent( 144 + { 145 + type: "assistant", 146 + message: { 147 + content: [{ type: "text", text: " world\n" }] 148 + } 149 + }, 150 + state 151 + ); 152 + assert.equal(assistant.updates[0].content[0].text, "world\n"); 153 + });
+10 -7
wrappers/acp/codex/src/lib.mjs
··· 68 68 if (typeof item.text !== "string") { 69 69 continue; 70 70 } 71 - const text = item.text.trim(); 71 + const text = item.text; 72 72 if (text !== "") { 73 73 parts.push(text); 74 74 } 75 75 } 76 - return parts.join("\n").trim(); 76 + return parts.join("\n"); 77 77 } 78 78 79 79 export function mapTurnOutcome(turn) { ··· 505 505 ); 506 506 } 507 507 const prompt = collectACPText(payload.prompt); 508 - if (prompt === "") { 508 + if (prompt.trim() === "") { 509 509 throw new JsonRpcFailure(RPC_INVALID_PARAMS, "session/prompt requires text content"); 510 510 } 511 511 ··· 573 573 if (!shouldEmitAgentMessagePhase(phase)) { 574 574 return; 575 575 } 576 - const delta = normalizeString(params.delta); 576 + const delta = stringOrEmpty(params.delta); 577 577 if (delta !== "") { 578 578 this.#notifySessionUpdate(session.sessionId, { 579 579 sessionUpdate: "agent_message_chunk", ··· 858 858 return typeof value === "string" ? value.trim() : ""; 859 859 } 860 860 861 + function stringOrEmpty(value) { 862 + return typeof value === "string" ? value : ""; 863 + } 864 + 861 865 function pickBoolean(source, key, fallback) { 862 866 const value = source[key]; 863 867 return typeof value === "boolean" ? value : fallback; ··· 906 910 } 907 911 908 912 function textContent(text) { 909 - const clean = typeof text === "string" ? text.trim() : ""; 910 - if (clean === "") { 913 + if (typeof text !== "string" || text === "") { 911 914 return []; 912 915 } 913 - return [{ type: "text", text: clean }]; 916 + return [{ type: "text", text }]; 914 917 } 915 918 916 919 function turnMatches(expected, actual) {
+51
wrappers/acp/codex/test/lib.test.mjs
··· 1 1 import test from "node:test"; 2 2 import assert from "node:assert/strict"; 3 + import { PassThrough } from "node:stream"; 3 4 4 5 import { 6 + CodexACPServer, 5 7 buildToolDoneUpdate, 6 8 buildToolProgressUpdate, 7 9 buildToolStartUpdate, ··· 33 35 ]); 34 36 35 37 assert.equal(text, "hello\nworld"); 38 + }); 39 + 40 + test("collectACPText preserves surrounding whitespace", () => { 41 + const text = collectACPText([{ type: "text", text: " hello \n" }]); 42 + 43 + assert.equal(text, " hello \n"); 36 44 }); 37 45 38 46 test("mapTurnOutcome maps completed and interrupted turns", () => { ··· 83 91 }); 84 92 assert.equal(done.sessionUpdate, "tool_call_update"); 85 93 assert.equal(done.status, "completed"); 94 + }); 95 + 96 + test("tool update builders preserve whitespace deltas", () => { 97 + const update = buildToolProgressUpdate("item/commandExecution/outputDelta", { 98 + itemId: "cmd-1", 99 + delta: " hello \n", 100 + }); 101 + 102 + assert.equal(update.content[0].text, " hello \n"); 103 + }); 104 + 105 + test("CodexACPServer preserves whitespace-only agent deltas", async () => { 106 + const stdout = new PassThrough(); 107 + let raw = ""; 108 + stdout.on("data", (chunk) => { 109 + raw += chunk.toString(); 110 + }); 111 + 112 + const server = new CodexACPServer({ 113 + stdin: new PassThrough(), 114 + stdout, 115 + }); 116 + server.sessions.set("sess-1", { 117 + sessionId: "sess-1", 118 + threadId: "thread-1", 119 + options: {}, 120 + pendingTurn: null, 121 + itemPhases: new Map([["item-1", "final_answer"]]), 122 + }); 123 + 124 + await server.codex.notificationHandler({ 125 + method: "item/agentMessage/delta", 126 + params: { 127 + threadId: "thread-1", 128 + itemId: "item-1", 129 + delta: " ", 130 + }, 131 + }); 132 + 133 + const lines = raw.trimEnd().split("\n"); 134 + assert.equal(lines.length, 1); 135 + const message = JSON.parse(lines[0]); 136 + assert.equal(message.params.update.content[0].text, " "); 86 137 }); 87 138 88 139 test("shouldEmitAgentMessagePhase suppresses commentary", () => {