Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.2.0
0.3.0
2 changes: 2 additions & 0 deletions config.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ system_terminal:
enabled: false
cleanup:
timeout: 2m0s
files:
show_hidden: true
plans:
ttl: 24h0m0s
retention_days: 30
Expand Down
4 changes: 4 additions & 0 deletions internal/ai/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ type Message struct {
// while showing the operator a short label. Never sent to the
// provider.
Display string `json:"display,omitempty"`
// Hidden marks a turn the UI must not show at all: prompts composed
// by the product (e.g. "analyze these logs") rather than typed by
// the operator. The model still sees Content.
Hidden bool `json:"hidden,omitempty"`
// ToolCalls is set on an assistant message that wants tools run.
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
// ToolCallID and Name identify a role:"tool" result message.
Expand Down
7 changes: 5 additions & 2 deletions internal/ai/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,8 @@ func (s *Session) touch() {
// AddUserMessage records a user turn. When display differs from
// content, the model sees content (e.g. message plus embedded logs)
// while the UI shows display (e.g. a short label).
func (s *Session) AddUserMessage(content, display string) {
s.Messages = append(s.Messages, Message{Role: "user", Content: content, Display: display})
func (s *Session) AddUserMessage(content, display string, hidden bool) {
s.Messages = append(s.Messages, Message{Role: "user", Content: content, Display: display, Hidden: hidden})
s.touch()
}

Expand Down Expand Up @@ -206,6 +206,9 @@ func (s *Session) DisplayMessages() []DisplayTurn {
for _, m := range s.Messages {
switch m.Role {
case "user":
if m.Hidden {
continue
}
shown := m.Content
if m.Display != "" {
shown = m.Display
Expand Down
5 changes: 3 additions & 2 deletions internal/api/ai_session_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ func (s *Server) createAISession(c *gin.Context) {
AutoRun bool `json:"auto_run"`
Message string `json:"message"`
Context string `json:"context"`
Seed bool `json:"seed"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
Expand All @@ -161,7 +162,7 @@ func (s *Server) createAISession(c *gin.Context) {
prompt := ai.BuildSessionPrompt(req.Scope, req.Deployment, s.config.AI.DocsURL)
sess := ai.NewSession(req.Scope, req.Deployment, req.AutoRun, sessionActorFrom(c), prompt)
content, display := composeUserMessage(req.Message, req.Context)
sess.AddUserMessage(content, display)
sess.AddUserMessage(content, display, req.Seed)

if err := s.advanceSession(c, sess); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
Expand Down Expand Up @@ -224,7 +225,7 @@ func (s *Server) postAISessionMessage(c *gin.Context) {
}

content, display := composeUserMessage(req.Message, req.Context)
sess.AddUserMessage(content, display)
sess.AddUserMessage(content, display, false)
if err := s.advanceSession(c, sess); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
Expand Down
45 changes: 45 additions & 0 deletions internal/api/ai_session_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,51 @@ func TestAISessionHidesBulkyContext(t *testing.T) {
}
}

func TestAISessionHidesSeededPrompt(t *testing.T) {
s, tmpDir, ts := setupPlanTestServer(t)
createTestDeployment(t, tmpDir, "myapp", &models.ServiceMetadata{Name: "myapp"})

stub := &scriptedProvider{responses: []*ai.Response{{Content: "All healthy.", Model: "scripted"}}}
s.aiProvider = stub

resp, parsed := doJSON(t, http.MethodPost, ts.URL+"/api/ai/sessions", map[string]interface{}{
"scope": "deployment",
"deployment": "myapp",
"auto_run": true,
"message": "Analyze the recent logs for myapp.",
"context": "```\nGET /health 200 OK\n```",
"seed": true,
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, body %v", resp.StatusCode, parsed)
}

// A seeded prompt is composed by the product, not typed by the
// operator, so the transcript starts with the assistant's answer.
messages := parsed["messages"].([]interface{})
for _, m := range messages {
turn := m.(map[string]interface{})
if turn["role"] == "user" {
t.Errorf("seeded prompt leaked into the transcript: %v", turn["content"])
}
}
if len(messages) == 0 {
t.Fatal("expected the assistant turn in the transcript")
}

// The model must still receive the seeded prompt and its context.
var prompt strings.Builder
for _, m := range stub.lastRequestMessages() {
prompt.WriteString(m.Content)
}
if !strings.Contains(prompt.String(), "Analyze the recent logs for myapp.") {
t.Error("seeded prompt was not sent to the model")
}
if !strings.Contains(prompt.String(), "GET /health 200 OK") {
t.Error("seeded context was not sent to the model")
}
}

func TestAISessionApprovalGating(t *testing.T) {
s, tmpDir, ts := setupPlanTestServer(t)
createTestDeployment(t, tmpDir, "myapp", &models.ServiceMetadata{Name: "myapp"})
Expand Down
20 changes: 16 additions & 4 deletions internal/api/container_exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,19 @@ func (s *Server) containerExec(c *gin.Context) {
cmd := exec.Command("docker", "exec", "-i", "-t", containerID, shell)
cmd.Env = append(os.Environ(), "TERM=xterm-256color")

// Start with PTY
guard := func(command string) (bool, *models.ProtectedCommandRule, error) {
return s.protectedContainerCommandBlocked(containerID, command)
}
streamPTY(conn, cmd, guard)
}

// terminalCommandGuard decides whether a submitted command line may run.
type terminalCommandGuard func(command string) (bool, *models.ProtectedCommandRule, error)

// streamPTY runs cmd under a PTY and pumps bytes between it and the
// websocket: binary frames carry raw terminal bytes, JSON text frames carry
// resizes, and each submitted line is checked against guard before it runs.
func streamPTY(conn *websocket.Conn, cmd *exec.Cmd, guard terminalCommandGuard) {
ptmx, err := pty.Start(cmd)
if err != nil {
sendError(conn, "Failed to start terminal: "+err.Error())
Expand Down Expand Up @@ -181,7 +193,7 @@ func (s *Server) containerExec(c *gin.Context) {
}
}

if blocked, err := s.handleTerminalInput(containerID, ptmx, conn, message, &commandBuffer); err != nil {
if blocked, err := handleTerminalInput(guard, ptmx, conn, message, &commandBuffer); err != nil {
log.Printf("PTY write error: %v", err)
return
} else if blocked {
Expand All @@ -205,7 +217,7 @@ func (s *Server) containerExec(c *gin.Context) {
wg.Wait()
}

func (s *Server) handleTerminalInput(containerID string, ptmx *os.File, conn *websocket.Conn, message []byte, commandBuffer *strings.Builder) (bool, error) {
func handleTerminalInput(guard terminalCommandGuard, ptmx *os.File, conn *websocket.Conn, message []byte, commandBuffer *strings.Builder) (bool, error) {
for _, b := range message {
switch b {
case '\r', '\n':
Expand All @@ -214,7 +226,7 @@ func (s *Server) handleTerminalInput(containerID string, ptmx *os.File, conn *we
if command == "" {
continue
}
blocked, rule, err := s.protectedContainerCommandBlocked(containerID, command)
blocked, rule, err := guard(command)
if err != nil {
return false, err
}
Expand Down
Loading
Loading