detached e6d4fe0

fix(mcp): fail-closed process ownership, interrupted-write reconnect, stricter redaction

commit
e6d4fe0882d69b73d1b37f87616b79e4c429e48d
author
brice · · 2026-10-15 13:51 UTC
parent
be3ffc0
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
11 files · +838 -131
M packages/agent/mcp/fakeserver_test.go +21 -2
@@ -29,7 +29,7 @@ import (
2929 const fakeServerEnv = "ZUT_MCP_FAKE_SERVER"
3030
3131 // fakeToolCount is the number of tools the fake server lists in total.
32-const fakeToolCount = 17
32+const fakeToolCount = 18
3333
3434 var fakeImageBytes = []byte("\x89PNG\r\n\x1a\nfake-image-bytes")
3535
@@ -58,7 +58,10 @@ type fakeBackend struct {
5858 extraTool atomic.Bool
5959 // manyResources makes resources/list return this many long entries.
6060 manyResources int
61- events string
61+ // hugeDescription, when > 0, gives the first resource a description of
62+ // that many bytes.
63+ hugeDescription int
64+ events string
6265 }
6366
6467 func fakeBackendFromEnv() *fakeBackend {
@@ -71,6 +74,9 @@ func fakeBackendFromEnv() *fakeBackend {
7174 if n, err := strconv.Atoi(os.Getenv("FAKE_MANY_RESOURCES")); err == nil {
7275 b.manyResources = n
7376 }
77+ if n, err := strconv.Atoi(os.Getenv("FAKE_HUGE_DESC")); err == nil {
78+ b.hugeDescription = n
79+ }
7480 return b
7581 }
7682
@@ -118,6 +124,7 @@ func (b *fakeBackend) toolPage(cursor string) map[string]any {
118124 fakeToolDef("spawn", "Start a descendant process and return its pid", false),
119125 fakeToolDef("stall", "Stop reading stdin", false),
120126 fakeToolDef("stderr_flood", "Write a huge stderr line", false),
127+ fakeToolDef("stderr_secret", "Write a stderr line with FAKE_SECRET straddling byte 4096", false),
121128 fakeToolDef("cwd", "Return the server working directory", false),
122129 }
123130 if b.extraTool.Load() {
@@ -211,6 +218,11 @@ func (b *fakeBackend) handle(method string, params json.RawMessage) (any, *RPCEr
211218 return nil, notFound
212219 }
213220 fakeEvent(b.events, "list-resources")
221+ if b.hugeDescription > 0 {
222+ return map[string]any{"resources": []any{map[string]any{
223+ "uri": "mem://huge", "name": "huge", "description": strings.Repeat("h", b.hugeDescription),
224+ }, map[string]any{"uri": "mem://after", "name": "after"}}}, nil
225+ }
214226 if b.manyResources > 0 {
215227 list := make([]any, 0, b.manyResources)
216228 for i := 0; i < b.manyResources; i++ {
@@ -367,6 +379,13 @@ func runFakeStdioServer() {
367379 }
368380 respond(msg.ID, fakeCallResult(fakeText(strconv.Itoa(child.Process.Pid))), nil)
369381 return
382+ case "stderr_secret":
383+ secret := os.Getenv("FAKE_SECRET")
384+ pad := 4096 - len(secret)/2
385+ fmt.Fprintln(os.Stderr, strings.Repeat("x", pad)+secret+strings.Repeat("y", 200))
386+ fmt.Fprintln(os.Stderr, "after-secret")
387+ respond(msg.ID, fakeCallResult(fakeText("written")), nil)
388+ return
370389 case "stderr_flood":
371390 os.Stderr.WriteString(strings.Repeat("x", 3<<20) + "\n")
372391 fmt.Fprintln(os.Stderr, "after-flood")
@@ -29,7 +29,7 @@ import (
29 const fakeServerEnv = "ZUT_MCP_FAKE_SERVER"29 const fakeServerEnv = "ZUT_MCP_FAKE_SERVER"
30 30
31 // fakeToolCount is the number of tools the fake server lists in total.31 // fakeToolCount is the number of tools the fake server lists in total.
32-const fakeToolCount = 1732+const fakeToolCount = 18
33 33
34 var fakeImageBytes = []byte("\x89PNG\r\n\x1a\nfake-image-bytes")34 var fakeImageBytes = []byte("\x89PNG\r\n\x1a\nfake-image-bytes")
35 35
@@ -58,7 +58,10 @@ type fakeBackend struct {
58 extraTool atomic.Bool58 extraTool atomic.Bool
59 // manyResources makes resources/list return this many long entries.59 // manyResources makes resources/list return this many long entries.
60 manyResources int60 manyResources int
61- events string61+ // hugeDescription, when > 0, gives the first resource a description of
62+ // that many bytes.
63+ hugeDescription int
64+ events string
62 }65 }
63 66
64 func fakeBackendFromEnv() *fakeBackend {67 func fakeBackendFromEnv() *fakeBackend {
@@ -71,6 +74,9 @@ func fakeBackendFromEnv() *fakeBackend {
71 if n, err := strconv.Atoi(os.Getenv("FAKE_MANY_RESOURCES")); err == nil {74 if n, err := strconv.Atoi(os.Getenv("FAKE_MANY_RESOURCES")); err == nil {
72 b.manyResources = n75 b.manyResources = n
73 }76 }
77+ if n, err := strconv.Atoi(os.Getenv("FAKE_HUGE_DESC")); err == nil {
78+ b.hugeDescription = n
79+ }
74 return b80 return b
75 }81 }
76 82
@@ -118,6 +124,7 @@ func (b *fakeBackend) toolPage(cursor string) map[string]any {
118 fakeToolDef("spawn", "Start a descendant process and return its pid", false),124 fakeToolDef("spawn", "Start a descendant process and return its pid", false),
119 fakeToolDef("stall", "Stop reading stdin", false),125 fakeToolDef("stall", "Stop reading stdin", false),
120 fakeToolDef("stderr_flood", "Write a huge stderr line", false),126 fakeToolDef("stderr_flood", "Write a huge stderr line", false),
127+ fakeToolDef("stderr_secret", "Write a stderr line with FAKE_SECRET straddling byte 4096", false),
121 fakeToolDef("cwd", "Return the server working directory", false),128 fakeToolDef("cwd", "Return the server working directory", false),
122 }129 }
123 if b.extraTool.Load() {130 if b.extraTool.Load() {
@@ -211,6 +218,11 @@ func (b *fakeBackend) handle(method string, params json.RawMessage) (any, *RPCEr
211 return nil, notFound218 return nil, notFound
212 }219 }
213 fakeEvent(b.events, "list-resources")220 fakeEvent(b.events, "list-resources")
221+ if b.hugeDescription > 0 {
222+ return map[string]any{"resources": []any{map[string]any{
223+ "uri": "mem://huge", "name": "huge", "description": strings.Repeat("h", b.hugeDescription),
224+ }, map[string]any{"uri": "mem://after", "name": "after"}}}, nil
225+ }
214 if b.manyResources > 0 {226 if b.manyResources > 0 {
215 list := make([]any, 0, b.manyResources)227 list := make([]any, 0, b.manyResources)
216 for i := 0; i < b.manyResources; i++ {228 for i := 0; i < b.manyResources; i++ {
@@ -367,6 +379,13 @@ func runFakeStdioServer() {
367 }379 }
368 respond(msg.ID, fakeCallResult(fakeText(strconv.Itoa(child.Process.Pid))), nil)380 respond(msg.ID, fakeCallResult(fakeText(strconv.Itoa(child.Process.Pid))), nil)
369 return381 return
382+ case "stderr_secret":
383+ secret := os.Getenv("FAKE_SECRET")
384+ pad := 4096 - len(secret)/2
385+ fmt.Fprintln(os.Stderr, strings.Repeat("x", pad)+secret+strings.Repeat("y", 200))
386+ fmt.Fprintln(os.Stderr, "after-secret")
387+ respond(msg.ID, fakeCallResult(fakeText("written")), nil)
388+ return
370 case "stderr_flood":389 case "stderr_flood":
371 os.Stderr.WriteString(strings.Repeat("x", 3<<20) + "\n")390 os.Stderr.WriteString(strings.Repeat("x", 3<<20) + "\n")
372 fmt.Fprintln(os.Stderr, "after-flood")391 fmt.Fprintln(os.Stderr, "after-flood")
M packages/agent/mcp/hardening_test.go +16 -6
@@ -558,20 +558,30 @@ func TestStdioCancelNotificationRules(t *testing.T) {
558558 if got := st.cancelsSent.Load(); got != 0 {
559559 t.Errorf("notifications/cancelled sent for initialize (%d)", got)
560560 }
561- // Any other method is announced exactly once, without blocking the caller.
562- ctx2, cancel2 := context.WithCancel(context.Background())
563- cancel2()
561+}
562+
563+func TestStdioCancelNotificationForSentRequest(t *testing.T) {
564+ c, events := connectFake(t, nil)
565+ st := c.t.(*stdioTransport)
566+ ctx, cancel := context.WithCancel(context.Background())
567+ done := make(chan error, 1)
568+ go func() {
569+ _, err := c.CallTool(ctx, "slow", []byte(`{}`))
570+ done <- err
571+ }()
572+ waitEvent(t, events, "slow-started")
564573 start := time.Now()
565- _, err = st.request(ctx2, 2, "tools/call", map[string]any{"name": "slow", "arguments": map[string]any{}})
566- if !errors.Is(err, context.Canceled) {
574+ cancel()
575+ if err := <-done; !errors.Is(err, context.Canceled) {
567576 t.Fatalf("err = %v", err)
568577 }
569578 if time.Since(start) > time.Second {
570579 t.Error("caller delayed by the cancel notification")
571580 }
572581 if got := st.cancelsSent.Load(); got != 1 {
573- t.Errorf("cancel count for tools/call = %d, want 1", got)
582+ t.Errorf("cancel count = %d, want 1", got)
574583 }
584+ waitEvent(t, events, "cancelled:")
575585 }
576586
577587 func TestHTTPInitializeNotCancelledOnTimeout(t *testing.T) {
@@ -558,20 +558,30 @@ func TestStdioCancelNotificationRules(t *testing.T) {
558 if got := st.cancelsSent.Load(); got != 0 {558 if got := st.cancelsSent.Load(); got != 0 {
559 t.Errorf("notifications/cancelled sent for initialize (%d)", got)559 t.Errorf("notifications/cancelled sent for initialize (%d)", got)
560 }560 }
561- // Any other method is announced exactly once, without blocking the caller.561+}
562- ctx2, cancel2 := context.WithCancel(context.Background())562+
563- cancel2()563+func TestStdioCancelNotificationForSentRequest(t *testing.T) {
564+ c, events := connectFake(t, nil)
565+ st := c.t.(*stdioTransport)
566+ ctx, cancel := context.WithCancel(context.Background())
567+ done := make(chan error, 1)
568+ go func() {
569+ _, err := c.CallTool(ctx, "slow", []byte(`{}`))
570+ done <- err
571+ }()
572+ waitEvent(t, events, "slow-started")
564 start := time.Now()573 start := time.Now()
565- _, err = st.request(ctx2, 2, "tools/call", map[string]any{"name": "slow", "arguments": map[string]any{}})574+ cancel()
566- if !errors.Is(err, context.Canceled) {575+ if err := <-done; !errors.Is(err, context.Canceled) {
567 t.Fatalf("err = %v", err)576 t.Fatalf("err = %v", err)
568 }577 }
569 if time.Since(start) > time.Second {578 if time.Since(start) > time.Second {
570 t.Error("caller delayed by the cancel notification")579 t.Error("caller delayed by the cancel notification")
571 }580 }
572 if got := st.cancelsSent.Load(); got != 1 {581 if got := st.cancelsSent.Load(); got != 1 {
573- t.Errorf("cancel count for tools/call = %d, want 1", got)582+ t.Errorf("cancel count = %d, want 1", got)
574 }583 }
584+ waitEvent(t, events, "cancelled:")
575 }585 }
576 586
577 func TestHTTPInitializeNotCancelledOnTimeout(t *testing.T) {587 func TestHTTPInitializeNotCancelledOnTimeout(t *testing.T) {
M packages/agent/mcp/http.go +2 -1
@@ -95,7 +95,8 @@ func (t *httpTransport) newRequest(ctx context.Context, method string, body []by
9595 }
9696 req, err := http.NewRequestWithContext(ctx, method, t.url, reader)
9797 if err != nil {
98- return nil, err
98+ // url parse errors quote the whole URL, query token included.
99+ return nil, t.red.scrubError(err)
99100 }
100101 for k, v := range t.headers {
101102 req.Header.Set(k, v)
@@ -95,7 +95,8 @@ func (t *httpTransport) newRequest(ctx context.Context, method string, body []by
95 }95 }
96 req, err := http.NewRequestWithContext(ctx, method, t.url, reader)96 req, err := http.NewRequestWithContext(ctx, method, t.url, reader)
97 if err != nil {97 if err != nil {
98- return nil, err98+ // url parse errors quote the whole URL, query token included.
99+ return nil, t.red.scrubError(err)
99 }100 }
100 for k, v := range t.headers {101 for k, v := range t.headers {
101 req.Header.Set(k, v)102 req.Header.Set(k, v)
M packages/agent/mcp/manager_test.go +2 -2
@@ -160,14 +160,14 @@ func TestManagerStdioToolsAndDescriptions(t *testing.T) {
160160 }
161161
162162 text := FormatStatus(m.Status())
163- if !strings.Contains(text, "fake (stdio): connected, 17 tools, resources") {
163+ if !strings.Contains(text, "fake (stdio): connected, 18 tools, resources") {
164164 t.Errorf("FormatStatus = %q", text)
165165 }
166166 if FormatStatus(nil) != "no MCP servers configured" {
167167 t.Error("empty FormatStatus")
168168 }
169169 add := m.SystemPromptAddendum()
170- for _, want := range []string{"[mcp_servers]", "fake: 17 tools", "mcp__fake__<tool>", "Be nice"} {
170+ for _, want := range []string{"[mcp_servers]", "fake: 18 tools", "mcp__fake__<tool>", "Be nice"} {
171171 if !strings.Contains(add, want) {
172172 t.Errorf("addendum missing %q:\n%s", want, add)
173173 }
@@ -160,14 +160,14 @@ func TestManagerStdioToolsAndDescriptions(t *testing.T) {
160 }160 }
161 161
162 text := FormatStatus(m.Status())162 text := FormatStatus(m.Status())
163- if !strings.Contains(text, "fake (stdio): connected, 17 tools, resources") {163+ if !strings.Contains(text, "fake (stdio): connected, 18 tools, resources") {
164 t.Errorf("FormatStatus = %q", text)164 t.Errorf("FormatStatus = %q", text)
165 }165 }
166 if FormatStatus(nil) != "no MCP servers configured" {166 if FormatStatus(nil) != "no MCP servers configured" {
167 t.Error("empty FormatStatus")167 t.Error("empty FormatStatus")
168 }168 }
169 add := m.SystemPromptAddendum()169 add := m.SystemPromptAddendum()
170- for _, want := range []string{"[mcp_servers]", "fake: 17 tools", "mcp__fake__<tool>", "Be nice"} {170+ for _, want := range []string{"[mcp_servers]", "fake: 18 tools", "mcp__fake__<tool>", "Be nice"} {
171 if !strings.Contains(add, want) {171 if !strings.Contains(add, want) {
172 t.Errorf("addendum missing %q:\n%s", want, add)172 t.Errorf("addendum missing %q:\n%s", want, add)
173 }173 }
M packages/agent/mcp/proc_unix.go +95 -17
@@ -3,49 +3,127 @@
33 package mcp
44
55 import (
6+ "errors"
7+ "os"
68 "os/exec"
9+ "sync"
710 "syscall"
11+ "time"
812 )
913
1014 // isolateProcess puts the server in its own process group so it does not
11-// receive terminal signals aimed at zut and so close can stop its children.
15+// receive terminal signals aimed at zut and so cleanup can stop its children.
1216 func isolateProcess(cmd *exec.Cmd) {
1317 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
1418 }
1519
16-// processGuard tracks everything a stdio server spawned: the process group on
17-// Unix, a Job Object on Windows.
20+// processGuard owns the process group of one stdio server.
21+//
22+// A numeric process-group id is only safe to signal while the group still has
23+// members: once the last member is gone the kernel may hand the number to an
24+// unrelated process. The guard therefore (a) cleans the group exactly once,
25+// (b) forgets the id as soon as the group is observed empty or cleaned, and
26+// (c) is cleaned as soon as the direct child is reaped, so no stale id stays
27+// signalable for the life of the transport.
1828 type processGuard struct {
19- cmd *exec.Cmd
29+ mu sync.Mutex
30+ pgid int // 0 once cleaned up or observed gone
31+ proc *os.Process
32+ kill func(pid int, sig syscall.Signal) error // syscall.Kill; replaced in tests
33+
34+ once sync.Once
2035 }
2136
2237 // newProcessGuard is called right after cmd.Start.
23-func newProcessGuard(cmd *exec.Cmd) *processGuard { return &processGuard{cmd: cmd} }
38+func newProcessGuard(cmd *exec.Cmd) (*processGuard, error) {
39+ g := &processGuard{kill: func(pid int, sig syscall.Signal) error { return syscall.Kill(pid, sig) }}
40+ if cmd != nil && cmd.Process != nil {
41+ g.pgid = cmd.Process.Pid
42+ g.proc = cmd.Process
43+ }
44+ return g, nil
45+}
2446
25-// terminate signals the whole process group, including descendants that
26-// outlive the direct child.
47+// terminate signals the whole group, including descendants that outlive the
48+// direct child. It does nothing once the group is cleaned up.
2749 func (g *processGuard) terminate(force bool) {
28- if g == nil || g.cmd == nil || g.cmd.Process == nil {
50+ if g == nil {
51+ return
52+ }
53+ g.mu.Lock()
54+ defer g.mu.Unlock()
55+ if g.pgid == 0 {
2956 return
3057 }
3158 sig := syscall.SIGTERM
3259 if force {
3360 sig = syscall.SIGKILL
3461 }
35- err := syscall.Kill(-g.cmd.Process.Pid, sig)
36- if err != nil && err != syscall.ESRCH {
37- _ = g.cmd.Process.Signal(sig)
62+ err := g.kill(-g.pgid, sig)
63+ switch {
64+ case err == nil:
65+ case errors.Is(err, syscall.ESRCH):
66+ g.pgid = 0 // group gone: never signal this number again
67+ default:
68+ if g.proc != nil {
69+ _ = g.proc.Signal(sig)
70+ }
3871 }
3972 }
4073
41-// alive reports whether any member of the process group still exists.
74+// alive reports whether the group still has members.
4275 func (g *processGuard) alive() bool {
43- if g == nil || g.cmd == nil || g.cmd.Process == nil {
76+ if g == nil {
77+ return false
78+ }
79+ g.mu.Lock()
80+ defer g.mu.Unlock()
81+ if g.pgid == 0 {
4482 return false
4583 }
46- err := syscall.Kill(-g.cmd.Process.Pid, 0)
47- return err == nil || err == syscall.EPERM
84+ err := g.kill(-g.pgid, 0)
85+ if errors.Is(err, syscall.ESRCH) {
86+ g.pgid = 0
87+ return false
88+ }
89+ return true // nil, or EPERM: a member exists that we cannot signal
4890 }
4991
50-// release frees resources held for the group. Nothing is held on Unix.
51-func (g *processGuard) release() {}
92+// cleanup stops every remaining group member (SIGTERM, then SIGKILL for
93+// survivors) and releases the id. It runs its body once; later and concurrent
94+// calls wait for that run and then return.
95+func (g *processGuard) cleanup() {
96+ if g == nil {
97+ return
98+ }
99+ g.once.Do(func() {
100+ defer func() {
101+ g.mu.Lock()
102+ g.pgid = 0
103+ g.proc = nil
104+ g.mu.Unlock()
105+ }()
106+ if !g.alive() {
107+ return
108+ }
109+ g.terminate(false)
110+ if g.waitGone(300 * time.Millisecond) {
111+ return
112+ }
113+ g.terminate(true)
114+ g.waitGone(200 * time.Millisecond)
115+ })
116+}
117+
118+func (g *processGuard) waitGone(d time.Duration) bool {
119+ deadline := time.Now().Add(d)
120+ for {
121+ if !g.alive() {
122+ return true
123+ }
124+ if time.Now().After(deadline) {
125+ return false
126+ }
127+ time.Sleep(10 * time.Millisecond)
128+ }
129+}
@@ -3,49 +3,127 @@
3 package mcp3 package mcp
4 4
5 import (5 import (
6+ "errors"
7+ "os"
6 "os/exec"8 "os/exec"
9+ "sync"
7 "syscall"10 "syscall"
11+ "time"
8 )12 )
9 13
10 // isolateProcess puts the server in its own process group so it does not14 // isolateProcess puts the server in its own process group so it does not
11-// receive terminal signals aimed at zut and so close can stop its children.15+// receive terminal signals aimed at zut and so cleanup can stop its children.
12 func isolateProcess(cmd *exec.Cmd) {16 func isolateProcess(cmd *exec.Cmd) {
13 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}17 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
14 }18 }
15 19
16-// processGuard tracks everything a stdio server spawned: the process group on20+// processGuard owns the process group of one stdio server.
17-// Unix, a Job Object on Windows.21+//
22+// A numeric process-group id is only safe to signal while the group still has
23+// members: once the last member is gone the kernel may hand the number to an
24+// unrelated process. The guard therefore (a) cleans the group exactly once,
25+// (b) forgets the id as soon as the group is observed empty or cleaned, and
26+// (c) is cleaned as soon as the direct child is reaped, so no stale id stays
27+// signalable for the life of the transport.
18 type processGuard struct {28 type processGuard struct {
19- cmd *exec.Cmd29+ mu sync.Mutex
30+ pgid int // 0 once cleaned up or observed gone
31+ proc *os.Process
32+ kill func(pid int, sig syscall.Signal) error // syscall.Kill; replaced in tests
33+
34+ once sync.Once
20 }35 }
21 36
22 // newProcessGuard is called right after cmd.Start.37 // newProcessGuard is called right after cmd.Start.
23-func newProcessGuard(cmd *exec.Cmd) *processGuard { return &processGuard{cmd: cmd} }38+func newProcessGuard(cmd *exec.Cmd) (*processGuard, error) {
39+ g := &processGuard{kill: func(pid int, sig syscall.Signal) error { return syscall.Kill(pid, sig) }}
40+ if cmd != nil && cmd.Process != nil {
41+ g.pgid = cmd.Process.Pid
42+ g.proc = cmd.Process
43+ }
44+ return g, nil
45+}
24 46
25-// terminate signals the whole process group, including descendants that47+// terminate signals the whole group, including descendants that outlive the
26-// outlive the direct child.48+// direct child. It does nothing once the group is cleaned up.
27 func (g *processGuard) terminate(force bool) {49 func (g *processGuard) terminate(force bool) {
28- if g == nil || g.cmd == nil || g.cmd.Process == nil {50+ if g == nil {
51+ return
52+ }
53+ g.mu.Lock()
54+ defer g.mu.Unlock()
55+ if g.pgid == 0 {
29 return56 return
30 }57 }
31 sig := syscall.SIGTERM58 sig := syscall.SIGTERM
32 if force {59 if force {
33 sig = syscall.SIGKILL60 sig = syscall.SIGKILL
34 }61 }
35- err := syscall.Kill(-g.cmd.Process.Pid, sig)62+ err := g.kill(-g.pgid, sig)
36- if err != nil && err != syscall.ESRCH {63+ switch {
37- _ = g.cmd.Process.Signal(sig)64+ case err == nil:
65+ case errors.Is(err, syscall.ESRCH):
66+ g.pgid = 0 // group gone: never signal this number again
67+ default:
68+ if g.proc != nil {
69+ _ = g.proc.Signal(sig)
70+ }
38 }71 }
39 }72 }
40 73
41-// alive reports whether any member of the process group still exists.74+// alive reports whether the group still has members.
42 func (g *processGuard) alive() bool {75 func (g *processGuard) alive() bool {
43- if g == nil || g.cmd == nil || g.cmd.Process == nil {76+ if g == nil {
77+ return false
78+ }
79+ g.mu.Lock()
80+ defer g.mu.Unlock()
81+ if g.pgid == 0 {
44 return false82 return false
45 }83 }
46- err := syscall.Kill(-g.cmd.Process.Pid, 0)84+ err := g.kill(-g.pgid, 0)
47- return err == nil || err == syscall.EPERM85+ if errors.Is(err, syscall.ESRCH) {
86+ g.pgid = 0
87+ return false
88+ }
89+ return true // nil, or EPERM: a member exists that we cannot signal
48 }90 }
49 91
50-// release frees resources held for the group. Nothing is held on Unix.92+// cleanup stops every remaining group member (SIGTERM, then SIGKILL for
51-func (g *processGuard) release() {}93+// survivors) and releases the id. It runs its body once; later and concurrent
94+// calls wait for that run and then return.
95+func (g *processGuard) cleanup() {
96+ if g == nil {
97+ return
98+ }
99+ g.once.Do(func() {
100+ defer func() {
101+ g.mu.Lock()
102+ g.pgid = 0
103+ g.proc = nil
104+ g.mu.Unlock()
105+ }()
106+ if !g.alive() {
107+ return
108+ }
109+ g.terminate(false)
110+ if g.waitGone(300 * time.Millisecond) {
111+ return
112+ }
113+ g.terminate(true)
114+ g.waitGone(200 * time.Millisecond)
115+ })
116+}
117+
118+func (g *processGuard) waitGone(d time.Duration) bool {
119+ deadline := time.Now().Add(d)
120+ for {
121+ if !g.alive() {
122+ return true
123+ }
124+ if time.Now().After(deadline) {
125+ return false
126+ }
127+ time.Sleep(10 * time.Millisecond)
128+ }
129+}
A packages/agent/mcp/proc_unix_guard_test.go +135 -0
@@ -0,0 +1,135 @@
1+//go:build !windows
2+
3+package mcp
4+
5+import (
6+ "strconv"
7+ "sync"
8+ "syscall"
9+ "testing"
10+)
11+
12+// fakeKill records signals and answers from a script.
13+type fakeKill struct {
14+ mu sync.Mutex
15+ calls []int // pids passed, in order (negative = group)
16+ err error
17+}
18+
19+func (f *fakeKill) kill(pid int, _ syscall.Signal) error {
20+ f.mu.Lock()
21+ defer f.mu.Unlock()
22+ f.calls = append(f.calls, pid)
23+ return f.err
24+}
25+
26+func (f *fakeKill) count() int {
27+ f.mu.Lock()
28+ defer f.mu.Unlock()
29+ return len(f.calls)
30+}
31+
32+func newGuardForTest(pgid int, k *fakeKill) *processGuard {
33+ return &processGuard{pgid: pgid, kill: k.kill}
34+}
35+
36+func TestGuardForgetsGroupOnceGone(t *testing.T) {
37+ k := &fakeKill{err: syscall.ESRCH}
38+ g := newGuardForTest(4242, k)
39+ if g.alive() {
40+ t.Fatal("ESRCH means the group is gone")
41+ }
42+ before := k.count()
43+ // A gone group is never probed or signalled again: its number may belong
44+ // to somebody else by now.
45+ g.terminate(false)
46+ g.terminate(true)
47+ if g.alive() {
48+ t.Error("alive after gone")
49+ }
50+ g.cleanup()
51+ if k.count() != before {
52+ t.Errorf("signals sent to a group already observed gone: %v", k.calls)
53+ }
54+}
55+
56+func TestGuardCleanupIsOneShot(t *testing.T) {
57+ k := &fakeKill{} // every probe/signal "succeeds": the group never empties
58+ g := newGuardForTest(4243, k)
59+ g.cleanup() // escalates SIGTERM then SIGKILL, then gives up
60+ after := k.count()
61+ if after == 0 {
62+ t.Fatal("cleanup sent nothing to a live group")
63+ }
64+ for i := 0; i < 3; i++ {
65+ g.cleanup()
66+ g.terminate(true)
67+ g.alive()
68+ }
69+ if k.count() != after {
70+ t.Errorf("signals after cleanup: %d more", k.count()-after)
71+ }
72+ if g.pgid != 0 {
73+ t.Error("process group id retained after cleanup")
74+ }
75+}
76+
77+func TestGuardNoSignalWhenAlreadyEmpty(t *testing.T) {
78+ k := &fakeKill{err: syscall.ESRCH}
79+ g := newGuardForTest(4244, k)
80+ g.cleanup()
81+ for _, pid := range k.calls {
82+ if pid != -4244 {
83+ t.Errorf("unexpected target %d", pid)
84+ }
85+ }
86+ // Only the liveness probe ran, and nothing after it.
87+ if k.count() != 1 {
88+ t.Errorf("calls = %v, want a single probe", k.calls)
89+ }
90+}
91+
92+func TestStdioRepeatedCloseDoesNotSignalAgain(t *testing.T) {
93+ cfg, _ := fakeStdioConfig(t, nil)
94+ st, err := startStdio("x", cfg, "")
95+ if err != nil {
96+ t.Fatal(err)
97+ }
98+ if err := st.close(); err != nil {
99+ t.Fatal(err)
100+ }
101+ k := &fakeKill{}
102+ st.guard.mu.Lock()
103+ st.guard.kill = k.kill
104+ st.guard.mu.Unlock()
105+ for i := 0; i < 3; i++ {
106+ if err := st.close(); err != nil {
107+ t.Fatal(err)
108+ }
109+ }
110+ if k.count() != 0 {
111+ t.Errorf("late Close signalled a saved process-group id: %v", k.calls)
112+ }
113+}
114+
115+func TestStdioNaturalExitCleansDescendants(t *testing.T) {
116+ c, _ := connectFake(t, nil)
117+ pid, err := strconv.Atoi(callText(t, c, "spawn", `{"mode":"sleeper-stubborn"}`))
118+ if err != nil {
119+ t.Fatal(err)
120+ }
121+ t.Cleanup(func() { killPID(pid) })
122+ if !processAlive(pid) {
123+ t.Fatal("descendant not running")
124+ }
125+ // The server dies on its own (no Close): the descendant must not be left
126+ // running, and the transport must have released the group by itself.
127+ _, _ = c.CallTool(t.Context(), "exit", []byte(`{}`))
128+ eventually(t, "descendant to be cleaned after the leader exited", func() bool { return !processAlive(pid) })
129+ st := c.t.(*stdioTransport)
130+ eventually(t, "group id to be released", func() bool {
131+ st.guard.mu.Lock()
132+ defer st.guard.mu.Unlock()
133+ return st.guard.pgid == 0
134+ })
135+}
@@ -0,0 +1,135 @@
1+//go:build !windows
2+
3+package mcp
4+
5+import (
6+ "strconv"
7+ "sync"
8+ "syscall"
9+ "testing"
10+)
11+
12+// fakeKill records signals and answers from a script.
13+type fakeKill struct {
14+ mu sync.Mutex
15+ calls []int // pids passed, in order (negative = group)
16+ err error
17+}
18+
19+func (f *fakeKill) kill(pid int, _ syscall.Signal) error {
20+ f.mu.Lock()
21+ defer f.mu.Unlock()
22+ f.calls = append(f.calls, pid)
23+ return f.err
24+}
25+
26+func (f *fakeKill) count() int {
27+ f.mu.Lock()
28+ defer f.mu.Unlock()
29+ return len(f.calls)
30+}
31+
32+func newGuardForTest(pgid int, k *fakeKill) *processGuard {
33+ return &processGuard{pgid: pgid, kill: k.kill}
34+}
35+
36+func TestGuardForgetsGroupOnceGone(t *testing.T) {
37+ k := &fakeKill{err: syscall.ESRCH}
38+ g := newGuardForTest(4242, k)
39+ if g.alive() {
40+ t.Fatal("ESRCH means the group is gone")
41+ }
42+ before := k.count()
43+ // A gone group is never probed or signalled again: its number may belong
44+ // to somebody else by now.
45+ g.terminate(false)
46+ g.terminate(true)
47+ if g.alive() {
48+ t.Error("alive after gone")
49+ }
50+ g.cleanup()
51+ if k.count() != before {
52+ t.Errorf("signals sent to a group already observed gone: %v", k.calls)
53+ }
54+}
55+
56+func TestGuardCleanupIsOneShot(t *testing.T) {
57+ k := &fakeKill{} // every probe/signal "succeeds": the group never empties
58+ g := newGuardForTest(4243, k)
59+ g.cleanup() // escalates SIGTERM then SIGKILL, then gives up
60+ after := k.count()
61+ if after == 0 {
62+ t.Fatal("cleanup sent nothing to a live group")
63+ }
64+ for i := 0; i < 3; i++ {
65+ g.cleanup()
66+ g.terminate(true)
67+ g.alive()
68+ }
69+ if k.count() != after {
70+ t.Errorf("signals after cleanup: %d more", k.count()-after)
71+ }
72+ if g.pgid != 0 {
73+ t.Error("process group id retained after cleanup")
74+ }
75+}
76+
77+func TestGuardNoSignalWhenAlreadyEmpty(t *testing.T) {
78+ k := &fakeKill{err: syscall.ESRCH}
79+ g := newGuardForTest(4244, k)
80+ g.cleanup()
81+ for _, pid := range k.calls {
82+ if pid != -4244 {
83+ t.Errorf("unexpected target %d", pid)
84+ }
85+ }
86+ // Only the liveness probe ran, and nothing after it.
87+ if k.count() != 1 {
88+ t.Errorf("calls = %v, want a single probe", k.calls)
89+ }
90+}
91+
92+func TestStdioRepeatedCloseDoesNotSignalAgain(t *testing.T) {
93+ cfg, _ := fakeStdioConfig(t, nil)
94+ st, err := startStdio("x", cfg, "")
95+ if err != nil {
96+ t.Fatal(err)
97+ }
98+ if err := st.close(); err != nil {
99+ t.Fatal(err)
100+ }
101+ k := &fakeKill{}
102+ st.guard.mu.Lock()
103+ st.guard.kill = k.kill
104+ st.guard.mu.Unlock()
105+ for i := 0; i < 3; i++ {
106+ if err := st.close(); err != nil {
107+ t.Fatal(err)
108+ }
109+ }
110+ if k.count() != 0 {
111+ t.Errorf("late Close signalled a saved process-group id: %v", k.calls)
112+ }
113+}
114+
115+func TestStdioNaturalExitCleansDescendants(t *testing.T) {
116+ c, _ := connectFake(t, nil)
117+ pid, err := strconv.Atoi(callText(t, c, "spawn", `{"mode":"sleeper-stubborn"}`))
118+ if err != nil {
119+ t.Fatal(err)
120+ }
121+ t.Cleanup(func() { killPID(pid) })
122+ if !processAlive(pid) {
123+ t.Fatal("descendant not running")
124+ }
125+ // The server dies on its own (no Close): the descendant must not be left
126+ // running, and the transport must have released the group by itself.
127+ _, _ = c.CallTool(t.Context(), "exit", []byte(`{}`))
128+ eventually(t, "descendant to be cleaned after the leader exited", func() bool { return !processAlive(pid) })
129+ st := c.t.(*stdioTransport)
130+ eventually(t, "group id to be released", func() bool {
131+ st.guard.mu.Lock()
132+ defer st.guard.mu.Unlock()
133+ return st.guard.pgid == 0
134+ })
135+}
M packages/agent/mcp/proc_windows.go +95 -45
@@ -3,90 +3,140 @@
33 package mcp
44
55 import (
6+ "errors"
7+ "fmt"
68 "os/exec"
79 "sync"
10+ "syscall"
811 "unsafe"
912
1013 "golang.org/x/sys/windows"
1114 )
1215
13-func isolateProcess(_ *exec.Cmd) {}
16+// isolateProcess starts the server suspended. The guard assigns it to a Job
17+// Object before its first instruction runs and then resumes it, so no
18+// descendant can be created outside the job.
19+func isolateProcess(cmd *exec.Cmd) {
20+ if cmd.SysProcAttr == nil {
21+ cmd.SysProcAttr = &syscall.SysProcAttr{}
22+ }
23+ cmd.SysProcAttr.CreationFlags |= windows.CREATE_SUSPENDED
24+}
1425
15-// processGuard assigns the server to a Job Object with
16-// JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, so descendants die with the server (and
17-// with zut, should zut itself be killed).
18-//
19-// Assignment happens right after Start; a server that spawns children in the
20-// first instants of its life can escape the job. If the job cannot be created
21-// or assigned, the guard falls back to killing the direct child.
26+// processGuard owns a Job Object with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE that
27+// contains the server and all its descendants. The mutex is held across every
28+// use of the handle so a concurrent release cannot close it mid-call.
2229 type processGuard struct {
23- cmd *exec.Cmd
24-
25- mu sync.Mutex
26- job windows.Handle
30+ mu sync.Mutex
31+ job windows.Handle // 0 once released
32+ once sync.Once
2733 }
2834
29-func newProcessGuard(cmd *exec.Cmd) *processGuard {
30- g := &processGuard{cmd: cmd}
35+// newProcessGuard is called right after cmd.Start with the process still
36+// suspended. It fails closed: if the job cannot be set up, the child is killed
37+// and an error is returned.
38+func newProcessGuard(cmd *exec.Cmd) (*processGuard, error) {
3139 if cmd == nil || cmd.Process == nil {
32- return g
40+ return nil, errors.New("process not started")
41+ }
42+ fail := func(what string, err error) (*processGuard, error) {
43+ _ = cmd.Process.Kill()
44+ return nil, fmt.Errorf("%s: %w", what, err)
3345 }
3446 job, err := windows.CreateJobObject(nil, nil)
3547 if err != nil {
36- return g
48+ return fail("create job object", err)
3749 }
3850 var info windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION
3951 info.BasicLimitInformation.LimitFlags = windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
4052 if _, err := windows.SetInformationJobObject(job, windows.JobObjectExtendedLimitInformation,
4153 uintptr(unsafe.Pointer(&info)), uint32(unsafe.Sizeof(info))); err != nil {
4254 _ = windows.CloseHandle(job)
43- return g
55+ return fail("configure job object", err)
4456 }
4557 proc, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(cmd.Process.Pid))
4658 if err != nil {
4759 _ = windows.CloseHandle(job)
48- return g
60+ return fail("open server process", err)
4961 }
50- defer windows.CloseHandle(proc)
51- if err := windows.AssignProcessToJobObject(job, proc); err != nil {
62+ err = windows.AssignProcessToJobObject(job, proc)
63+ _ = windows.CloseHandle(proc)
64+ if err != nil {
5265 _ = windows.CloseHandle(job)
53- return g
66+ return fail("assign server to job object", err)
5467 }
55- g.job = job
56- return g
68+ if err := resumeProcess(uint32(cmd.Process.Pid)); err != nil {
69+ _ = windows.CloseHandle(job) // kills the job's only member
70+ return fail("resume server", err)
71+ }
72+ return &processGuard{job: job}, nil
5773 }
5874
59-// terminate kills the job (every process in it) or, without a job, the direct
60-// child. Windows has no graceful group signal, so force is ignored.
75+// resumeProcess resumes every suspended thread of pid (a CREATE_SUSPENDED
76+// process has exactly its main thread).
77+func resumeProcess(pid uint32) error {
78+ snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPTHREAD, 0)
79+ if err != nil {
80+ return err
81+ }
82+ defer windows.CloseHandle(snap)
83+ entry := windows.ThreadEntry32{Size: uint32(unsafe.Sizeof(windows.ThreadEntry32{}))}
84+ if err := windows.Thread32First(snap, &entry); err != nil {
85+ return err
86+ }
87+ resumed := 0
88+ for {
89+ if entry.OwnerProcessID == pid {
90+ th, err := windows.OpenThread(windows.THREAD_SUSPEND_RESUME, false, entry.ThreadID)
91+ if err != nil {
92+ return err
93+ }
94+ _, err = windows.ResumeThread(th)
95+ _ = windows.CloseHandle(th)
96+ if err != nil {
97+ return err
98+ }
99+ resumed++
100+ }
101+ entry.Size = uint32(unsafe.Sizeof(entry))
102+ if err := windows.Thread32Next(snap, &entry); err != nil {
103+ if errors.Is(err, windows.ERROR_NO_MORE_FILES) {
104+ break
105+ }
106+ return err
107+ }
108+ }
109+ if resumed == 0 {
110+ return errors.New("no thread to resume")
111+ }
112+ return nil
113+}
114+
115+// terminate kills every process in the job. Windows has no graceful group
116+// signal, so force is ignored.
61117 func (g *processGuard) terminate(_ bool) {
62118 if g == nil {
63119 return
64120 }
65121 g.mu.Lock()
66- job := g.job
67- g.mu.Unlock()
68- if job != 0 {
69- _ = windows.TerminateJobObject(job, 1)
70- return
71- }
72- if g.cmd != nil && g.cmd.Process != nil {
73- _ = g.cmd.Process.Kill()
122+ defer g.mu.Unlock()
123+ if g.job != 0 {
124+ _ = windows.TerminateJobObject(g.job, 1)
74125 }
75126 }
76127
77-// alive is always false: terminate is already forceful.
78-func (g *processGuard) alive() bool { return false }
79-
80-// release closes the job handle, which kills any survivor.
81-func (g *processGuard) release() {
128+// cleanup kills the job and closes its handle, once.
129+func (g *processGuard) cleanup() {
82130 if g == nil {
83131 return
84132 }
85- g.mu.Lock()
86- job := g.job
87- g.job = 0
88- g.mu.Unlock()
89- if job != 0 {
90- _ = windows.CloseHandle(job)
91- }
133+ g.once.Do(func() {
134+ g.mu.Lock()
135+ defer g.mu.Unlock()
136+ if g.job != 0 {
137+ _ = windows.TerminateJobObject(g.job, 1)
138+ _ = windows.CloseHandle(g.job)
139+ g.job = 0
140+ }
141+ })
92142 }
@@ -3,90 +3,140 @@
3 package mcp3 package mcp
4 4
5 import (5 import (
6+ "errors"
7+ "fmt"
6 "os/exec"8 "os/exec"
7 "sync"9 "sync"
10+ "syscall"
8 "unsafe"11 "unsafe"
9 12
10 "golang.org/x/sys/windows"13 "golang.org/x/sys/windows"
11 )14 )
12 15
13-func isolateProcess(_ *exec.Cmd) {}16+// isolateProcess starts the server suspended. The guard assigns it to a Job
17+// Object before its first instruction runs and then resumes it, so no
18+// descendant can be created outside the job.
19+func isolateProcess(cmd *exec.Cmd) {
20+ if cmd.SysProcAttr == nil {
21+ cmd.SysProcAttr = &syscall.SysProcAttr{}
22+ }
23+ cmd.SysProcAttr.CreationFlags |= windows.CREATE_SUSPENDED
24+}
14 25
15-// processGuard assigns the server to a Job Object with26+// processGuard owns a Job Object with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE that
16-// JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, so descendants die with the server (and27+// contains the server and all its descendants. The mutex is held across every
17-// with zut, should zut itself be killed).28+// use of the handle so a concurrent release cannot close it mid-call.
18-//
19-// Assignment happens right after Start; a server that spawns children in the
20-// first instants of its life can escape the job. If the job cannot be created
21-// or assigned, the guard falls back to killing the direct child.
22 type processGuard struct {29 type processGuard struct {
23- cmd *exec.Cmd30+ mu sync.Mutex
24-31+ job windows.Handle // 0 once released
25- mu sync.Mutex32+ once sync.Once
26- job windows.Handle
27 }33 }
28 34
29-func newProcessGuard(cmd *exec.Cmd) *processGuard {35+// newProcessGuard is called right after cmd.Start with the process still
30- g := &processGuard{cmd: cmd}36+// suspended. It fails closed: if the job cannot be set up, the child is killed
37+// and an error is returned.
38+func newProcessGuard(cmd *exec.Cmd) (*processGuard, error) {
31 if cmd == nil || cmd.Process == nil {39 if cmd == nil || cmd.Process == nil {
32- return g40+ return nil, errors.New("process not started")
41+ }
42+ fail := func(what string, err error) (*processGuard, error) {
43+ _ = cmd.Process.Kill()
44+ return nil, fmt.Errorf("%s: %w", what, err)
33 }45 }
34 job, err := windows.CreateJobObject(nil, nil)46 job, err := windows.CreateJobObject(nil, nil)
35 if err != nil {47 if err != nil {
36- return g48+ return fail("create job object", err)
37 }49 }
38 var info windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION50 var info windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION
39 info.BasicLimitInformation.LimitFlags = windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE51 info.BasicLimitInformation.LimitFlags = windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
40 if _, err := windows.SetInformationJobObject(job, windows.JobObjectExtendedLimitInformation,52 if _, err := windows.SetInformationJobObject(job, windows.JobObjectExtendedLimitInformation,
41 uintptr(unsafe.Pointer(&info)), uint32(unsafe.Sizeof(info))); err != nil {53 uintptr(unsafe.Pointer(&info)), uint32(unsafe.Sizeof(info))); err != nil {
42 _ = windows.CloseHandle(job)54 _ = windows.CloseHandle(job)
43- return g55+ return fail("configure job object", err)
44 }56 }
45 proc, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(cmd.Process.Pid))57 proc, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(cmd.Process.Pid))
46 if err != nil {58 if err != nil {
47 _ = windows.CloseHandle(job)59 _ = windows.CloseHandle(job)
48- return g60+ return fail("open server process", err)
49 }61 }
50- defer windows.CloseHandle(proc)62+ err = windows.AssignProcessToJobObject(job, proc)
51- if err := windows.AssignProcessToJobObject(job, proc); err != nil {63+ _ = windows.CloseHandle(proc)
64+ if err != nil {
52 _ = windows.CloseHandle(job)65 _ = windows.CloseHandle(job)
53- return g66+ return fail("assign server to job object", err)
54 }67 }
55- g.job = job68+ if err := resumeProcess(uint32(cmd.Process.Pid)); err != nil {
56- return g69+ _ = windows.CloseHandle(job) // kills the job's only member
70+ return fail("resume server", err)
71+ }
72+ return &processGuard{job: job}, nil
57 }73 }
58 74
59-// terminate kills the job (every process in it) or, without a job, the direct75+// resumeProcess resumes every suspended thread of pid (a CREATE_SUSPENDED
60-// child. Windows has no graceful group signal, so force is ignored.76+// process has exactly its main thread).
77+func resumeProcess(pid uint32) error {
78+ snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPTHREAD, 0)
79+ if err != nil {
80+ return err
81+ }
82+ defer windows.CloseHandle(snap)
83+ entry := windows.ThreadEntry32{Size: uint32(unsafe.Sizeof(windows.ThreadEntry32{}))}
84+ if err := windows.Thread32First(snap, &entry); err != nil {
85+ return err
86+ }
87+ resumed := 0
88+ for {
89+ if entry.OwnerProcessID == pid {
90+ th, err := windows.OpenThread(windows.THREAD_SUSPEND_RESUME, false, entry.ThreadID)
91+ if err != nil {
92+ return err
93+ }
94+ _, err = windows.ResumeThread(th)
95+ _ = windows.CloseHandle(th)
96+ if err != nil {
97+ return err
98+ }
99+ resumed++
100+ }
101+ entry.Size = uint32(unsafe.Sizeof(entry))
102+ if err := windows.Thread32Next(snap, &entry); err != nil {
103+ if errors.Is(err, windows.ERROR_NO_MORE_FILES) {
104+ break
105+ }
106+ return err
107+ }
108+ }
109+ if resumed == 0 {
110+ return errors.New("no thread to resume")
111+ }
112+ return nil
113+}
114+
115+// terminate kills every process in the job. Windows has no graceful group
116+// signal, so force is ignored.
61 func (g *processGuard) terminate(_ bool) {117 func (g *processGuard) terminate(_ bool) {
62 if g == nil {118 if g == nil {
63 return119 return
64 }120 }
65 g.mu.Lock()121 g.mu.Lock()
66- job := g.job122+ defer g.mu.Unlock()
67- g.mu.Unlock()123+ if g.job != 0 {
68- if job != 0 {124+ _ = windows.TerminateJobObject(g.job, 1)
69- _ = windows.TerminateJobObject(job, 1)
70- return
71- }
72- if g.cmd != nil && g.cmd.Process != nil {
73- _ = g.cmd.Process.Kill()
74 }125 }
75 }126 }
76 127
77-// alive is always false: terminate is already forceful.128+// cleanup kills the job and closes its handle, once.
78-func (g *processGuard) alive() bool { return false }129+func (g *processGuard) cleanup() {
79-
80-// release closes the job handle, which kills any survivor.
81-func (g *processGuard) release() {
82 if g == nil {130 if g == nil {
83 return131 return
84 }132 }
85- g.mu.Lock()133+ g.once.Do(func() {
86- job := g.job134+ g.mu.Lock()
87- g.job = 0135+ defer g.mu.Unlock()
88- g.mu.Unlock()136+ if g.job != 0 {
89- if job != 0 {137+ _ = windows.TerminateJobObject(g.job, 1)
90- _ = windows.CloseHandle(job)138+ _ = windows.CloseHandle(g.job)
91- }139+ g.job = 0
140+ }
141+ })
92 }142 }
A packages/agent/mcp/round2_test.go +275 -0
@@ -0,0 +1,275 @@
1+package mcp
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "io"
8+ "net/url"
9+ "strings"
10+ "testing"
11+ "time"
12+)
13+
14+// Regression tests for the second review round.
15+
16+// ---- GO-015: write cancellation ----
17+
18+func TestStdioQueuedFrameIsWithdrawnButStartedWriteFailsTransport(t *testing.T) {
19+ c, events := connectFake(t, nil)
20+ st := c.t.(*stdioTransport)
21+
22+ // The server stops reading stdin.
23+ stallCtx, stallCancel := context.WithCancel(context.Background())
24+ defer stallCancel()
25+ go func() { _, _ = c.CallTool(stallCtx, "stall", []byte(`{}`)) }()
26+ waitEvent(t, events, "stalled")
27+
28+ // Frame A is far larger than the pipe buffer: its write starts and blocks.
29+ started := st.writesStarted.Load()
30+ aCtx, aCancel := context.WithCancel(context.Background())
31+ big := bytes.Repeat([]byte("a"), 4<<20)
32+ aDone := make(chan struct {
33+ sent bool
34+ err error
35+ }, 1)
36+ go func() {
37+ sent, err := st.writeFrame(aCtx, big)
38+ aDone <- struct {
39+ sent bool
40+ err error
41+ }{sent, err}
42+ }()
43+ eventually(t, "frame A to start writing", func() bool { return st.writesStarted.Load() == started+1 })
44+
45+ // Frame B queues behind A. Its caller gives up before B is dispatched:
46+ // B must be withdrawn, never written, and the transport must stay usable.
47+ bCtx, bCancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
48+ defer bCancel()
49+ sent, err := st.writeFrame(bCtx, []byte(`{"jsonrpc":"2.0","method":"notifications/never"}`))
50+ if sent || !errors.Is(err, context.DeadlineExceeded) {
51+ t.Fatalf("queued frame: sent=%v err=%v, want withdrawn with deadline error", sent, err)
52+ }
53+ select {
54+ case <-st.done():
55+ t.Fatal("withdrawing a queued frame must not fail the transport")
56+ default:
57+ }
58+
59+ // Now A's caller gives up mid-write: the frame cannot be withdrawn, so the
60+ // transport fails and stdin is closed.
61+ aCancel()
62+ select {
63+ case r := <-aDone:
64+ if !r.sent || !errors.Is(r.err, context.Canceled) {
65+ t.Errorf("started frame: sent=%v err=%v", r.sent, r.err)
66+ }
67+ case <-time.After(5 * time.Second):
68+ t.Fatal("caller still blocked after cancellation")
69+ }
70+ select {
71+ case <-st.done():
72+ case <-time.After(5 * time.Second):
73+ t.Fatal("transport still live with a half-written frame")
74+ }
75+ if err := st.failure(); err == nil || !strings.Contains(err.Error(), "interrupted") {
76+ t.Errorf("failure = %v", err)
77+ }
78+ // B was never handed to the writer.
79+ if got := st.writesStarted.Load(); got != started+1 {
80+ t.Errorf("writes started = %d, want %d (frame B must never be written)", got, started+1)
81+ }
82+}
83+
84+func TestManagerReconnectsAfterInterruptedWrite(t *testing.T) {
85+ m, events := startFake(t, nil, func(c *ServerConfig) { c.ToolTimeoutSec = 0.4 })
86+ // Park the server: the call is delivered, then it stops reading stdin.
87+ if res := execTool(t, findTool(t, m, "mcp__fake__stall"), `{}`); !res.IsError {
88+ t.Fatalf("stall result = %q", resultText(res))
89+ }
90+ waitEvent(t, events, "stalled")
91+ // A request too large for the pipe times out mid-write.
92+ big := `{"text":"` + strings.Repeat("a", 4<<20) + `"}`
93+ if res := execTool(t, findTool(t, m, "mcp__fake__echo"), big); !res.IsError {
94+ t.Fatalf("big call result = %q", resultText(res))
95+ }
96+ eventually(t, "status to show the dropped connection", func() bool { return !m.Status()[0].Connected })
97+ // The next call starts a fresh server and works.
98+ if got := resultText(execTool(t, findTool(t, m, "mcp__fake__echo"), `{"text":"again"}`)); got != "echo: again" {
99+ t.Fatalf("after reconnect = %q", got)
100+ }
101+ if n := countEvents(events, "start"); n != 2 {
102+ t.Errorf("server starts = %d, want 2", n)
103+ }
104+}
105+
106+// ---- GO-004, GO-018: stderr redaction ----
107+
108+func newTestStderrTransport(cfg ServerConfig) *stdioTransport {
109+ return &stdioTransport{red: newRedactor(cfg), stderrDone: make(chan struct{})}
110+}
111+
112+func TestStderrSecretStraddlingTruncationIsRedacted(t *testing.T) {
113+ const secret = "SECRETVALUE-0123456789"
114+ tr := newTestStderrTransport(ServerConfig{Env: map[string]string{"K": secret}})
115+ // The secret starts before the 4096-byte display cut and ends after it.
116+ line := strings.Repeat("x", maxStderrLine-len(secret)/2) + secret + strings.Repeat("y", 300) + "\nnext line\n"
117+ tr.readStderr(strings.NewReader(line))
118+ tail := tr.stderrTail()
119+ for _, frag := range []string{"SECRET", "VALUE", "0123", secret[:8]} {
120+ if strings.Contains(tail, frag) {
121+ t.Errorf("secret fragment %q leaked: %.80q…", frag, tail[maxStderrLine-60:])
122+ }
123+ }
124+ if !strings.Contains(tail, redactedMarker) || !strings.Contains(tail, "[line truncated]") || !strings.Contains(tail, "next line") {
125+ t.Errorf("unexpected tail shape (len %d)", len(tail))
126+ }
127+}
128+
129+func TestStderrSecretOnTheCutIsNotPartiallyShown(t *testing.T) {
130+ // A line whose redacted form still ends in a secret prefix would leak it;
131+ // trimPartialSecret removes such a tail.
132+ red := newRedactor(ServerConfig{Env: map[string]string{"K": "TOPSECRET99"}})
133+ for _, in := range []string{"abc TOP", "abc TOPSECRET9", "abc TOPSECRET", "abc"} {
134+ got := red.trimPartialSecret(in)
135+ if strings.Contains(got, "TOP") {
136+ t.Errorf("trimPartialSecret(%q) = %q", in, got)
137+ }
138+ }
139+ if got := red.trimPartialSecret("keep TOPS and"); got != "keep TOPS and" {
140+ t.Errorf("interior text changed: %q", got)
141+ }
142+ var nilRed *redactor
143+ if got := nilRed.trimPartialSecret("x"); got != "x" {
144+ t.Error("nil redactor must pass text through")
145+ }
146+}
147+
148+func TestStdioStderrSecretEndToEnd(t *testing.T) {
149+ const secret = "E2ESECRET-abcdefghij"
150+ c, _ := connectFake(t, map[string]string{"FAKE_SECRET": secret})
151+ if got := callText(t, c, "stderr_secret", `{}`); got != "written" {
152+ t.Fatal(got)
153+ }
154+ st := c.t.(*stdioTransport)
155+ eventually(t, "stderr to be read", func() bool { return strings.Contains(st.stderrTail(), "after-secret") })
156+ tail := st.stderrTail()
157+ if strings.Contains(tail, "E2ESECRET") || strings.Contains(tail, "abcdef") {
158+ t.Errorf("secret (or prefix) leaked")
159+ }
160+}
161+
162+func TestRedactionSurvivesControlSequencesInsideSecret(t *testing.T) {
163+ // Case 1: the server's output splits the secret with escape sequences.
164+ red := newRedactor(ServerConfig{Env: map[string]string{"K": "token-abcdef123"}})
165+ out := red.scrub("leak: tok\x1b[0men-abc\x1b]0;x\x07def123 end")
166+ if strings.Contains(out, "abcdef123") || strings.Contains(out, "token") || !strings.Contains(out, redactedMarker) {
167+ t.Errorf("out = %q", out)
168+ }
169+ // Case 2: the configured secret itself contains control bytes.
170+ red = newRedactor(ServerConfig{Env: map[string]string{"K": "se\x1b[1mcret-value-9"}})
171+ out = red.scrub("echo se\x1b[1mcret-value-9 / secret-value-9")
172+ if strings.Contains(out, "cret-value-9") {
173+ t.Errorf("out = %q", out)
174+ }
175+}
176+
177+// ---- GO-016: request-building errors ----
178+
179+func TestHTTPRequestBuildErrorIsScrubbed(t *testing.T) {
180+ const secret = "QUERYSECRET-42"
181+ cfg := ServerConfig{Type: "http", URL: "http://bad host/path?token=" + secret}
182+ tr, err := newHTTPTransport("x", cfg, nil)
183+ if err != nil {
184+ t.Fatal(err)
185+ }
186+ defer tr.close()
187+ _, err = tr.request(context.Background(), 1, "initialize", nil)
188+ if err == nil {
189+ t.Fatal("want an error for an unparsable URL")
190+ }
191+ if strings.Contains(err.Error(), secret) {
192+ t.Errorf("error leaks the query token: %v", err)
193+ }
194+ // The same path also feeds close() and the GET stream.
195+ if _, err := tr.newRequest(context.Background(), "GET", nil); err == nil || strings.Contains(err.Error(), secret) {
196+ t.Errorf("newRequest error = %v", err)
197+ }
198+}
199+
200+// ---- GO-017: encoded URL secrets ----
201+
202+func TestURLSecretsIncludePercentDecodedForms(t *testing.T) {
203+ raw := "https://us%40er:p%40ssw0rd@h.example/p?api_key=a%2Bb%20c&other=plain-value&semi=x;y=z-token"
204+ got := map[string]bool{}
205+ for _, v := range urlSecrets(raw) {
206+ got[v] = true
207+ }
208+ for _, want := range []string{"us%40er", "us@er", "p%40ssw0rd", "p@ssw0rd", "a%2Bb%20c", "a+b c", "plain-value"} {
209+ if !got[want] {
210+ t.Errorf("urlSecrets missing %q (have %v)", want, got)
211+ }
212+ }
213+ red := newRedactor(ServerConfig{URL: raw})
214+ out := red.scrub("server said p@ssw0rd and a+b c and a%2Bb%20c and plain-value")
215+ for _, secret := range []string{"p@ssw0rd", "a+b c", "a%2Bb%20c", "plain-value"} {
216+ if strings.Contains(out, secret) {
217+ t.Errorf("%q leaked in %q", secret, out)
218+ }
219+ }
220+ // A URL that net/url refuses to parse still yields its secrets.
221+ unparsable := strings.Replace("http://bad_host/x?tok=SECRETQ99", "_", " ", 1) // space in the host
222+ if _, err := url.Parse(unparsable); err == nil {
223+ t.Fatal("test premise: URL should not parse")
224+ }
225+ bad := urlSecrets(unparsable)
226+ found := false
227+ for _, v := range bad {
228+ found = found || v == "SECRETQ99"
229+ }
230+ if !found {
231+ t.Errorf("unparsable URL secrets = %v", bad)
232+ }
233+}
234+
235+// ---- GO-014: per-field budget ----
236+
237+func TestBudgetedFieldIsBoundedBeforeFormatting(t *testing.T) {
238+ b := &budgetedText{}
239+ huge := strings.Repeat("é", 10<<20) // 20 MiB of two-byte runes
240+ got := b.field(huge)
241+ if len(got) > maxResultTextBytes+1 {
242+ t.Errorf("field not clipped: %d bytes", len(got))
243+ }
244+ if !strings.HasPrefix(huge, got) && got != "" {
245+ t.Error("clipped field is not a prefix")
246+ }
247+ if clipUTF8("日本語", 4) != "日" {
248+ t.Errorf("clipUTF8 split a rune: %q", clipUTF8("日本語", 4))
249+ }
250+ b.write(strings.Repeat("a", maxResultTextBytes-10))
251+ if got := b.field(strings.Repeat("b", 1000)); len(got) > 11 {
252+ t.Errorf("field ignores remaining budget: %d", len(got))
253+ }
254+}
255+
256+func TestListResourcesHugeDescription(t *testing.T) {
257+ m, _ := startFake(t, map[string]string{"FAKE_HUGE_DESC": "5242880"}, nil) // 5 MiB
258+ text := resultText(execTool(t, findTool(t, m, ListResourcesToolName), `{}`))
259+ if len(text) > maxResultTextBytes+len("\n... [truncated at 51200 bytes]") {
260+ t.Errorf("output %d bytes exceeds the budget", len(text))
261+ }
262+ if !strings.Contains(text, "mem://huge") || !strings.Contains(text, "[truncated") {
263+ t.Errorf("unexpected output head: %.100q", text)
264+ }
265+}
266+
267+// ---- readStderr bookkeeping ----
268+
269+func TestReadStderrHandlesEOFWithoutNewline(t *testing.T) {
270+ tr := newTestStderrTransport(ServerConfig{})
271+ tr.readStderr(io.MultiReader(strings.NewReader("one\ntwo"), strings.NewReader("")))
272+ if got := tr.stderrTail(); got != "one\ntwo" {
273+ t.Errorf("tail = %q", got)
274+ }
275+}
@@ -0,0 +1,275 @@
1+package mcp
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "io"
8+ "net/url"
9+ "strings"
10+ "testing"
11+ "time"
12+)
13+
14+// Regression tests for the second review round.
15+
16+// ---- GO-015: write cancellation ----
17+
18+func TestStdioQueuedFrameIsWithdrawnButStartedWriteFailsTransport(t *testing.T) {
19+ c, events := connectFake(t, nil)
20+ st := c.t.(*stdioTransport)
21+
22+ // The server stops reading stdin.
23+ stallCtx, stallCancel := context.WithCancel(context.Background())
24+ defer stallCancel()
25+ go func() { _, _ = c.CallTool(stallCtx, "stall", []byte(`{}`)) }()
26+ waitEvent(t, events, "stalled")
27+
28+ // Frame A is far larger than the pipe buffer: its write starts and blocks.
29+ started := st.writesStarted.Load()
30+ aCtx, aCancel := context.WithCancel(context.Background())
31+ big := bytes.Repeat([]byte("a"), 4<<20)
32+ aDone := make(chan struct {
33+ sent bool
34+ err error
35+ }, 1)
36+ go func() {
37+ sent, err := st.writeFrame(aCtx, big)
38+ aDone <- struct {
39+ sent bool
40+ err error
41+ }{sent, err}
42+ }()
43+ eventually(t, "frame A to start writing", func() bool { return st.writesStarted.Load() == started+1 })
44+
45+ // Frame B queues behind A. Its caller gives up before B is dispatched:
46+ // B must be withdrawn, never written, and the transport must stay usable.
47+ bCtx, bCancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
48+ defer bCancel()
49+ sent, err := st.writeFrame(bCtx, []byte(`{"jsonrpc":"2.0","method":"notifications/never"}`))
50+ if sent || !errors.Is(err, context.DeadlineExceeded) {
51+ t.Fatalf("queued frame: sent=%v err=%v, want withdrawn with deadline error", sent, err)
52+ }
53+ select {
54+ case <-st.done():
55+ t.Fatal("withdrawing a queued frame must not fail the transport")
56+ default:
57+ }
58+
59+ // Now A's caller gives up mid-write: the frame cannot be withdrawn, so the
60+ // transport fails and stdin is closed.
61+ aCancel()
62+ select {
63+ case r := <-aDone:
64+ if !r.sent || !errors.Is(r.err, context.Canceled) {
65+ t.Errorf("started frame: sent=%v err=%v", r.sent, r.err)
66+ }
67+ case <-time.After(5 * time.Second):
68+ t.Fatal("caller still blocked after cancellation")
69+ }
70+ select {
71+ case <-st.done():
72+ case <-time.After(5 * time.Second):
73+ t.Fatal("transport still live with a half-written frame")
74+ }
75+ if err := st.failure(); err == nil || !strings.Contains(err.Error(), "interrupted") {
76+ t.Errorf("failure = %v", err)
77+ }
78+ // B was never handed to the writer.
79+ if got := st.writesStarted.Load(); got != started+1 {
80+ t.Errorf("writes started = %d, want %d (frame B must never be written)", got, started+1)
81+ }
82+}
83+
84+func TestManagerReconnectsAfterInterruptedWrite(t *testing.T) {
85+ m, events := startFake(t, nil, func(c *ServerConfig) { c.ToolTimeoutSec = 0.4 })
86+ // Park the server: the call is delivered, then it stops reading stdin.
87+ if res := execTool(t, findTool(t, m, "mcp__fake__stall"), `{}`); !res.IsError {
88+ t.Fatalf("stall result = %q", resultText(res))
89+ }
90+ waitEvent(t, events, "stalled")
91+ // A request too large for the pipe times out mid-write.
92+ big := `{"text":"` + strings.Repeat("a", 4<<20) + `"}`
93+ if res := execTool(t, findTool(t, m, "mcp__fake__echo"), big); !res.IsError {
94+ t.Fatalf("big call result = %q", resultText(res))
95+ }
96+ eventually(t, "status to show the dropped connection", func() bool { return !m.Status()[0].Connected })
97+ // The next call starts a fresh server and works.
98+ if got := resultText(execTool(t, findTool(t, m, "mcp__fake__echo"), `{"text":"again"}`)); got != "echo: again" {
99+ t.Fatalf("after reconnect = %q", got)
100+ }
101+ if n := countEvents(events, "start"); n != 2 {
102+ t.Errorf("server starts = %d, want 2", n)
103+ }
104+}
105+
106+// ---- GO-004, GO-018: stderr redaction ----
107+
108+func newTestStderrTransport(cfg ServerConfig) *stdioTransport {
109+ return &stdioTransport{red: newRedactor(cfg), stderrDone: make(chan struct{})}
110+}
111+
112+func TestStderrSecretStraddlingTruncationIsRedacted(t *testing.T) {
113+ const secret = "SECRETVALUE-0123456789"
114+ tr := newTestStderrTransport(ServerConfig{Env: map[string]string{"K": secret}})
115+ // The secret starts before the 4096-byte display cut and ends after it.
116+ line := strings.Repeat("x", maxStderrLine-len(secret)/2) + secret + strings.Repeat("y", 300) + "\nnext line\n"
117+ tr.readStderr(strings.NewReader(line))
118+ tail := tr.stderrTail()
119+ for _, frag := range []string{"SECRET", "VALUE", "0123", secret[:8]} {
120+ if strings.Contains(tail, frag) {
121+ t.Errorf("secret fragment %q leaked: %.80q…", frag, tail[maxStderrLine-60:])
122+ }
123+ }
124+ if !strings.Contains(tail, redactedMarker) || !strings.Contains(tail, "[line truncated]") || !strings.Contains(tail, "next line") {
125+ t.Errorf("unexpected tail shape (len %d)", len(tail))
126+ }
127+}
128+
129+func TestStderrSecretOnTheCutIsNotPartiallyShown(t *testing.T) {
130+ // A line whose redacted form still ends in a secret prefix would leak it;
131+ // trimPartialSecret removes such a tail.
132+ red := newRedactor(ServerConfig{Env: map[string]string{"K": "TOPSECRET99"}})
133+ for _, in := range []string{"abc TOP", "abc TOPSECRET9", "abc TOPSECRET", "abc"} {
134+ got := red.trimPartialSecret(in)
135+ if strings.Contains(got, "TOP") {
136+ t.Errorf("trimPartialSecret(%q) = %q", in, got)
137+ }
138+ }
139+ if got := red.trimPartialSecret("keep TOPS and"); got != "keep TOPS and" {
140+ t.Errorf("interior text changed: %q", got)
141+ }
142+ var nilRed *redactor
143+ if got := nilRed.trimPartialSecret("x"); got != "x" {
144+ t.Error("nil redactor must pass text through")
145+ }
146+}
147+
148+func TestStdioStderrSecretEndToEnd(t *testing.T) {
149+ const secret = "E2ESECRET-abcdefghij"
150+ c, _ := connectFake(t, map[string]string{"FAKE_SECRET": secret})
151+ if got := callText(t, c, "stderr_secret", `{}`); got != "written" {
152+ t.Fatal(got)
153+ }
154+ st := c.t.(*stdioTransport)
155+ eventually(t, "stderr to be read", func() bool { return strings.Contains(st.stderrTail(), "after-secret") })
156+ tail := st.stderrTail()
157+ if strings.Contains(tail, "E2ESECRET") || strings.Contains(tail, "abcdef") {
158+ t.Errorf("secret (or prefix) leaked")
159+ }
160+}
161+
162+func TestRedactionSurvivesControlSequencesInsideSecret(t *testing.T) {
163+ // Case 1: the server's output splits the secret with escape sequences.
164+ red := newRedactor(ServerConfig{Env: map[string]string{"K": "token-abcdef123"}})
165+ out := red.scrub("leak: tok\x1b[0men-abc\x1b]0;x\x07def123 end")
166+ if strings.Contains(out, "abcdef123") || strings.Contains(out, "token") || !strings.Contains(out, redactedMarker) {
167+ t.Errorf("out = %q", out)
168+ }
169+ // Case 2: the configured secret itself contains control bytes.
170+ red = newRedactor(ServerConfig{Env: map[string]string{"K": "se\x1b[1mcret-value-9"}})
171+ out = red.scrub("echo se\x1b[1mcret-value-9 / secret-value-9")
172+ if strings.Contains(out, "cret-value-9") {
173+ t.Errorf("out = %q", out)
174+ }
175+}
176+
177+// ---- GO-016: request-building errors ----
178+
179+func TestHTTPRequestBuildErrorIsScrubbed(t *testing.T) {
180+ const secret = "QUERYSECRET-42"
181+ cfg := ServerConfig{Type: "http", URL: "http://bad host/path?token=" + secret}
182+ tr, err := newHTTPTransport("x", cfg, nil)
183+ if err != nil {
184+ t.Fatal(err)
185+ }
186+ defer tr.close()
187+ _, err = tr.request(context.Background(), 1, "initialize", nil)
188+ if err == nil {
189+ t.Fatal("want an error for an unparsable URL")
190+ }
191+ if strings.Contains(err.Error(), secret) {
192+ t.Errorf("error leaks the query token: %v", err)
193+ }
194+ // The same path also feeds close() and the GET stream.
195+ if _, err := tr.newRequest(context.Background(), "GET", nil); err == nil || strings.Contains(err.Error(), secret) {
196+ t.Errorf("newRequest error = %v", err)
197+ }
198+}
199+
200+// ---- GO-017: encoded URL secrets ----
201+
202+func TestURLSecretsIncludePercentDecodedForms(t *testing.T) {
203+ raw := "https://us%40er:p%40ssw0rd@h.example/p?api_key=a%2Bb%20c&other=plain-value&semi=x;y=z-token"
204+ got := map[string]bool{}
205+ for _, v := range urlSecrets(raw) {
206+ got[v] = true
207+ }
208+ for _, want := range []string{"us%40er", "us@er", "p%40ssw0rd", "p@ssw0rd", "a%2Bb%20c", "a+b c", "plain-value"} {
209+ if !got[want] {
210+ t.Errorf("urlSecrets missing %q (have %v)", want, got)
211+ }
212+ }
213+ red := newRedactor(ServerConfig{URL: raw})
214+ out := red.scrub("server said p@ssw0rd and a+b c and a%2Bb%20c and plain-value")
215+ for _, secret := range []string{"p@ssw0rd", "a+b c", "a%2Bb%20c", "plain-value"} {
216+ if strings.Contains(out, secret) {
217+ t.Errorf("%q leaked in %q", secret, out)
218+ }
219+ }
220+ // A URL that net/url refuses to parse still yields its secrets.
221+ unparsable := strings.Replace("http://bad_host/x?tok=SECRETQ99", "_", " ", 1) // space in the host
222+ if _, err := url.Parse(unparsable); err == nil {
223+ t.Fatal("test premise: URL should not parse")
224+ }
225+ bad := urlSecrets(unparsable)
226+ found := false
227+ for _, v := range bad {
228+ found = found || v == "SECRETQ99"
229+ }
230+ if !found {
231+ t.Errorf("unparsable URL secrets = %v", bad)
232+ }
233+}
234+
235+// ---- GO-014: per-field budget ----
236+
237+func TestBudgetedFieldIsBoundedBeforeFormatting(t *testing.T) {
238+ b := &budgetedText{}
239+ huge := strings.Repeat("é", 10<<20) // 20 MiB of two-byte runes
240+ got := b.field(huge)
241+ if len(got) > maxResultTextBytes+1 {
242+ t.Errorf("field not clipped: %d bytes", len(got))
243+ }
244+ if !strings.HasPrefix(huge, got) && got != "" {
245+ t.Error("clipped field is not a prefix")
246+ }
247+ if clipUTF8("日本語", 4) != "日" {
248+ t.Errorf("clipUTF8 split a rune: %q", clipUTF8("日本語", 4))
249+ }
250+ b.write(strings.Repeat("a", maxResultTextBytes-10))
251+ if got := b.field(strings.Repeat("b", 1000)); len(got) > 11 {
252+ t.Errorf("field ignores remaining budget: %d", len(got))
253+ }
254+}
255+
256+func TestListResourcesHugeDescription(t *testing.T) {
257+ m, _ := startFake(t, map[string]string{"FAKE_HUGE_DESC": "5242880"}, nil) // 5 MiB
258+ text := resultText(execTool(t, findTool(t, m, ListResourcesToolName), `{}`))
259+ if len(text) > maxResultTextBytes+len("\n... [truncated at 51200 bytes]") {
260+ t.Errorf("output %d bytes exceeds the budget", len(text))
261+ }
262+ if !strings.Contains(text, "mem://huge") || !strings.Contains(text, "[truncated") {
263+ t.Errorf("unexpected output head: %.100q", text)
264+ }
265+}
266+
267+// ---- readStderr bookkeeping ----
268+
269+func TestReadStderrHandlesEOFWithoutNewline(t *testing.T) {
270+ tr := newTestStderrTransport(ServerConfig{})
271+ tr.readStderr(io.MultiReader(strings.NewReader("one\ntwo"), strings.NewReader("")))
272+ if got := tr.stderrTail(); got != "one\ntwo" {
273+ t.Errorf("tail = %q", got)
274+ }
275+}
M packages/agent/mcp/sanitize.go +75 -12
@@ -1,6 +1,7 @@
11 package mcp
22
33 import (
4+ "net/url"
45 "os"
56 "regexp"
67 "sort"
@@ -24,6 +25,9 @@ const (
2425 // A nil *redactor still strips URLs' credentials and control characters.
2526 type redactor struct {
2627 secrets []string
28+ // maxLen is the length of the longest secret, used to size the overlap
29+ // when long lines are cut for display.
30+ maxLen int
2731 }
2832
2933 // newRedactor collects the secret-bearing values of one server configuration:
@@ -37,6 +41,12 @@ func newRedactor(cfg ServerConfig) *redactor {
3741 return
3842 }
3943 set[v] = true
44+ // The control-stripped form too: text is sanitized before it is
45+ // matched, so a secret that itself contains escape bytes must match
46+ // in its sanitized shape.
47+ if clean := sanitizeText(v, true); len(clean) >= minSecretLen {
48+ set[clean] = true
49+ }
4050 // "Bearer abc123" is also worth redacting as just "abc123".
4151 if strings.ContainsAny(v, " \t") {
4252 for _, field := range strings.Fields(v) {
@@ -75,6 +85,7 @@ func newRedactor(cfg ServerConfig) *redactor {
7585 r := &redactor{}
7686 for v := range set {
7787 r.secrets = append(r.secrets, v)
88+ r.maxLen = max(r.maxLen, len(v))
7889 }
7990 // Longest first so a secret containing another is replaced whole.
8091 sort.Slice(r.secrets, func(i, j int) bool {
@@ -86,30 +97,52 @@ func newRedactor(cfg ServerConfig) *redactor {
8697 return r
8798 }
8899
89-// urlSecrets returns the userinfo password/user and query values of raw.
100+// urlSecrets returns the secret-bearing parts of raw: userinfo (user and
101+// password) and query values, both as written and percent-decoded. It splits
102+// the text itself instead of requiring url.Parse to succeed, because the URLs
103+// that matter most here are the malformed ones that fail to parse.
90104 func urlSecrets(raw string) []string {
105+ var out []string
106+ add := func(v string) {
107+ if v == "" {
108+ return
109+ }
110+ out = append(out, v)
111+ if d, err := url.PathUnescape(v); err == nil && d != v {
112+ out = append(out, d)
113+ }
114+ if d, err := url.QueryUnescape(v); err == nil && d != v {
115+ out = append(out, d)
116+ }
117+ }
91118 rest := raw
92119 if i := strings.Index(rest, "://"); i >= 0 {
93120 rest = rest[i+3:]
94121 }
95- var out []string
96122 authority := rest
97123 if i := strings.IndexAny(authority, "/?#"); i >= 0 {
98124 authority = authority[:i]
99125 }
100126 if i := strings.LastIndex(authority, "@"); i >= 0 {
101- for _, part := range strings.SplitN(authority[:i], ":", 2) {
102- out = append(out, part)
103- }
127+ user, pass, _ := strings.Cut(authority[:i], ":")
128+ add(user)
129+ add(pass)
104130 }
105131 if i := strings.Index(rest, "?"); i >= 0 {
106132 query := rest[i+1:]
107133 if j := strings.Index(query, "#"); j >= 0 {
108134 query = query[:j]
109135 }
110- for _, pair := range strings.Split(query, "&") {
136+ for _, pair := range strings.FieldsFunc(query, func(r rune) bool { return r == '&' || r == ';' }) {
111137 if _, v, ok := strings.Cut(pair, "="); ok {
112- out = append(out, v)
138+ add(v)
139+ }
140+ }
141+ if values, err := url.ParseQuery(query); err == nil {
142+ for _, vs := range values {
143+ for _, v := range vs {
144+ add(v)
145+ }
113146 }
114147 }
115148 }
@@ -122,13 +155,43 @@ func (r *redactor) scrub(s string) string {
122155 if s == "" {
123156 return s
124157 }
125- if r != nil {
126- for _, secret := range r.secrets {
127- s = strings.ReplaceAll(s, secret, redactedMarker)
158+ // Redact, strip control sequences, then redact again: a secret that has
159+ // escape bytes inserted into it only becomes matchable once they are gone.
160+ s = r.redact(s)
161+ s = sanitizeText(s, true)
162+ s = r.redact(s)
163+ return scrubURLs(s)
164+}
165+
166+// redact replaces configured secret values. A nil redactor changes nothing.
167+func (r *redactor) redact(s string) string {
168+ if r == nil {
169+ return s
170+ }
171+ for _, secret := range r.secrets {
172+ s = strings.ReplaceAll(s, secret, redactedMarker)
173+ }
174+ return s
175+}
176+
177+// trimPartialSecret removes a trailing fragment that is a proper prefix of a
178+// secret. It is for text that was cut at an arbitrary byte: a secret that
179+// straddles the cut cannot be matched as a whole, and its prefix must not
180+// survive either.
181+func (r *redactor) trimPartialSecret(s string) string {
182+ if r == nil {
183+ return s
184+ }
185+ drop := 0
186+ for _, secret := range r.secrets {
187+ for k := min(len(secret)-1, len(s)); k > drop; k-- {
188+ if strings.HasSuffix(s, secret[:k]) {
189+ drop = k
190+ break
191+ }
128192 }
129193 }
130- s = scrubURLs(s)
131- return sanitizeText(s, true)
194+ return s[:len(s)-drop]
132195 }
133196
134197 // scrubOneLine is scrub with newlines collapsed to spaces.
@@ -1,6 +1,7 @@
1 package mcp1 package mcp
2 2
3 import (3 import (
4+ "net/url"
4 "os"5 "os"
5 "regexp"6 "regexp"
6 "sort"7 "sort"
@@ -24,6 +25,9 @@ const (
24 // A nil *redactor still strips URLs' credentials and control characters.25 // A nil *redactor still strips URLs' credentials and control characters.
25 type redactor struct {26 type redactor struct {
26 secrets []string27 secrets []string
28+ // maxLen is the length of the longest secret, used to size the overlap
29+ // when long lines are cut for display.
30+ maxLen int
27 }31 }
28 32
29 // newRedactor collects the secret-bearing values of one server configuration:33 // newRedactor collects the secret-bearing values of one server configuration:
@@ -37,6 +41,12 @@ func newRedactor(cfg ServerConfig) *redactor {
37 return41 return
38 }42 }
39 set[v] = true43 set[v] = true
44+ // The control-stripped form too: text is sanitized before it is
45+ // matched, so a secret that itself contains escape bytes must match
46+ // in its sanitized shape.
47+ if clean := sanitizeText(v, true); len(clean) >= minSecretLen {
48+ set[clean] = true
49+ }
40 // "Bearer abc123" is also worth redacting as just "abc123".50 // "Bearer abc123" is also worth redacting as just "abc123".
41 if strings.ContainsAny(v, " \t") {51 if strings.ContainsAny(v, " \t") {
42 for _, field := range strings.Fields(v) {52 for _, field := range strings.Fields(v) {
@@ -75,6 +85,7 @@ func newRedactor(cfg ServerConfig) *redactor {
75 r := &redactor{}85 r := &redactor{}
76 for v := range set {86 for v := range set {
77 r.secrets = append(r.secrets, v)87 r.secrets = append(r.secrets, v)
88+ r.maxLen = max(r.maxLen, len(v))
78 }89 }
79 // Longest first so a secret containing another is replaced whole.90 // Longest first so a secret containing another is replaced whole.
80 sort.Slice(r.secrets, func(i, j int) bool {91 sort.Slice(r.secrets, func(i, j int) bool {
@@ -86,30 +97,52 @@ func newRedactor(cfg ServerConfig) *redactor {
86 return r97 return r
87 }98 }
88 99
89-// urlSecrets returns the userinfo password/user and query values of raw.100+// urlSecrets returns the secret-bearing parts of raw: userinfo (user and
101+// password) and query values, both as written and percent-decoded. It splits
102+// the text itself instead of requiring url.Parse to succeed, because the URLs
103+// that matter most here are the malformed ones that fail to parse.
90 func urlSecrets(raw string) []string {104 func urlSecrets(raw string) []string {
105+ var out []string
106+ add := func(v string) {
107+ if v == "" {
108+ return
109+ }
110+ out = append(out, v)
111+ if d, err := url.PathUnescape(v); err == nil && d != v {
112+ out = append(out, d)
113+ }
114+ if d, err := url.QueryUnescape(v); err == nil && d != v {
115+ out = append(out, d)
116+ }
117+ }
91 rest := raw118 rest := raw
92 if i := strings.Index(rest, "://"); i >= 0 {119 if i := strings.Index(rest, "://"); i >= 0 {
93 rest = rest[i+3:]120 rest = rest[i+3:]
94 }121 }
95- var out []string
96 authority := rest122 authority := rest
97 if i := strings.IndexAny(authority, "/?#"); i >= 0 {123 if i := strings.IndexAny(authority, "/?#"); i >= 0 {
98 authority = authority[:i]124 authority = authority[:i]
99 }125 }
100 if i := strings.LastIndex(authority, "@"); i >= 0 {126 if i := strings.LastIndex(authority, "@"); i >= 0 {
101- for _, part := range strings.SplitN(authority[:i], ":", 2) {127+ user, pass, _ := strings.Cut(authority[:i], ":")
102- out = append(out, part)128+ add(user)
103- }129+ add(pass)
104 }130 }
105 if i := strings.Index(rest, "?"); i >= 0 {131 if i := strings.Index(rest, "?"); i >= 0 {
106 query := rest[i+1:]132 query := rest[i+1:]
107 if j := strings.Index(query, "#"); j >= 0 {133 if j := strings.Index(query, "#"); j >= 0 {
108 query = query[:j]134 query = query[:j]
109 }135 }
110- for _, pair := range strings.Split(query, "&") {136+ for _, pair := range strings.FieldsFunc(query, func(r rune) bool { return r == '&' || r == ';' }) {
111 if _, v, ok := strings.Cut(pair, "="); ok {137 if _, v, ok := strings.Cut(pair, "="); ok {
112- out = append(out, v)138+ add(v)
139+ }
140+ }
141+ if values, err := url.ParseQuery(query); err == nil {
142+ for _, vs := range values {
143+ for _, v := range vs {
144+ add(v)
145+ }
113 }146 }
114 }147 }
115 }148 }
@@ -122,13 +155,43 @@ func (r *redactor) scrub(s string) string {
122 if s == "" {155 if s == "" {
123 return s156 return s
124 }157 }
125- if r != nil {158+ // Redact, strip control sequences, then redact again: a secret that has
126- for _, secret := range r.secrets {159+ // escape bytes inserted into it only becomes matchable once they are gone.
127- s = strings.ReplaceAll(s, secret, redactedMarker)160+ s = r.redact(s)
161+ s = sanitizeText(s, true)
162+ s = r.redact(s)
163+ return scrubURLs(s)
164+}
165+
166+// redact replaces configured secret values. A nil redactor changes nothing.
167+func (r *redactor) redact(s string) string {
168+ if r == nil {
169+ return s
170+ }
171+ for _, secret := range r.secrets {
172+ s = strings.ReplaceAll(s, secret, redactedMarker)
173+ }
174+ return s
175+}
176+
177+// trimPartialSecret removes a trailing fragment that is a proper prefix of a
178+// secret. It is for text that was cut at an arbitrary byte: a secret that
179+// straddles the cut cannot be matched as a whole, and its prefix must not
180+// survive either.
181+func (r *redactor) trimPartialSecret(s string) string {
182+ if r == nil {
183+ return s
184+ }
185+ drop := 0
186+ for _, secret := range r.secrets {
187+ for k := min(len(secret)-1, len(s)); k > drop; k-- {
188+ if strings.HasSuffix(s, secret[:k]) {
189+ drop = k
190+ break
191+ }
128 }192 }
129 }193 }
130- s = scrubURLs(s)194+ return s[:len(s)-drop]
131- return sanitizeText(s, true)
132 }195 }
133 196
134 // scrubOneLine is scrub with newlines collapsed to spaces.197 // scrubOneLine is scrub with newlines collapsed to spaces.
M packages/agent/mcp/stdio.go +88 -34
@@ -15,6 +15,7 @@ import (
1515 "sync"
1616 "sync/atomic"
1717 "time"
18+ "unicode/utf8"
1819 )
1920
2021 // maxStdioFrame bounds one newline-delimited JSON-RPC message from a server.
@@ -26,6 +27,9 @@ const (
2627 // maxStderrLine truncates one retained stderr line; the rest of a longer
2728 // line is still drained from the pipe, just not kept.
2829 maxStderrLine = 4096
30+ // maxStderrOverlap caps how far past maxStderrLine raw bytes are kept so
31+ // a straddling secret can still be redacted.
32+ maxStderrOverlap = 64 << 10
2933 // writeQueueSize bounds frames waiting for the writer goroutine.
3034 writeQueueSize = 64
3135 // responseWriteTimeout bounds answering one server-initiated request.
@@ -57,8 +61,17 @@ type writeReq struct {
5761 ctx context.Context
5862 frame []byte
5963 res chan error
64+ state atomic.Int32 // reqQueued, reqWriting, or reqSkipped
6065 }
6166
67+// A queued frame is claimed exactly once: by the writer (it will be written)
68+// or by its caller (it will never be written).
69+const (
70+ reqQueued int32 = iota
71+ reqWriting
72+ reqSkipped
73+)
74+
6275 type stdioTransport struct {
6376 name string
6477 cmd *exec.Cmd
@@ -92,6 +105,9 @@ type stdioTransport struct {
92105 // cancelsSent counts notifications/cancelled sends started (diagnostics
93106 // and tests).
94107 cancelsSent atomic.Int64
108+ // writesStarted counts frames the writer has begun writing (diagnostics
109+ // and tests).
110+ writesStarted atomic.Int64
95111 }
96112
97113 // buildStdioEnv returns the restricted child environment.
@@ -153,11 +169,21 @@ func startStdio(name string, cfg ServerConfig, cwd string) (*stdioTransport, err
153169 }
154170 outW.Close()
155171 errW.Close()
172+ guard, err := newProcessGuard(cmd)
173+ if err != nil {
174+ // Fail closed: never run a server that cannot be contained.
175+ _ = stdin.Close()
176+ _ = cmd.Process.Kill()
177+ _ = cmd.Wait()
178+ outR.Close()
179+ errR.Close()
180+ return nil, fmt.Errorf("start %s: %w", cfg.Command, red.scrubError(err))
181+ }
156182 ctx, cancel := context.WithCancel(context.Background())
157183 t := &stdioTransport{
158184 name: name,
159185 cmd: cmd,
160- guard: newProcessGuard(cmd),
186+ guard: guard,
161187 red: red,
162188 ctx: ctx,
163189 cancel: cancel,
@@ -187,6 +213,9 @@ func startStdio(name string, cfg ServerConfig, cwd string) (*stdioTransport, err
187213 case <-time.After(250 * time.Millisecond):
188214 }
189215 t.fail(fmt.Errorf("server process ended: %w", waitErr))
216+ // The leader is gone: stop whatever it left behind, once, while the
217+ // process-group id is still guaranteed to belong to our group.
218+ t.guard.cleanup()
190219 }()
191220 return t, nil
192221 }
@@ -194,15 +223,28 @@ func startStdio(name string, cfg ServerConfig, cwd string) (*stdioTransport, err
194223 // readStderr drains the child's stderr for its whole life, keeping a bounded
195224 // tail of bounded lines. It never stops early: a child blocked on a full
196225 // stderr pipe would hang.
226+//
227+// Redaction happens on the complete line before it is shortened for display.
228+// For a line longer than the display limit the raw prefix is kept
229+// maxSecretLen bytes past the cut, so a secret that straddles the cut is
230+// matched whole; what remains of one anyway is trimmed afterwards.
197231 func (t *stdioTransport) readStderr(r io.Reader) {
198232 defer close(t.stderrDone)
199233 reader := bufio.NewReaderSize(r, 4096)
234+ keep := maxStderrLine + min(t.red.maxLen, maxStderrOverlap)
200235 var line []byte
201236 truncated := false
202237 flush := func() {
203- text := strings.TrimRight(string(line), "\r")
204- if truncated {
205- text += "…[line truncated]"
238+ text := t.red.scrub(strings.TrimRight(string(line), "\r"))
239+ if len(text) > maxStderrLine || truncated {
240+ if len(text) > maxStderrLine {
241+ cut := maxStderrLine
242+ for cut > 0 && !utf8.RuneStart(text[cut]) {
243+ cut--
244+ }
245+ text = text[:cut]
246+ }
247+ text = t.red.trimPartialSecret(text) + "…[line truncated]"
206248 }
207249 line, truncated = nil, false
208250 t.stderrMu.Lock()
@@ -214,7 +256,7 @@ func (t *stdioTransport) readStderr(r io.Reader) {
214256 }
215257 for {
216258 chunk, isPrefix, err := reader.ReadLine()
217- if room := maxStderrLine - len(line); len(chunk) > room {
259+ if room := keep - len(line); len(chunk) > room {
218260 if room > 0 {
219261 line = append(line, chunk[:room]...)
220262 }
@@ -335,10 +377,14 @@ func (t *stdioTransport) writeLoop() {
335377 case <-t.doneCh:
336378 return
337379 case req := <-t.writeQ:
338- if err := req.ctx.Err(); err != nil {
380+ if err := req.ctx.Err(); err != nil && req.state.CompareAndSwap(reqQueued, reqSkipped) {
339381 req.res <- err // never started: nothing partial on the wire
340382 continue
341383 }
384+ if !req.state.CompareAndSwap(reqQueued, reqWriting) {
385+ continue // the caller gave up before we started
386+ }
387+ t.writesStarted.Add(1)
342388 _, err := t.stdin.Write(req.frame)
343389 req.res <- err
344390 if err != nil {
@@ -353,26 +399,49 @@ func (t *stdioTransport) writeLoop() {
353399 // write queues one frame (newline appended) and waits until it has been
354400 // written, ctx ends, or the connection fails.
355401 func (t *stdioTransport) write(ctx context.Context, frame []byte) error {
402+ _, err := t.writeFrame(ctx, frame)
403+ return err
404+}
405+
406+// writeFrame is write that also reports whether any byte of the frame may
407+// have reached the server. When ctx ends:
408+// - a frame the writer has not started is withdrawn and never written, and
409+// the transport stays usable;
410+// - a frame whose write is already in progress cannot be withdrawn (part of
411+// it may be on the wire and the rest could still be flushed later), so the
412+// transport is failed and stdin closed. The manager reconnects on the next
413+// call and the abandoned frame can never complete.
414+func (t *stdioTransport) writeFrame(ctx context.Context, frame []byte) (sent bool, err error) {
356415 req := &writeReq{ctx: ctx, frame: append(append(make([]byte, 0, len(frame)+1), frame...), '\n'), res: make(chan error, 1)}
357416 select {
358417 case t.writeQ <- req:
359418 case <-ctx.Done():
360- return ctx.Err()
419+ return false, ctx.Err()
361420 case <-t.doneCh:
362- return t.failure()
421+ return false, t.failure()
363422 }
364423 select {
365424 case err := <-req.res:
366- return err
425+ return req.state.Load() == reqWriting, err
367426 case <-ctx.Done():
368- return ctx.Err()
427+ if req.state.CompareAndSwap(reqQueued, reqSkipped) {
428+ return false, ctx.Err()
429+ }
430+ select {
431+ case err := <-req.res:
432+ return req.state.Load() == reqWriting, err
433+ default:
434+ }
435+ t.fail(fmt.Errorf("write to server interrupted: %w", ctx.Err()))
436+ _ = t.stdin.Close() // unblocks the stuck write
437+ return true, ctx.Err()
369438 case <-t.doneCh:
370439 select {
371440 case err := <-req.res:
372- return err
441+ return req.state.Load() == reqWriting, err
373442 default:
374443 }
375- return t.failure()
444+ return req.state.Load() == reqWriting, t.failure()
376445 }
377446 }
378447
@@ -396,10 +465,12 @@ func (t *stdioTransport) request(ctx context.Context, id int64, method string, p
396465 delete(t.pending, key)
397466 t.mu.Unlock()
398467 }
399- if err := t.write(ctx, frame); err != nil {
468+ if sent, err := t.writeFrame(ctx, frame); err != nil {
400469 cleanup()
401470 if ctx.Err() != nil {
402- t.cancelRequest(id, method, ctx.Err().Error())
471+ if sent {
472+ t.cancelRequest(id, method, ctx.Err().Error())
473+ }
403474 return nil, ctx.Err()
404475 }
405476 return nil, err
@@ -494,7 +565,8 @@ func (t *stdioTransport) waitExit(d time.Duration) bool {
494565
495566 // close shuts the server down: close stdin (the MCP stdio shutdown signal)
496567 // without waiting behind any blocked write, wait briefly, then terminate the
497-// process group. Descendants that outlive the direct child are signalled too.
568+// process group. Group cleanup is one-shot and also runs when the direct
569+// child exits on its own, so repeated or late calls never signal a stale id.
498570 func (t *stdioTransport) close() error {
499571 t.fail(errors.New("connection closed"))
500572 _ = t.stdin.Close() // also unblocks a writer stuck on a full pipe
@@ -505,7 +577,7 @@ func (t *stdioTransport) close() error {
505577 t.waitExit(time.Second)
506578 }
507579 }
508- t.reapGroup()
580+ t.guard.cleanup()
509581 // With the child gone its stderr hits EOF once buffered data is read; give
510582 // the reader a moment so diagnostics are complete. A descendant holding the
511583 // pipe open only costs the timeout.
@@ -519,23 +591,5 @@ func (t *stdioTransport) close() error {
519591 }
520592 _ = t.outR.Close()
521593 _ = t.errR.Close()
522- t.guard.release()
523594 return nil
524595 }
525-
526-// reapGroup stops processes the direct child left behind. The process group
527-// outlives its leader, so this is meaningful even after the leader has been
528-// reaped (the group id cannot be reused while any member remains).
529-func (t *stdioTransport) reapGroup() {
530- if !t.guard.alive() {
531- return
532- }
533- t.guard.terminate(false)
534- deadline := time.Now().Add(300 * time.Millisecond)
535- for t.guard.alive() && time.Now().Before(deadline) {
536- time.Sleep(10 * time.Millisecond)
537- }
538- if t.guard.alive() {
539- t.guard.terminate(true)
540- }
541-}
@@ -15,6 +15,7 @@ import (
15 "sync"15 "sync"
16 "sync/atomic"16 "sync/atomic"
17 "time"17 "time"
18+ "unicode/utf8"
18 )19 )
19 20
20 // maxStdioFrame bounds one newline-delimited JSON-RPC message from a server.21 // maxStdioFrame bounds one newline-delimited JSON-RPC message from a server.
@@ -26,6 +27,9 @@ const (
26 // maxStderrLine truncates one retained stderr line; the rest of a longer27 // maxStderrLine truncates one retained stderr line; the rest of a longer
27 // line is still drained from the pipe, just not kept.28 // line is still drained from the pipe, just not kept.
28 maxStderrLine = 409629 maxStderrLine = 4096
30+ // maxStderrOverlap caps how far past maxStderrLine raw bytes are kept so
31+ // a straddling secret can still be redacted.
32+ maxStderrOverlap = 64 << 10
29 // writeQueueSize bounds frames waiting for the writer goroutine.33 // writeQueueSize bounds frames waiting for the writer goroutine.
30 writeQueueSize = 6434 writeQueueSize = 64
31 // responseWriteTimeout bounds answering one server-initiated request.35 // responseWriteTimeout bounds answering one server-initiated request.
@@ -57,8 +61,17 @@ type writeReq struct {
57 ctx context.Context61 ctx context.Context
58 frame []byte62 frame []byte
59 res chan error63 res chan error
64+ state atomic.Int32 // reqQueued, reqWriting, or reqSkipped
60 }65 }
61 66
67+// A queued frame is claimed exactly once: by the writer (it will be written)
68+// or by its caller (it will never be written).
69+const (
70+ reqQueued int32 = iota
71+ reqWriting
72+ reqSkipped
73+)
74+
62 type stdioTransport struct {75 type stdioTransport struct {
63 name string76 name string
64 cmd *exec.Cmd77 cmd *exec.Cmd
@@ -92,6 +105,9 @@ type stdioTransport struct {
92 // cancelsSent counts notifications/cancelled sends started (diagnostics105 // cancelsSent counts notifications/cancelled sends started (diagnostics
93 // and tests).106 // and tests).
94 cancelsSent atomic.Int64107 cancelsSent atomic.Int64
108+ // writesStarted counts frames the writer has begun writing (diagnostics
109+ // and tests).
110+ writesStarted atomic.Int64
95 }111 }
96 112
97 // buildStdioEnv returns the restricted child environment.113 // buildStdioEnv returns the restricted child environment.
@@ -153,11 +169,21 @@ func startStdio(name string, cfg ServerConfig, cwd string) (*stdioTransport, err
153 }169 }
154 outW.Close()170 outW.Close()
155 errW.Close()171 errW.Close()
172+ guard, err := newProcessGuard(cmd)
173+ if err != nil {
174+ // Fail closed: never run a server that cannot be contained.
175+ _ = stdin.Close()
176+ _ = cmd.Process.Kill()
177+ _ = cmd.Wait()
178+ outR.Close()
179+ errR.Close()
180+ return nil, fmt.Errorf("start %s: %w", cfg.Command, red.scrubError(err))
181+ }
156 ctx, cancel := context.WithCancel(context.Background())182 ctx, cancel := context.WithCancel(context.Background())
157 t := &stdioTransport{183 t := &stdioTransport{
158 name: name,184 name: name,
159 cmd: cmd,185 cmd: cmd,
160- guard: newProcessGuard(cmd),186+ guard: guard,
161 red: red,187 red: red,
162 ctx: ctx,188 ctx: ctx,
163 cancel: cancel,189 cancel: cancel,
@@ -187,6 +213,9 @@ func startStdio(name string, cfg ServerConfig, cwd string) (*stdioTransport, err
187 case <-time.After(250 * time.Millisecond):213 case <-time.After(250 * time.Millisecond):
188 }214 }
189 t.fail(fmt.Errorf("server process ended: %w", waitErr))215 t.fail(fmt.Errorf("server process ended: %w", waitErr))
216+ // The leader is gone: stop whatever it left behind, once, while the
217+ // process-group id is still guaranteed to belong to our group.
218+ t.guard.cleanup()
190 }()219 }()
191 return t, nil220 return t, nil
192 }221 }
@@ -194,15 +223,28 @@ func startStdio(name string, cfg ServerConfig, cwd string) (*stdioTransport, err
194 // readStderr drains the child's stderr for its whole life, keeping a bounded223 // readStderr drains the child's stderr for its whole life, keeping a bounded
195 // tail of bounded lines. It never stops early: a child blocked on a full224 // tail of bounded lines. It never stops early: a child blocked on a full
196 // stderr pipe would hang.225 // stderr pipe would hang.
226+//
227+// Redaction happens on the complete line before it is shortened for display.
228+// For a line longer than the display limit the raw prefix is kept
229+// maxSecretLen bytes past the cut, so a secret that straddles the cut is
230+// matched whole; what remains of one anyway is trimmed afterwards.
197 func (t *stdioTransport) readStderr(r io.Reader) {231 func (t *stdioTransport) readStderr(r io.Reader) {
198 defer close(t.stderrDone)232 defer close(t.stderrDone)
199 reader := bufio.NewReaderSize(r, 4096)233 reader := bufio.NewReaderSize(r, 4096)
234+ keep := maxStderrLine + min(t.red.maxLen, maxStderrOverlap)
200 var line []byte235 var line []byte
201 truncated := false236 truncated := false
202 flush := func() {237 flush := func() {
203- text := strings.TrimRight(string(line), "\r")238+ text := t.red.scrub(strings.TrimRight(string(line), "\r"))
204- if truncated {239+ if len(text) > maxStderrLine || truncated {
205- text += "…[line truncated]"240+ if len(text) > maxStderrLine {
241+ cut := maxStderrLine
242+ for cut > 0 && !utf8.RuneStart(text[cut]) {
243+ cut--
244+ }
245+ text = text[:cut]
246+ }
247+ text = t.red.trimPartialSecret(text) + "…[line truncated]"
206 }248 }
207 line, truncated = nil, false249 line, truncated = nil, false
208 t.stderrMu.Lock()250 t.stderrMu.Lock()
@@ -214,7 +256,7 @@ func (t *stdioTransport) readStderr(r io.Reader) {
214 }256 }
215 for {257 for {
216 chunk, isPrefix, err := reader.ReadLine()258 chunk, isPrefix, err := reader.ReadLine()
217- if room := maxStderrLine - len(line); len(chunk) > room {259+ if room := keep - len(line); len(chunk) > room {
218 if room > 0 {260 if room > 0 {
219 line = append(line, chunk[:room]...)261 line = append(line, chunk[:room]...)
220 }262 }
@@ -335,10 +377,14 @@ func (t *stdioTransport) writeLoop() {
335 case <-t.doneCh:377 case <-t.doneCh:
336 return378 return
337 case req := <-t.writeQ:379 case req := <-t.writeQ:
338- if err := req.ctx.Err(); err != nil {380+ if err := req.ctx.Err(); err != nil && req.state.CompareAndSwap(reqQueued, reqSkipped) {
339 req.res <- err // never started: nothing partial on the wire381 req.res <- err // never started: nothing partial on the wire
340 continue382 continue
341 }383 }
384+ if !req.state.CompareAndSwap(reqQueued, reqWriting) {
385+ continue // the caller gave up before we started
386+ }
387+ t.writesStarted.Add(1)
342 _, err := t.stdin.Write(req.frame)388 _, err := t.stdin.Write(req.frame)
343 req.res <- err389 req.res <- err
344 if err != nil {390 if err != nil {
@@ -353,26 +399,49 @@ func (t *stdioTransport) writeLoop() {
353 // write queues one frame (newline appended) and waits until it has been399 // write queues one frame (newline appended) and waits until it has been
354 // written, ctx ends, or the connection fails.400 // written, ctx ends, or the connection fails.
355 func (t *stdioTransport) write(ctx context.Context, frame []byte) error {401 func (t *stdioTransport) write(ctx context.Context, frame []byte) error {
402+ _, err := t.writeFrame(ctx, frame)
403+ return err
404+}
405+
406+// writeFrame is write that also reports whether any byte of the frame may
407+// have reached the server. When ctx ends:
408+// - a frame the writer has not started is withdrawn and never written, and
409+// the transport stays usable;
410+// - a frame whose write is already in progress cannot be withdrawn (part of
411+// it may be on the wire and the rest could still be flushed later), so the
412+// transport is failed and stdin closed. The manager reconnects on the next
413+// call and the abandoned frame can never complete.
414+func (t *stdioTransport) writeFrame(ctx context.Context, frame []byte) (sent bool, err error) {
356 req := &writeReq{ctx: ctx, frame: append(append(make([]byte, 0, len(frame)+1), frame...), '\n'), res: make(chan error, 1)}415 req := &writeReq{ctx: ctx, frame: append(append(make([]byte, 0, len(frame)+1), frame...), '\n'), res: make(chan error, 1)}
357 select {416 select {
358 case t.writeQ <- req:417 case t.writeQ <- req:
359 case <-ctx.Done():418 case <-ctx.Done():
360- return ctx.Err()419+ return false, ctx.Err()
361 case <-t.doneCh:420 case <-t.doneCh:
362- return t.failure()421+ return false, t.failure()
363 }422 }
364 select {423 select {
365 case err := <-req.res:424 case err := <-req.res:
366- return err425+ return req.state.Load() == reqWriting, err
367 case <-ctx.Done():426 case <-ctx.Done():
368- return ctx.Err()427+ if req.state.CompareAndSwap(reqQueued, reqSkipped) {
428+ return false, ctx.Err()
429+ }
430+ select {
431+ case err := <-req.res:
432+ return req.state.Load() == reqWriting, err
433+ default:
434+ }
435+ t.fail(fmt.Errorf("write to server interrupted: %w", ctx.Err()))
436+ _ = t.stdin.Close() // unblocks the stuck write
437+ return true, ctx.Err()
369 case <-t.doneCh:438 case <-t.doneCh:
370 select {439 select {
371 case err := <-req.res:440 case err := <-req.res:
372- return err441+ return req.state.Load() == reqWriting, err
373 default:442 default:
374 }443 }
375- return t.failure()444+ return req.state.Load() == reqWriting, t.failure()
376 }445 }
377 }446 }
378 447
@@ -396,10 +465,12 @@ func (t *stdioTransport) request(ctx context.Context, id int64, method string, p
396 delete(t.pending, key)465 delete(t.pending, key)
397 t.mu.Unlock()466 t.mu.Unlock()
398 }467 }
399- if err := t.write(ctx, frame); err != nil {468+ if sent, err := t.writeFrame(ctx, frame); err != nil {
400 cleanup()469 cleanup()
401 if ctx.Err() != nil {470 if ctx.Err() != nil {
402- t.cancelRequest(id, method, ctx.Err().Error())471+ if sent {
472+ t.cancelRequest(id, method, ctx.Err().Error())
473+ }
403 return nil, ctx.Err()474 return nil, ctx.Err()
404 }475 }
405 return nil, err476 return nil, err
@@ -494,7 +565,8 @@ func (t *stdioTransport) waitExit(d time.Duration) bool {
494 565
495 // close shuts the server down: close stdin (the MCP stdio shutdown signal)566 // close shuts the server down: close stdin (the MCP stdio shutdown signal)
496 // without waiting behind any blocked write, wait briefly, then terminate the567 // without waiting behind any blocked write, wait briefly, then terminate the
497-// process group. Descendants that outlive the direct child are signalled too.568+// process group. Group cleanup is one-shot and also runs when the direct
569+// child exits on its own, so repeated or late calls never signal a stale id.
498 func (t *stdioTransport) close() error {570 func (t *stdioTransport) close() error {
499 t.fail(errors.New("connection closed"))571 t.fail(errors.New("connection closed"))
500 _ = t.stdin.Close() // also unblocks a writer stuck on a full pipe572 _ = t.stdin.Close() // also unblocks a writer stuck on a full pipe
@@ -505,7 +577,7 @@ func (t *stdioTransport) close() error {
505 t.waitExit(time.Second)577 t.waitExit(time.Second)
506 }578 }
507 }579 }
508- t.reapGroup()580+ t.guard.cleanup()
509 // With the child gone its stderr hits EOF once buffered data is read; give581 // With the child gone its stderr hits EOF once buffered data is read; give
510 // the reader a moment so diagnostics are complete. A descendant holding the582 // the reader a moment so diagnostics are complete. A descendant holding the
511 // pipe open only costs the timeout.583 // pipe open only costs the timeout.
@@ -519,23 +591,5 @@ func (t *stdioTransport) close() error {
519 }591 }
520 _ = t.outR.Close()592 _ = t.outR.Close()
521 _ = t.errR.Close()593 _ = t.errR.Close()
522- t.guard.release()
523 return nil594 return nil
524 }595 }
525-
526-// reapGroup stops processes the direct child left behind. The process group
527-// outlives its leader, so this is meaningful even after the leader has been
528-// reaped (the group id cannot be reused while any member remains).
529-func (t *stdioTransport) reapGroup() {
530- if !t.guard.alive() {
531- return
532- }
533- t.guard.terminate(false)
534- deadline := time.Now().Add(300 * time.Millisecond)
535- for t.guard.alive() && time.Now().Before(deadline) {
536- time.Sleep(10 * time.Millisecond)
537- }
538- if t.guard.alive() {
539- t.guard.terminate(true)
540- }
541-}
M packages/agent/mcp/tools.go +34 -12
@@ -7,6 +7,7 @@ import (
77 "errors"
88 "fmt"
99 "strings"
10+ "unicode/utf8"
1011
1112 "github.com/bnema/zut/packages/core"
1213 "github.com/bnema/zut/packages/provider"
@@ -283,35 +284,35 @@ func (t *listResourcesTool) Execute(ctx context.Context, args json.RawMessage, _
283284 }
284285 client, cfg, err := t.manager.client(ctx, server)
285286 if err != nil {
286- out.printf("## %s\nunavailable: %s\n\n", sanitizeText(server, false), sanitizeText(firstLine(err.Error()), false))
287+ out.printf("## %s\nunavailable: %s\n\n", out.field(server), out.field(firstLine(err.Error())))
287288 continue
288289 }
289290 if !client.Info().HasResources {
290291 if in.Server != "" {
291- out.printf("## %s\nthis server does not offer resources\n\n", sanitizeText(server, false))
292+ out.printf("## %s\nthis server does not offer resources\n\n", out.field(server))
292293 }
293294 continue
294295 }
295296 reqCtx, cancel := withTimeout(ctx, cfg.ToolTimeout())
296297 resources, rErr := client.ListResources(reqCtx)
297- out.printf("## %s\n", sanitizeText(server, false))
298+ out.printf("## %s\n", out.field(server))
298299 if rErr != nil {
299- out.printf("resources error: %s\n", sanitizeText(rErr.Error(), false))
300+ out.printf("resources error: %s\n", out.field(rErr.Error()))
300301 }
301302 for _, r := range resources {
302303 if out.full() {
303304 break
304305 }
305306 var line strings.Builder
306- fmt.Fprintf(&line, "- %s", sanitizeText(r.URI, false))
307+ fmt.Fprintf(&line, "- %s", out.field(r.URI))
307308 if name := firstNonEmpty(r.Title, r.Name); name != "" {
308- fmt.Fprintf(&line, " — %s", sanitizeText(name, false))
309+ fmt.Fprintf(&line, " — %s", out.field(name))
309310 }
310311 if r.MimeType != "" {
311- fmt.Fprintf(&line, " (%s)", sanitizeText(r.MimeType, false))
312+ fmt.Fprintf(&line, " (%s)", out.field(r.MimeType))
312313 }
313314 if r.Description != "" {
314- fmt.Fprintf(&line, ": %s", sanitizeText(r.Description, false))
315+ fmt.Fprintf(&line, ": %s", out.field(r.Description))
315316 }
316317 line.WriteByte('\n')
317318 out.write(line.String())
@@ -324,19 +325,19 @@ func (t *listResourcesTool) Execute(ctx context.Context, args json.RawMessage, _
324325 }
325326 cancel()
326327 if tErr != nil && !isMethodNotFound(tErr) {
327- out.printf("templates error: %s\n", sanitizeText(tErr.Error(), false))
328+ out.printf("templates error: %s\n", out.field(tErr.Error()))
328329 }
329330 for _, tpl := range templates {
330331 if out.full() {
331332 break
332333 }
333334 var line strings.Builder
334- fmt.Fprintf(&line, "- template %s", sanitizeText(tpl.URITemplate, false))
335+ fmt.Fprintf(&line, "- template %s", out.field(tpl.URITemplate))
335336 if name := firstNonEmpty(tpl.Title, tpl.Name); name != "" {
336- fmt.Fprintf(&line, " — %s", sanitizeText(name, false))
337+ fmt.Fprintf(&line, " — %s", out.field(name))
337338 }
338339 if tpl.Description != "" {
339- fmt.Fprintf(&line, ": %s", sanitizeText(tpl.Description, false))
340+ fmt.Fprintf(&line, ": %s", out.field(tpl.Description))
340341 }
341342 line.WriteByte('\n')
342343 out.write(line.String())
@@ -362,6 +363,27 @@ type budgetedText struct {
362363
363364 func (t *budgetedText) full() bool { return t.budget.truncated }
364365
366+// remaining is how many more bytes of text fit.
367+func (t *budgetedText) remaining() int { return max(0, maxResultTextBytes-t.budget.used) }
368+
369+// field prepares one server-supplied string for output: it is cut to the
370+// remaining budget first (so a multi-megabyte description is never fully
371+// processed) and then stripped of control sequences and line breaks.
372+func (t *budgetedText) field(s string) string {
373+ return sanitizeText(clipUTF8(s, t.remaining()+1), false)
374+}
375+
376+// clipUTF8 shortens s to at most n bytes without splitting a rune.
377+func clipUTF8(s string, n int) string {
378+ if len(s) <= n {
379+ return s
380+ }
381+ for n > 0 && !utf8.RuneStart(s[n]) {
382+ n--
383+ }
384+ return s[:n]
385+}
386+
365387 func (t *budgetedText) write(s string) {
366388 if t.budget.truncated {
367389 return
@@ -7,6 +7,7 @@ import (
7 "errors"7 "errors"
8 "fmt"8 "fmt"
9 "strings"9 "strings"
10+ "unicode/utf8"
10 11
11 "github.com/bnema/zut/packages/core"12 "github.com/bnema/zut/packages/core"
12 "github.com/bnema/zut/packages/provider"13 "github.com/bnema/zut/packages/provider"
@@ -283,35 +284,35 @@ func (t *listResourcesTool) Execute(ctx context.Context, args json.RawMessage, _
283 }284 }
284 client, cfg, err := t.manager.client(ctx, server)285 client, cfg, err := t.manager.client(ctx, server)
285 if err != nil {286 if err != nil {
286- out.printf("## %s\nunavailable: %s\n\n", sanitizeText(server, false), sanitizeText(firstLine(err.Error()), false))287+ out.printf("## %s\nunavailable: %s\n\n", out.field(server), out.field(firstLine(err.Error())))
287 continue288 continue
288 }289 }
289 if !client.Info().HasResources {290 if !client.Info().HasResources {
290 if in.Server != "" {291 if in.Server != "" {
291- out.printf("## %s\nthis server does not offer resources\n\n", sanitizeText(server, false))292+ out.printf("## %s\nthis server does not offer resources\n\n", out.field(server))
292 }293 }
293 continue294 continue
294 }295 }
295 reqCtx, cancel := withTimeout(ctx, cfg.ToolTimeout())296 reqCtx, cancel := withTimeout(ctx, cfg.ToolTimeout())
296 resources, rErr := client.ListResources(reqCtx)297 resources, rErr := client.ListResources(reqCtx)
297- out.printf("## %s\n", sanitizeText(server, false))298+ out.printf("## %s\n", out.field(server))
298 if rErr != nil {299 if rErr != nil {
299- out.printf("resources error: %s\n", sanitizeText(rErr.Error(), false))300+ out.printf("resources error: %s\n", out.field(rErr.Error()))
300 }301 }
301 for _, r := range resources {302 for _, r := range resources {
302 if out.full() {303 if out.full() {
303 break304 break
304 }305 }
305 var line strings.Builder306 var line strings.Builder
306- fmt.Fprintf(&line, "- %s", sanitizeText(r.URI, false))307+ fmt.Fprintf(&line, "- %s", out.field(r.URI))
307 if name := firstNonEmpty(r.Title, r.Name); name != "" {308 if name := firstNonEmpty(r.Title, r.Name); name != "" {
308- fmt.Fprintf(&line, " — %s", sanitizeText(name, false))309+ fmt.Fprintf(&line, " — %s", out.field(name))
309 }310 }
310 if r.MimeType != "" {311 if r.MimeType != "" {
311- fmt.Fprintf(&line, " (%s)", sanitizeText(r.MimeType, false))312+ fmt.Fprintf(&line, " (%s)", out.field(r.MimeType))
312 }313 }
313 if r.Description != "" {314 if r.Description != "" {
314- fmt.Fprintf(&line, ": %s", sanitizeText(r.Description, false))315+ fmt.Fprintf(&line, ": %s", out.field(r.Description))
315 }316 }
316 line.WriteByte('\n')317 line.WriteByte('\n')
317 out.write(line.String())318 out.write(line.String())
@@ -324,19 +325,19 @@ func (t *listResourcesTool) Execute(ctx context.Context, args json.RawMessage, _
324 }325 }
325 cancel()326 cancel()
326 if tErr != nil && !isMethodNotFound(tErr) {327 if tErr != nil && !isMethodNotFound(tErr) {
327- out.printf("templates error: %s\n", sanitizeText(tErr.Error(), false))328+ out.printf("templates error: %s\n", out.field(tErr.Error()))
328 }329 }
329 for _, tpl := range templates {330 for _, tpl := range templates {
330 if out.full() {331 if out.full() {
331 break332 break
332 }333 }
333 var line strings.Builder334 var line strings.Builder
334- fmt.Fprintf(&line, "- template %s", sanitizeText(tpl.URITemplate, false))335+ fmt.Fprintf(&line, "- template %s", out.field(tpl.URITemplate))
335 if name := firstNonEmpty(tpl.Title, tpl.Name); name != "" {336 if name := firstNonEmpty(tpl.Title, tpl.Name); name != "" {
336- fmt.Fprintf(&line, " — %s", sanitizeText(name, false))337+ fmt.Fprintf(&line, " — %s", out.field(name))
337 }338 }
338 if tpl.Description != "" {339 if tpl.Description != "" {
339- fmt.Fprintf(&line, ": %s", sanitizeText(tpl.Description, false))340+ fmt.Fprintf(&line, ": %s", out.field(tpl.Description))
340 }341 }
341 line.WriteByte('\n')342 line.WriteByte('\n')
342 out.write(line.String())343 out.write(line.String())
@@ -362,6 +363,27 @@ type budgetedText struct {
362 363
363 func (t *budgetedText) full() bool { return t.budget.truncated }364 func (t *budgetedText) full() bool { return t.budget.truncated }
364 365
366+// remaining is how many more bytes of text fit.
367+func (t *budgetedText) remaining() int { return max(0, maxResultTextBytes-t.budget.used) }
368+
369+// field prepares one server-supplied string for output: it is cut to the
370+// remaining budget first (so a multi-megabyte description is never fully
371+// processed) and then stripped of control sequences and line breaks.
372+func (t *budgetedText) field(s string) string {
373+ return sanitizeText(clipUTF8(s, t.remaining()+1), false)
374+}
375+
376+// clipUTF8 shortens s to at most n bytes without splitting a rune.
377+func clipUTF8(s string, n int) string {
378+ if len(s) <= n {
379+ return s
380+ }
381+ for n > 0 && !utf8.RuneStart(s[n]) {
382+ n--
383+ }
384+ return s[:n]
385+}
386+
365 func (t *budgetedText) write(s string) {387 func (t *budgetedText) write(s string) {
366 if t.budget.truncated {388 if t.budget.truncated {
367 return389 return

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close