sketch/loop: add PortMonitor for TCP port monitoring with Agent integration
Add PortMonitor struct that uses Tailscale portlist library to monitor
open/listening TCP ports and send AgentMessage notifications to Agent
when ports are opened or closed, with cached port list access method.
When I asked Sketch to do this with the old implementation, it did
ok parsing /proc, but then it tried to conver it to ss format...
using a library seems to work ok!
Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: s8fc57de4b5583d34k
diff --git a/loop/server/loophttp.go b/loop/server/loophttp.go
index b6e2259..3f90f8b 100644
--- a/loop/server/loophttp.go
+++ b/loop/server/loophttp.go
@@ -104,6 +104,15 @@
SSHConnectionString string `json:"ssh_connection_string,omitempty"` // SSH connection string for container
DiffLinesAdded int `json:"diff_lines_added"` // Lines added from sketch-base to HEAD
DiffLinesRemoved int `json:"diff_lines_removed"` // Lines removed from sketch-base to HEAD
+ OpenPorts []Port `json:"open_ports,omitempty"` // Currently open TCP ports
+}
+
+// Port represents an open TCP port
+type Port struct {
+ Proto string `json:"proto"` // "tcp" or "udp"
+ Port uint16 `json:"port"` // port number
+ Process string `json:"process"` // optional process name
+ Pid int `json:"pid"` // process ID
}
type InitRequest struct {
@@ -1308,9 +1317,29 @@
SSHConnectionString: s.agent.SSHConnectionString(),
DiffLinesAdded: diffAdded,
DiffLinesRemoved: diffRemoved,
+ OpenPorts: s.getOpenPorts(),
}
}
+// getOpenPorts retrieves the current open ports from the agent
+func (s *Server) getOpenPorts() []Port {
+ ports := s.agent.GetPorts()
+ if ports == nil {
+ return nil
+ }
+
+ result := make([]Port, len(ports))
+ for i, port := range ports {
+ result[i] = Port{
+ Proto: port.Proto,
+ Port: port.Port,
+ Process: port.Process,
+ Pid: port.Pid,
+ }
+ }
+ return result
+}
+
func (s *Server) handleGitRawDiff(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
w.WriteHeader(http.StatusMethodNotAllowed)
diff --git a/loop/server/loophttp_test.go b/loop/server/loophttp_test.go
index f6ec8c7..cd97b12 100644
--- a/loop/server/loophttp_test.go
+++ b/loop/server/loophttp_test.go
@@ -14,6 +14,7 @@
"sketch.dev/llm/conversation"
"sketch.dev/loop"
"sketch.dev/loop/server"
+ "tailscale.com/portlist"
)
// mockAgent is a mock implementation of loop.CodingAgent for testing
@@ -263,6 +264,14 @@
func (m *mockAgent) SkabandAddr() string { return m.skabandAddr }
func (m *mockAgent) LinkToGitHub() bool { return false }
func (m *mockAgent) DiffStats() (int, int) { return 0, 0 }
+func (m *mockAgent) GetPorts() []portlist.Port {
+ // Mock returns a few test ports
+ return []portlist.Port{
+ {Proto: "tcp", Port: 22, Process: "sshd", Pid: 1234},
+ {Proto: "tcp", Port: 80, Process: "nginx", Pid: 5678},
+ {Proto: "tcp", Port: 8080, Process: "test-server", Pid: 9012},
+ }
+}
// TestSSEStream tests the SSE stream endpoint
func TestSSEStream(t *testing.T) {
@@ -588,3 +597,79 @@
})
}
}
+
+// TestStateEndpointIncludesPorts tests that the /state endpoint includes port information
+func TestStateEndpointIncludesPorts(t *testing.T) {
+ mockAgent := &mockAgent{
+ messages: []loop.AgentMessage{},
+ messageCount: 0,
+ currentState: "initial",
+ subscribers: []chan *loop.AgentMessage{},
+ gitUsername: "test-user",
+ initialCommit: "abc123",
+ branchName: "test-branch",
+ branchPrefix: "test-",
+ workingDir: "/tmp/test",
+ sessionID: "test-session",
+ slug: "test-slug",
+ skabandAddr: "http://localhost:8080",
+ }
+
+ // Create a test server
+ server, err := server.New(mockAgent, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Create a test request to the /state endpoint
+ req, err := http.NewRequest("GET", "/state", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Create a response recorder
+ rr := httptest.NewRecorder()
+
+ // Execute the request
+ server.ServeHTTP(rr, req)
+
+ // Check the response
+ if status := rr.Code; status != http.StatusOK {
+ t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusOK)
+ }
+
+ // Check that the response contains port information
+ responseBody := rr.Body.String()
+ t.Logf("Response body: %s", responseBody)
+
+ // Verify the response contains the expected ports
+ if !strings.Contains(responseBody, `"open_ports"`) {
+ t.Error("Response should contain 'open_ports' field")
+ }
+
+ if !strings.Contains(responseBody, `"port": 22`) {
+ t.Error("Response should contain port 22 from mock")
+ }
+
+ if !strings.Contains(responseBody, `"port": 80`) {
+ t.Error("Response should contain port 80 from mock")
+ }
+
+ if !strings.Contains(responseBody, `"port": 8080`) {
+ t.Error("Response should contain port 8080 from mock")
+ }
+
+ if !strings.Contains(responseBody, `"process": "sshd"`) {
+ t.Error("Response should contain process name 'sshd'")
+ }
+
+ if !strings.Contains(responseBody, `"process": "nginx"`) {
+ t.Error("Response should contain process name 'nginx'")
+ }
+
+ if !strings.Contains(responseBody, `"proto": "tcp"`) {
+ t.Error("Response should contain protocol 'tcp'")
+ }
+
+ t.Log("State endpoint includes port information correctly")
+}