Unified Agent + reusable Go agent core.
0
fork

Configure Feed

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

fix: cap acp stderr and file reads

Lyric 941d2205 720bfaf0

+230 -31
+56 -23
internal/acpclient/client.go
··· 41 41 permissionRejectOnce = "reject_once" 42 42 permissionOutcomeSel = "selected" 43 43 permissionOutcomeCancel = "cancelled" 44 + maxRPCStderrBytes = 64 * 1024 45 + maxReadTextFileBytes = 1024 * 1024 44 46 ) 45 47 46 48 type EventKind string ··· 166 168 done chan struct{} 167 169 168 170 stderrMu sync.Mutex 169 - stderrBuf bytes.Buffer 171 + stderrBuf cappedTailBuffer 170 172 } 171 173 172 174 func RunPrompt(ctx context.Context, cfg PreparedAgentConfig, req RunRequest) (RunResult, error) { ··· 291 293 enc: json.NewEncoder(stdin), 292 294 pending: map[string]chan pendingResponse{}, 293 295 done: make(chan struct{}), 296 + stderrBuf: cappedTailBuffer{ 297 + limit: maxRPCStderrBytes, 298 + }, 294 299 } 295 300 go conn.readLoop() 296 301 go conn.drainStderr() ··· 655 660 if c.stderr == nil { 656 661 return 657 662 } 658 - _, _ = io.Copy(&lockedWriter{mu: &c.stderrMu, buf: &c.stderrBuf}, c.stderr) 663 + _, _ = io.Copy(&lockedTailWriter{mu: &c.stderrMu, buf: &c.stderrBuf}, c.stderr) 659 664 } 660 665 661 666 func (c *rpcConn) stderrString() string { ··· 960 965 if err != nil { 961 966 continue 962 967 } 963 - resolvedRoot, err := resolveRealPath(absRoot) 964 - if err != nil { 965 - continue 966 - } 967 - if isWithinRoot(resolvedRoot, resolvedPath) { 968 + if isWithinRoot(filepath.Clean(absRoot), resolvedPath) { 968 969 return resolvedPath, nil 969 970 } 970 971 } ··· 1047 1048 } 1048 1049 1049 1050 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 1051 file, err := os.Open(path) 1059 1052 if err != nil { 1060 1053 return "", err ··· 1070 1063 var out strings.Builder 1071 1064 1072 1065 for { 1073 - chunk, readErr := reader.ReadString('\n') 1074 - if chunk != "" { 1066 + chunk, readErr := reader.ReadSlice('\n') 1067 + if len(chunk) > 0 { 1075 1068 if currentLine >= line && (limit <= 0 || remaining > 0) { 1076 - out.WriteString(chunk) 1077 - if limit > 0 { 1069 + if out.Len()+len(chunk) > maxReadTextFileBytes { 1070 + return "", fmt.Errorf("fs/read_text_file exceeds %d bytes", maxReadTextFileBytes) 1071 + } 1072 + _, _ = out.Write(chunk) 1073 + if limit > 0 && chunk[len(chunk)-1] == '\n' { 1078 1074 remaining-- 1079 1075 if remaining == 0 { 1080 1076 return out.String(), nil 1081 1077 } 1082 1078 } 1083 1079 } 1084 - currentLine++ 1080 + if chunk[len(chunk)-1] == '\n' { 1081 + currentLine++ 1082 + } 1083 + } 1084 + if readErr == bufio.ErrBufferFull { 1085 + continue 1085 1086 } 1086 1087 if readErr == io.EOF { 1087 1088 return out.String(), nil ··· 1158 1159 return out 1159 1160 } 1160 1161 1161 - type lockedWriter struct { 1162 + type lockedTailWriter struct { 1162 1163 mu *sync.Mutex 1163 - buf *bytes.Buffer 1164 + buf *cappedTailBuffer 1164 1165 } 1165 1166 1166 - func (w *lockedWriter) Write(p []byte) (int, error) { 1167 + func (w *lockedTailWriter) Write(p []byte) (int, error) { 1167 1168 if w == nil || w.mu == nil || w.buf == nil { 1168 1169 return len(p), nil 1169 1170 } ··· 1171 1172 defer w.mu.Unlock() 1172 1173 return w.buf.Write(p) 1173 1174 } 1175 + 1176 + type cappedTailBuffer struct { 1177 + limit int 1178 + data []byte 1179 + truncated bool 1180 + } 1181 + 1182 + func (b *cappedTailBuffer) Write(p []byte) (int, error) { 1183 + if b == nil || len(p) == 0 { 1184 + return len(p), nil 1185 + } 1186 + if b.limit <= 0 { 1187 + return len(p), nil 1188 + } 1189 + b.data = append(b.data, p...) 1190 + if len(b.data) > b.limit { 1191 + b.truncated = true 1192 + b.data = append([]byte(nil), b.data[len(b.data)-b.limit:]...) 1193 + } 1194 + return len(p), nil 1195 + } 1196 + 1197 + func (b *cappedTailBuffer) String() string { 1198 + if b == nil || len(b.data) == 0 { 1199 + return "" 1200 + } 1201 + text := string(bytes.ToValidUTF8(b.data, []byte("\n[non-utf8 stderr]\n"))) 1202 + if !b.truncated { 1203 + return text 1204 + } 1205 + return "[stderr truncated]\n" + text 1206 + }
+42
internal/acpclient/client_test.go
··· 10 10 "os" 11 11 "path/filepath" 12 12 "runtime" 13 + "strings" 13 14 "testing" 14 15 "time" 15 16 ) ··· 673 674 } 674 675 } 675 676 677 + func TestReadTextFileContent_RejectsOversizeFile(t *testing.T) { 678 + t.Parallel() 679 + 680 + path := filepath.Join(t.TempDir(), "large.txt") 681 + data := strings.Repeat("a", maxReadTextFileBytes+1) 682 + if err := os.WriteFile(path, []byte(data), 0o644); err != nil { 683 + t.Fatalf("WriteFile(path) error = %v", err) 684 + } 685 + 686 + if _, err := readTextFileContent(path, 1, 0); err == nil { 687 + t.Fatal("readTextFileContent() error = nil, want byte limit") 688 + } 689 + } 690 + 691 + func TestRPCConnStderrString_TruncatesLargeOutput(t *testing.T) { 692 + t.Parallel() 693 + 694 + conn := &rpcConn{ 695 + stderr: io.NopCloser(strings.NewReader(strings.Repeat("x", maxRPCStderrBytes+32))), 696 + stderrBuf: cappedTailBuffer{ 697 + limit: maxRPCStderrBytes, 698 + }, 699 + } 700 + conn.drainStderr() 701 + 702 + got := conn.stderrString() 703 + if !strings.HasPrefix(got, "[stderr truncated]\n") { 704 + t.Fatalf("stderrString() = %q, want truncated prefix", got[:minInt(len(got), 32)]) 705 + } 706 + if len(got) > len("[stderr truncated]\n")+maxRPCStderrBytes { 707 + t.Fatalf("stderrString() len = %d, want <= %d", len(got), len("[stderr truncated]\n")+maxRPCStderrBytes) 708 + } 709 + } 710 + 676 711 func TestRunPrompt_AuthenticatesWithChatGPTFallback(t *testing.T) { 677 712 t.Parallel() 678 713 ··· 834 869 return []string{"-lc", `printf '%s\n' "$MM_TERM_TEST"`} 835 870 } 836 871 } 872 + 873 + func minInt(a int, b int) int { 874 + if a < b { 875 + return a 876 + } 877 + return b 878 + }
+23 -3
internal/acpclient/config.go
··· 92 92 if err != nil { 93 93 return PreparedAgentConfig{}, fmt.Errorf("resolve acp cwd: %w", err) 94 94 } 95 + profileCWD, err = freezePreparedPath(profileCWD) 96 + if err != nil { 97 + return PreparedAgentConfig{}, fmt.Errorf("resolve acp cwd: %w", err) 98 + } 95 99 96 100 readRoots, err := resolveRoots(profileCWD, cfg.ReadRoots) 97 101 if err != nil { ··· 106 110 resolvedCWD := profileCWD 107 111 if strings.TrimSpace(overrideCWD) != "" { 108 112 resolvedCWD, err = resolveAbsoluteDir(strings.TrimSpace(overrideCWD), profileCWD) 113 + if err != nil { 114 + return PreparedAgentConfig{}, fmt.Errorf("resolve acp cwd: %w", err) 115 + } 116 + resolvedCWD, err = freezePreparedPath(resolvedCWD) 109 117 if err != nil { 110 118 return PreparedAgentConfig{}, fmt.Errorf("resolve acp cwd: %w", err) 111 119 } ··· 176 184 if err != nil { 177 185 return nil, fmt.Errorf("resolve acp root %q: %w", raw, err) 178 186 } 179 - if _, ok := seen[absRoot]; ok { 187 + frozenRoot, err := freezePreparedPath(absRoot) 188 + if err != nil { 189 + return nil, fmt.Errorf("resolve acp root %q: %w", raw, err) 190 + } 191 + if _, ok := seen[frozenRoot]; ok { 180 192 continue 181 193 } 182 - seen[absRoot] = struct{}{} 183 - out = append(out, absRoot) 194 + seen[frozenRoot] = struct{}{} 195 + out = append(out, frozenRoot) 184 196 } 185 197 if len(out) == 0 { 186 198 return []string{cwd}, nil ··· 223 235 return "", fmt.Errorf("acp cwd %q is not a directory", absPath) 224 236 } 225 237 return absPath, nil 238 + } 239 + 240 + func freezePreparedPath(path string) (string, error) { 241 + resolved, err := resolveRealPath(path) 242 + if err != nil { 243 + return "", err 244 + } 245 + return filepath.Clean(resolved), nil 226 246 } 227 247 228 248 func collectAllowedRoots(profileCWD string, readRoots []string, writeRoots []string) []string {
+46
internal/acpclient/config_test.go
··· 3 3 import ( 4 4 "os" 5 5 "path/filepath" 6 + "runtime" 6 7 "testing" 7 8 ) 8 9 ··· 99 100 t.Fatal("PrepareAgentConfig() error = nil, want outside allowed roots") 100 101 } 101 102 } 103 + 104 + func TestPrepareAgentConfig_FreezesMissingRootBoundary(t *testing.T) { 105 + t.Parallel() 106 + 107 + if runtime.GOOS == "windows" { 108 + t.Skip("symlink behavior varies on windows") 109 + } 110 + 111 + root := t.TempDir() 112 + base := filepath.Join(root, "profile") 113 + outside := filepath.Join(root, "outside") 114 + if err := os.MkdirAll(base, 0o755); err != nil { 115 + t.Fatalf("MkdirAll(base) error = %v", err) 116 + } 117 + if err := os.MkdirAll(outside, 0o755); err != nil { 118 + t.Fatalf("MkdirAll(outside) error = %v", err) 119 + } 120 + 121 + cfg := AgentConfig{ 122 + Name: "codex", 123 + Enable: true, 124 + Type: "stdio", 125 + Command: "helper", 126 + CWD: base, 127 + ReadRoots: []string{"sandbox"}, 128 + WriteRoots: []string{"sandbox"}, 129 + } 130 + prepared, err := PrepareAgentConfig(cfg, "") 131 + if err != nil { 132 + t.Fatalf("PrepareAgentConfig() error = %v", err) 133 + } 134 + 135 + lateRoot := filepath.Join(base, "sandbox") 136 + if err := os.Symlink(outside, lateRoot); err != nil { 137 + t.Skipf("Symlink() unavailable: %v", err) 138 + } 139 + target := filepath.Join(lateRoot, "secret.txt") 140 + if err := os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("secret"), 0o644); err != nil { 141 + t.Fatalf("WriteFile(secret) error = %v", err) 142 + } 143 + 144 + if _, err := resolveAllowedPath(target, prepared.ReadRoots); err == nil { 145 + t.Fatal("resolveAllowedPath() error = nil, want frozen root boundary") 146 + } 147 + }
+50 -4
wrappers/acp/claude/src/lib.mjs
··· 19 19 const RPC_INVALID_PARAMS = -32602; 20 20 const RPC_INTERNAL_ERROR = -32603; 21 21 const DEFAULT_PERMISSION_MODE = "dontAsk"; 22 + const MAX_STDERR_BYTES = 64 * 1024; 22 23 const ACP_METHOD_INITIALIZE = "initialize"; 23 24 const ACP_METHOD_AUTHENTICATE = "authenticate"; 24 25 const ACP_METHOD_SESSION_NEW = "session/new"; ··· 186 187 }); 187 188 this.proc = proc; 188 189 const stdout = readline.createInterface({ input: proc.stdout }); 189 - let stderrText = ""; 190 + const stderrState = createCappedStderrState(); 190 191 let settled = false; 191 192 192 193 const finish = (fn, value) => { ··· 199 200 }; 200 201 201 202 proc.stderr.on("data", (chunk) => { 202 - stderrText += chunk.toString(); 203 + appendStderrChunk(stderrState, chunk); 203 204 }); 204 205 205 206 stdout.on("line", (line) => { ··· 228 229 } 229 230 }); 230 231 231 - proc.on("exit", (code, signal) => { 232 + proc.on("close", (code, signal) => { 232 233 if (settled) { 233 234 return; 234 235 } ··· 236 237 finish(resolve, { stopReason: "cancelled" }); 237 238 return; 238 239 } 239 - const detail = normalizeString(stderrText); 240 + const detail = normalizeString(stderrDetail(stderrState)); 240 241 const suffix = signal ? `signal ${signal}` : `code ${code ?? "unknown"}`; 241 242 finish( 242 243 reject, ··· 626 627 627 628 function stringOrEmpty(value) { 628 629 return typeof value === "string" ? value : ""; 630 + } 631 + 632 + export function createCappedStderrState() { 633 + return { 634 + buffers: [], 635 + size: 0, 636 + truncated: false 637 + }; 638 + } 639 + 640 + export function appendStderrChunk(state, chunk) { 641 + if (!state) { 642 + return; 643 + } 644 + const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)); 645 + if (buf.length === 0) { 646 + return; 647 + } 648 + state.buffers.push(buf); 649 + state.size += buf.length; 650 + 651 + while (state.size > MAX_STDERR_BYTES && state.buffers.length > 0) { 652 + const head = state.buffers[0]; 653 + const overflow = state.size - MAX_STDERR_BYTES; 654 + state.truncated = true; 655 + if (head.length <= overflow) { 656 + state.buffers.shift(); 657 + state.size -= head.length; 658 + continue; 659 + } 660 + state.buffers[0] = head.subarray(overflow); 661 + state.size -= overflow; 662 + break; 663 + } 664 + } 665 + 666 + export function stderrDetail(state) { 667 + if (!state || state.buffers.length === 0) { 668 + return ""; 669 + } 670 + const text = Buffer.concat(state.buffers).toString("utf8"); 671 + if (!state.truncated) { 672 + return text; 673 + } 674 + return `[stderr truncated]\n${text}`; 629 675 } 630 676 631 677 function normalizeToolList(value) {
+13 -1
wrappers/acp/claude/test/lib.test.mjs
··· 2 2 import assert from "node:assert/strict"; 3 3 4 4 import { 5 + appendStderrChunk, 5 6 buildClaudePromptFlags, 6 7 collectACPText, 8 + createCappedStderrState, 7 9 createPromptState, 8 10 normalizeSessionOptions, 9 - processClaudeEvent 11 + processClaudeEvent, 12 + stderrDetail 10 13 } from "../src/lib.mjs"; 11 14 12 15 test("normalizeSessionOptions supports defaults and arrays", () => { ··· 151 154 ); 152 155 assert.equal(assistant.updates[0].content[0].text, "world\n"); 153 156 }); 157 + 158 + test("stderrDetail caps backend stderr", () => { 159 + const state = createCappedStderrState(); 160 + appendStderrChunk(state, Buffer.from("x".repeat(70000))); 161 + 162 + const detail = stderrDetail(state); 163 + assert.match(detail, /\[stderr truncated\]/); 164 + assert.ok(detail.length < 70000); 165 + });