Compare commits

...
Author SHA1 Message Date
moloch-- 593925b4a5 Update docs for wg+grpc/mtls multiplayer 2026-03-26 17:30:33 -07:00
Joe 255477e572 Merge pull request #2228 from BishopFox/fix/mcp
Fix/mcp
2026-03-26 17:21:49 -07:00
moloch-- 0acc6911d8 Tweak ai mode init 2026-03-26 16:59:27 -07:00
moloch-- 4ca026906b Merge master 2026-03-26 15:54:00 -07:00
moloch-- 97a249e28e Implement http auth for mcp 2026-03-26 15:32:26 -07:00
moloch-- 9f331687d3 wip on agentic actions 2026-03-24 19:06:47 -07:00
Joe 14d68d6fcd Merge pull request #2225 from BishopFox/ai/tui2
Ai/tui
2026-03-22 08:36:46 -07:00
moloch-- 05c5334fea Switch to openai sdk 2026-03-21 09:23:49 -07:00
moloch-- d277a14c57 Add examples dir 2026-03-21 08:29:27 -07:00
moloch-- a7b5dbe6ee Tweak colors for user blocks 2026-03-20 20:29:28 -07:00
moloch-- c188dc1e69 Make messages copy-able 2026-03-20 20:19:02 -07:00
moloch-- 9fc9d1762e Merge branch 'master' into ai/tui2 2026-03-20 20:00:05 -07:00
Joe 4ef86448d0 Merge pull request #2224 from BishopFox/clean-makefile
Cleanup makefile vars
2026-03-20 08:22:50 -07:00
moloch-- 4cd010afb9 wip on copy/paste 2026-03-19 08:52:46 -07:00
41 changed files with 6226 additions and 1156 deletions

No files matched your search

+2
View File
@@ -1,5 +1,7 @@
/crush
/examples
/.vscode
/.vscode/*
/.devcontainer
+24 -19
View File
@@ -25,6 +25,7 @@ func AICmd(_ *cobra.Command, con *console.SliverClient, _ []string) {
defer con.RemoveEventListener(listenerID)
model := newAIModel(con, buildAIContext(con), listener)
model.showExperimentalWarningModal()
width, height := 100, 30
if w, h, err := term.GetSize(0); err == nil && w > 0 && h > 0 {
@@ -48,13 +49,15 @@ type aiContext struct {
}
type aiTargetSummary struct {
Label string
Host string
OS string
Arch string
C2 string
Mode string
Details []string
SessionID string
BeaconID string
Label string
Host string
OS string
Arch string
C2 string
Mode string
Details []string
}
type aiConnectionSummary struct {
@@ -97,12 +100,13 @@ func buildAIContext(con *console.SliverClient) aiContext {
switch {
case session != nil:
ctx.target = aiTargetSummary{
Label: fmt.Sprintf("Session %s", fallback(session.Name, session.ID)),
Host: fallback(session.Hostname, "<unknown host>"),
OS: fallback(session.OS, "unknown"),
Arch: fallback(session.Arch, "unknown"),
C2: fallback(session.ActiveC2, "unknown"),
Mode: "interactive session",
SessionID: session.ID,
Label: fmt.Sprintf("Session %s", fallback(session.Name, session.ID)),
Host: fallback(session.Hostname, "<unknown host>"),
OS: fallback(session.OS, "unknown"),
Arch: fallback(session.Arch, "unknown"),
C2: fallback(session.ActiveC2, "unknown"),
Mode: "interactive session",
Details: []string{
fmt.Sprintf("User: %s", fallback(session.Username, "<unknown>")),
fmt.Sprintf("PID: %d", session.PID),
@@ -111,12 +115,13 @@ func buildAIContext(con *console.SliverClient) aiContext {
}
case beacon != nil:
ctx.target = aiTargetSummary{
Label: fmt.Sprintf("Beacon %s", fallback(beacon.Name, beacon.ID)),
Host: fallback(beacon.Hostname, "<unknown host>"),
OS: fallback(beacon.OS, "unknown"),
Arch: fallback(beacon.Arch, "unknown"),
C2: fallback(beacon.ActiveC2, "unknown"),
Mode: "asynchronous beacon",
BeaconID: beacon.ID,
Label: fmt.Sprintf("Beacon %s", fallback(beacon.Name, beacon.ID)),
Host: fallback(beacon.Hostname, "<unknown host>"),
OS: fallback(beacon.OS, "unknown"),
Arch: fallback(beacon.Arch, "unknown"),
C2: fallback(beacon.ActiveC2, "unknown"),
Mode: "asynchronous beacon",
Details: []string{
fmt.Sprintf("User: %s", fallback(beacon.Username, "<unknown>")),
fmt.Sprintf("Interval: %s", time.Duration(beacon.Interval).String()),
+53 -23
View File
@@ -49,7 +49,7 @@ func TestLoadAIStateCmdLoadsConversationOverRPC(t *testing.T) {
rpcClient, cleanup := newAITestRPCClient(t, server)
defer cleanup()
cmd := loadAIStateCmd(&console.SliverClient{Rpc: rpcClient}, "")
cmd := loadAIStateCmd(&console.SliverClient{Rpc: rpcClient}, aiTargetSummary{}, "")
msg := cmd()
loaded, ok := msg.(aiLoadedMsg)
@@ -112,7 +112,7 @@ func TestLoadAIStateCmdCreatesConversationWhenNoneExist(t *testing.T) {
rpcClient, cleanup := newAITestRPCClient(t, server)
defer cleanup()
msg := loadAIStateCmd(&console.SliverClient{Rpc: rpcClient}, "")()
msg := loadAIStateCmd(&console.SliverClient{Rpc: rpcClient}, aiTargetSummary{}, "")()
loaded, ok := msg.(aiLoadedMsg)
if !ok {
@@ -157,6 +157,7 @@ func TestSubmitPromptCmdCreatesConversationAndSavesUserMessage(t *testing.T) {
msg := submitPromptCmd(
&console.SliverClient{Rpc: rpcClient},
aiTargetSummary{SessionID: "session-1"},
nil,
"openai",
"gpt-test",
@@ -179,6 +180,9 @@ func TestSubmitPromptCmdCreatesConversationAndSavesUserMessage(t *testing.T) {
if server.saveConversationReqs[0].GetTitle() != "Explain the workflow." {
t.Fatalf("unexpected conversation title: %q", server.saveConversationReqs[0].GetTitle())
}
if server.saveConversationReqs[0].GetTargetSessionID() != "session-1" {
t.Fatalf("expected target session to be forwarded, got %+v", server.saveConversationReqs[0])
}
if len(server.saveMessageReqs) != 1 {
t.Fatalf("expected SaveAIConversationMessage to be called once, got %d", len(server.saveMessageReqs))
}
@@ -206,6 +210,7 @@ func TestSubmitPromptCmdUsesExistingConversationSettings(t *testing.T) {
msg := submitPromptCmd(
&console.SliverClient{Rpc: rpcClient},
aiTargetSummary{},
&clientpb.AIConversation{ID: "conv-1", Provider: "anthropic", Model: "claude-test", Title: "Thread"},
"openai",
"gpt-test",
@@ -262,10 +267,16 @@ func TestDeleteConversationCmdSendsDeleteRequest(t *testing.T) {
}
func TestWaitForAIConversationEventCmdFiltersForAIEvents(t *testing.T) {
conversation := &clientpb.AIConversation{ID: "conv-1", OperatorName: "alice"}
data, err := proto.Marshal(conversation)
eventPayload := &clientpb.AIConversationEvent{
EventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_CONVERSATION_UPDATED,
Conversation: &clientpb.AIConversation{
ID: "conv-1",
OperatorName: "alice",
},
}
data, err := proto.Marshal(eventPayload)
if err != nil {
t.Fatalf("marshal conversation: %v", err)
t.Fatalf("marshal conversation event: %v", err)
}
listener := make(chan *clientpb.Event, 2)
@@ -278,8 +289,8 @@ func TestWaitForAIConversationEventCmdFiltersForAIEvents(t *testing.T) {
if !ok {
t.Fatalf("expected aiConversationEventMsg, got %T", msg)
}
if aiEvent.conversation == nil || aiEvent.conversation.GetID() != "conv-1" {
t.Fatalf("unexpected AI conversation event payload: %+v", aiEvent.conversation)
if aiEvent.event == nil || aiEvent.event.GetConversation() == nil || aiEvent.event.GetConversation().GetID() != "conv-1" {
t.Fatalf("unexpected AI conversation event payload: %+v", aiEvent.event)
}
}
@@ -293,7 +304,7 @@ func TestWaitForAIConversationEventCmdReturnsClosedMessage(t *testing.T) {
}
}
func TestAIModelReloadsSharedConversationEvents(t *testing.T) {
func TestAIModelAppliesSharedConversationEventsWithoutReload(t *testing.T) {
listener := make(chan *clientpb.Event)
close(listener)
@@ -301,25 +312,30 @@ func TestAIModelReloadsSharedConversationEvents(t *testing.T) {
connection: aiConnectionSummary{Operator: "alice"},
}, listener)
model.loading = false
model.conversations = []*clientpb.AIConversation{{ID: "conv-1", Title: "First"}}
updated, cmd := model.Update(aiConversationEventMsg{conversation: &clientpb.AIConversation{
ID: "conv-2",
OperatorName: "bob",
updated, cmd := model.Update(aiConversationEventMsg{event: &clientpb.AIConversationEvent{
EventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_CONVERSATION_UPDATED,
Conversation: &clientpb.AIConversation{
ID: "conv-2",
OperatorName: "bob",
Title: "Shared thread",
},
}})
nextModel := updated.(*aiModel)
if !nextModel.loading {
t.Fatal("expected shared conversation event to trigger a reload")
if nextModel.loading {
t.Fatal("did not expect shared conversation event to trigger a reload")
}
if nextModel.status != "Conversation updated on the server. Refreshing..." {
t.Fatalf("unexpected reload status: %q", nextModel.status)
if conversationIndexByID(nextModel.conversations, "conv-2") < 0 {
t.Fatalf("expected shared conversation to be merged locally, got %+v", nextModel.conversations)
}
if cmd == nil {
t.Fatal("expected shared conversation event to schedule a refresh")
t.Fatal("expected shared conversation event to continue listening for events")
}
}
func TestAIModelReloadsDeleteEventsWhileAwaitingResponse(t *testing.T) {
func TestAIModelAppliesDeleteEventsWhileAwaitingResponse(t *testing.T) {
listener := make(chan *clientpb.Event)
close(listener)
@@ -332,22 +348,36 @@ func TestAIModelReloadsDeleteEventsWhileAwaitingResponse(t *testing.T) {
ID: "conv-2",
OperatorName: "alice",
UpdatedAt: 100,
TurnState: clientpb.AIConversationTurnState_AI_TURN_STATE_IN_PROGRESS,
Messages: []*clientpb.AIConversationMessage{
{Role: "user", Content: "still waiting"},
},
}
model.conversations = []*clientpb.AIConversation{
{ID: "conv-2", Title: "Current"},
{ID: "conv-1", Title: "Fallback"},
}
updated, cmd := model.Update(aiConversationEventMsg{conversation: &clientpb.AIConversation{
ID: "conv-2",
OperatorName: "bob",
updated, cmd := model.Update(aiConversationEventMsg{event: &clientpb.AIConversationEvent{
EventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_CONVERSATION_DELETED,
ConversationID: "conv-2",
Conversation: &clientpb.AIConversation{
ID: "conv-2",
},
}})
nextModel := updated.(*aiModel)
if !nextModel.loading {
t.Fatal("expected delete tombstone to reload the conversation")
if nextModel.loading {
t.Fatal("did not expect delete tombstone to trigger a reload")
}
if nextModel.currentConversation != nil {
t.Fatalf("expected deleted conversation to be cleared, got %+v", nextModel.currentConversation)
}
if conversationIndexByID(nextModel.conversations, "conv-2") >= 0 {
t.Fatalf("expected deleted conversation to be removed, got %+v", nextModel.conversations)
}
if cmd == nil {
t.Fatal("expected delete tombstone to schedule a refresh")
t.Fatal("expected delete tombstone to continue listening for events")
}
}
+1104 -157
View File
File diff suppressed because it is too large. Load diff
+321 -7
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"fmt"
"image/color"
"strings"
"sync"
"testing"
@@ -13,10 +14,20 @@ import (
"charm.land/lipgloss/v2"
"github.com/bishopfox/sliver/client/console"
aithinking "github.com/bishopfox/sliver/client/spin/thinking"
clienttheme "github.com/bishopfox/sliver/client/theme"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/charmbracelet/x/ansi"
)
func assertColorEqual(t *testing.T, got color.Color, want color.Color) {
t.Helper()
gotR, gotG, gotB, gotA := got.RGBA()
wantR, wantG, wantB, wantA := want.RGBA()
if gotR != wantR || gotG != wantG || gotB != wantB || gotA != wantA {
t.Fatalf("unexpected color: got rgba(%d,%d,%d,%d), want rgba(%d,%d,%d,%d)", gotR, gotG, gotB, gotA, wantR, wantG, wantB, wantA)
}
}
func TestPromptConversationTitleUsesFirstNonEmptyLine(t *testing.T) {
title := promptConversationTitle("\n\n First line title \nsecond line")
if title != "First line title" {
@@ -65,6 +76,27 @@ func TestBuildConversationMarkdownFallsBackToUserLabel(t *testing.T) {
}
}
func TestMessageBlockLabelIgnoresUnknownOperatorPlaceholders(t *testing.T) {
conversation := &clientpb.AIConversation{OperatorName: "<unknown>"}
message := &clientpb.AIConversationMessage{
Role: "user",
OperatorName: "<unknown>",
Content: "Hello",
}
label := messageBlockLabel(conversation, message)
if label != "User" {
t.Fatalf("expected placeholder operator names to fall back to User, got %q", label)
}
}
func TestTranscriptSpeakerStyleUsesPrimaryThemeForUserMessages(t *testing.T) {
styles := transcriptSpeakerStyle("alice", "user")
assertColorEqual(t, styles.border.GetForeground(), clienttheme.Primary())
assertColorEqual(t, styles.label.GetBackground(), clienttheme.Primary())
}
func TestBuildConversationMarkdownWithoutConversationAvoidsKeyHints(t *testing.T) {
markdown := buildConversationMarkdown(nil)
if strings.Contains(markdown, "`n`") {
@@ -171,6 +203,37 @@ func TestRenderConversationTranscriptLinesOnlyBoxesMessages(t *testing.T) {
}
}
func TestRenderConversationTranscriptLinesRendersReasoningAndToolBlocks(t *testing.T) {
conversation := &clientpb.AIConversation{
OperatorName: "alice",
Messages: []*clientpb.AIConversationMessage{
{
Kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_REASONING,
Visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY,
State: clientpb.AIConversationMessageState_AI_MESSAGE_STATE_COMPLETED,
Content: "Summary:\nChecked the active target before choosing a tool.",
},
{
Kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_TOOL_CALL,
Visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY,
State: clientpb.AIConversationMessageState_AI_MESSAGE_STATE_FAILED,
ToolName: "fs_ls",
ToolArguments: `{"path":"/tmp","session_id":"session-1"}`,
ToolResult: `{"path":"/tmp","exists":true}`,
ErrorText: "permission denied",
},
},
}
renderedRaw := strings.Join(renderConversationTranscriptLines(72, conversation), "\n")
rendered := ansi.Strip(renderedRaw)
for _, fragment := range []string{"Reasoning", "Checked the active target", "Tool: fs_ls", "ARGUMENTS", "\"path\": \"/tmp\"", "ERROR", "permission denied", "RESULT"} {
if !strings.Contains(rendered, fragment) {
t.Fatalf("expected transcript to contain %q, got %q", fragment, rendered)
}
}
}
func TestRenderTranscriptHeaderLinesUsesCompactInlineMetadata(t *testing.T) {
model := newAIModel(nil, aiContext{}, nil)
model.currentConversation = &clientpb.AIConversation{
@@ -234,6 +297,35 @@ func TestTranscriptSpeakerPaletteVariesAcrossUsers(t *testing.T) {
}
}
func TestTranscriptSpeakerStyleUsesPrimaryBorderForUserMessages(t *testing.T) {
userStyles := transcriptSpeakerStyle("alice", "user")
userBorder := transcriptSpeakerStyle("alice", "user").border.Render("│")
wantUserBorder := lipgloss.NewStyle().Foreground(clienttheme.Primary()).Render("│")
if userBorder != wantUserBorder {
t.Fatalf("expected user message border to use theme primary color")
}
wantUserLabel := lipgloss.NewStyle().
Bold(true).
Foreground(clienttheme.DefaultMod(900)).
Background(clienttheme.Primary()).
Padding(0, 1).
Render("alice")
if got := userStyles.label.Render("alice"); got != wantUserLabel {
t.Fatalf("expected user message label to use theme primary color")
}
wantUserMeta := lipgloss.NewStyle().Foreground(clienttheme.Primary()).Render("openai | gpt-5.4")
if got := userStyles.meta.Render("openai | gpt-5.4"); got != wantUserMeta {
t.Fatalf("expected user message metadata to use theme primary color")
}
assistantBorder := transcriptSpeakerStyle("AI", "assistant").border.Render("│")
if assistantBorder == wantUserBorder {
t.Fatalf("expected assistant message border to keep its non-primary speaker color")
}
}
func TestWindowResizeQueuesTranscriptRenderAsync(t *testing.T) {
model := newAIModel(nil, aiContext{}, nil)
model.width = 100
@@ -474,6 +566,29 @@ func TestConversationAwaitingResponseStopsWhenAssistantReplies(t *testing.T) {
}
}
func TestConversationAwaitingResponseUsesTurnState(t *testing.T) {
conversation := &clientpb.AIConversation{
TurnState: clientpb.AIConversationTurnState_AI_TURN_STATE_IN_PROGRESS,
Messages: []*clientpb.AIConversationMessage{
{
Role: "assistant",
Content: "Listing files",
Kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_TOOL_CALL,
Visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY,
},
},
}
if !conversationAwaitingResponse(conversation) {
t.Fatal("expected in-progress turn state to keep the conversation pending")
}
conversation.TurnState = clientpb.AIConversationTurnState_AI_TURN_STATE_FAILED
if conversationAwaitingResponse(conversation) {
t.Fatal("expected failed turn state to clear the pending marker")
}
}
func TestPendingLabelUsesThinkingWhenConfigured(t *testing.T) {
model := &aiModel{
config: &clientpb.AIConfigSummary{ThinkingLevel: "high"},
@@ -558,10 +673,10 @@ func TestTabCyclesVisiblePanesOnly(t *testing.T) {
}
}
func TestAIViewEnablesMouseCellMotion(t *testing.T) {
func TestAIViewLeavesMouseSelectionToTerminal(t *testing.T) {
view := aiView("hello")
if view.MouseMode != tea.MouseModeCellMotion {
t.Fatalf("expected AI view mouse mode %v, got %v", tea.MouseModeCellMotion, view.MouseMode)
if view.MouseMode != tea.MouseModeNone {
t.Fatalf("expected AI view mouse mode %v, got %v", tea.MouseModeNone, view.MouseMode)
}
}
@@ -648,6 +763,57 @@ func TestRenderComposerOmitsInlineControls(t *testing.T) {
}
}
func TestComposerPasteMsgInsertsAtCursor(t *testing.T) {
model := newAIModel(nil, aiContext{}, nil)
model.focus = aiFocusComposer
model.input = []rune("hello")
model.cursor = len(model.input)
updated, cmd := model.Update(tea.PasteMsg{Content: " world"})
if cmd != nil {
t.Fatalf("expected paste to update in place without commands, got %v", cmd)
}
updatedModel := updated.(*aiModel)
if got := string(updatedModel.input); got != "hello world" {
t.Fatalf("expected pasted composer input, got %q", got)
}
if updatedModel.cursor != len([]rune("hello world")) {
t.Fatalf("expected cursor at end of pasted input, got %d", updatedModel.cursor)
}
}
func TestTranscriptFocusDisablesMouseForNativeSelection(t *testing.T) {
model := newAIModel(nil, aiContext{}, nil)
model.width = 108
model.height = 28
model.focus = aiFocusTranscript
view := model.View()
if view.MouseMode != tea.MouseModeNone {
t.Fatalf("expected transcript focus to disable mouse reporting, got %v", view.MouseMode)
}
}
func TestTranscriptClickPromptsNativeSelection(t *testing.T) {
model := newAIModel(nil, aiContext{}, nil)
model.width = 108
model.height = 28
model.focus = aiFocusSidebar
rect := model.currentPaneRects().transcript
updated, _ := model.Update(tea.MouseClickMsg{
X: rect.x + rect.width/2,
Y: rect.y + rect.height/2,
Button: tea.MouseLeft,
})
model = updated.(*aiModel)
if got := model.focus; got != aiFocusTranscript {
t.Fatalf("expected transcript click to focus transcript, got %s", got.String())
}
if !strings.Contains(model.status, "Drag again") {
t.Fatalf("expected transcript click to explain native selection flow, got %q", model.status)
}
}
func TestLayoutHeightsKeepWideComposerCompact(t *testing.T) {
model := newAIModel(nil, aiContext{}, nil)
model.width = 120
@@ -1362,6 +1528,69 @@ func TestContextModalCancelsOnEscape(t *testing.T) {
}
}
func TestShowExperimentalWarningModalDefaultsToCancelFocus(t *testing.T) {
model := newAIModel(nil, aiContext{}, nil)
model.showExperimentalWarningModal()
if model.modal == nil || model.modal.kind != aiModalKindExperimentalWarning {
t.Fatalf("expected experimental warning modal, got %+v", model.modal)
}
if model.modal.title != aiExperimentalWarningTitle {
t.Fatalf("unexpected warning title: %q", model.modal.title)
}
if model.modal.body != aiExperimentalWarningBody {
t.Fatalf("unexpected warning body: %q", model.modal.body)
}
if model.modal.focus != aiModalFocusCancel {
t.Fatalf("expected cancel focus by default, got %v", model.modal.focus)
}
}
func TestExperimentalWarningModalCancelsOnEnterFromDefaultFocus(t *testing.T) {
model := newAIModel(nil, aiContext{}, nil)
model.showExperimentalWarningModal()
updated, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
if updated == nil {
t.Fatal("expected model to be returned")
}
if cmd == nil {
t.Fatal("expected cancel action to quit")
}
msg := cmd()
if _, ok := msg.(tea.QuitMsg); !ok {
t.Fatalf("expected tea.QuitMsg, got %#v", msg)
}
}
func TestExperimentalWarningModalAcceptsAfterTabFocus(t *testing.T) {
model := newAIModel(nil, aiContext{status: "Loading AI conversations from the server..."}, nil)
model.showExperimentalWarningModal()
updated, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyTab})
if cmd != nil {
t.Fatalf("did not expect focus change to queue work, got %v", cmd)
}
model = updated.(*aiModel)
if model.modal == nil || model.modal.focus != aiModalFocusConfirm {
t.Fatalf("expected confirm focus, got %+v", model.modal)
}
updated, cmd = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
if cmd == nil {
t.Fatal("expected accept to start AI startup")
}
updatedModel := updated.(*aiModel)
if updatedModel.modal != nil {
t.Fatalf("expected warning modal to close after accept, got %+v", updatedModel.modal)
}
if updatedModel.status != "Loading AI conversations from the server..." {
t.Fatalf("unexpected status after accept: %q", updatedModel.status)
}
}
func TestModalViewRetainsBackgroundContent(t *testing.T) {
model := newAIModel(nil, aiContext{}, nil)
model.width = 120
@@ -1453,6 +1682,31 @@ func TestNewConversationModalViewIncludesOverlayContent(t *testing.T) {
}
}
func TestExperimentalWarningModalViewIncludesDangerContent(t *testing.T) {
model := newAIModel(nil, aiContext{}, nil)
model.width = 120
model.height = 30
model.loading = false
model.conversations = []*clientpb.AIConversation{{ID: "conv-1", Title: "Thread"}}
model.currentConversation = &clientpb.AIConversation{ID: "conv-1", Title: "Thread"}
model.showExperimentalWarningModal()
view := ansi.Strip(model.View().Content)
expected := []string{
"Conversations",
aiExperimentalWarningTitle,
"provided on an EXPERIMENTAL basis",
"reliability or data integrity",
aiExperimentalWarningCancelLabel,
aiExperimentalWarningConfirmLabel,
}
for _, fragment := range expected {
if !strings.Contains(view, fragment) {
t.Fatalf("expected warning modal view to contain %q, got %q", fragment, view)
}
}
}
func TestDeletedConversationMsgRemovesConversationBeforeReload(t *testing.T) {
model := newAIModel(nil, aiContext{}, nil)
model.loading = false
@@ -1557,6 +1811,62 @@ func TestPromptSubmittedStartsAwaitingResponseImmediately(t *testing.T) {
}
}
func TestPromptSubmittedDoesNotDuplicateUserMessageWhenEventArrivesFirst(t *testing.T) {
model := newAIModel(nil, aiContext{
connection: aiConnectionSummary{Operator: "<unknown>"},
}, nil)
model.loading = false
model.submittingPrompt = true
model.pendingPrompt = "What changed?"
model.conversations = []*clientpb.AIConversation{
{ID: "conv-1", Title: "Thread", Provider: "openai", Model: "gpt-test"},
}
model.currentConversation = &clientpb.AIConversation{
ID: "conv-1",
Title: "Thread",
Provider: "openai",
Model: "gpt-test",
Messages: []*clientpb.AIConversationMessage{
{ID: "assistant-1", Role: "assistant", Content: "Previous reply"},
{
ID: "msg-1",
ConversationID: "conv-1",
Role: "user",
Content: "What changed?",
},
},
}
updated, _ := model.Update(aiPromptSubmittedMsg{
conversationID: "conv-1",
conversation: &clientpb.AIConversation{
ID: "conv-1",
Title: "Thread",
Provider: "openai",
Model: "gpt-test",
},
message: &clientpb.AIConversationMessage{
ID: "msg-1",
ConversationID: "conv-1",
Role: "user",
Content: "What changed?",
},
status: "Saved prompt to Thread. Waiting for AI response...",
})
updatedModel := updated.(*aiModel)
if got := len(updatedModel.currentConversation.GetMessages()); got != 2 {
t.Fatalf("expected submit replay to preserve 2 messages without duplication, got %d", got)
}
last := lastConversationMessage(updatedModel.currentConversation)
if last == nil || last.GetID() != "msg-1" {
t.Fatalf("expected the existing saved user message to remain the last message, got %#v", last)
}
if label := messageBlockLabel(updatedModel.currentConversation, last); label != "User" {
t.Fatalf("expected unknown operator placeholders to fall back to User, got %q", label)
}
}
func TestAsyncErrClearsAwaitingResponseWhenSubmitFails(t *testing.T) {
model := newAIModel(nil, aiContext{}, nil)
model.awaitingResponse = true
@@ -1963,10 +2273,14 @@ func TestAIProgramSkipsRedundantPendingConversationReload(t *testing.T) {
t.Fatal("expected animation to start before redundant event processing")
}
program.Send(aiConversationEventMsg{conversation: &clientpb.AIConversation{
ID: "conv-1",
OperatorName: "alice",
UpdatedAt: 100,
program.Send(aiConversationEventMsg{event: &clientpb.AIConversationEvent{
EventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_TURN_STARTED,
Conversation: &clientpb.AIConversation{
ID: "conv-1",
OperatorName: "alice",
UpdatedAt: 100,
TurnState: clientpb.AIConversationTurnState_AI_TURN_STATE_IN_PROGRESS,
},
}})
select {
+1
View File
@@ -66,6 +66,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
flags.Bind("", false, consoleCmd, func(f *pflag.FlagSet) {
f.String("transport", string(defaultConfig.Transport), "mcp transport (http or sse)")
f.String("url", "", "mcp server URL (defaults to local server settings)")
f.String("auth-token", "", "MCP auth token (defaults to the local mcp.yaml token)")
})
flags.BindFlagCompletions(consoleCmd, func(comp *carapace.ActionMap) {
(*comp)["transport"] = carapace.ActionValues("http", "sse")
+29 -4
View File
@@ -6,12 +6,14 @@ import (
"errors"
"fmt"
"io"
"os"
"strings"
"github.com/bishopfox/sliver/client/console"
clientmcp "github.com/bishopfox/sliver/client/mcp"
"github.com/bishopfox/sliver/client/version"
mcpclient "github.com/mark3labs/mcp-go/client"
mcptransport "github.com/mark3labs/mcp-go/client/transport"
mcpapi "github.com/mark3labs/mcp-go/mcp"
"github.com/spf13/cobra"
)
@@ -36,7 +38,23 @@ func McpConsoleCmd(cmd *cobra.Command, con *console.SliverClient, args []string)
}
}
client, err := newConsoleClient(transport, targetURL)
authToken, _ := cmd.Flags().GetString("auth-token")
if authToken == "" {
status := clientmcp.GetStatus()
if status.AuthToken != "" {
authToken = status.AuthToken
} else {
authInfo, loadErr := clientmcp.LoadAuthInfo()
if loadErr == nil {
authToken = authInfo.Token
} else if !errors.Is(loadErr, os.ErrNotExist) {
con.PrintErrorf("%s\n", loadErr)
return
}
}
}
client, err := newConsoleClient(transport, targetURL, authToken)
if err != nil {
con.PrintErrorf("%s\n", err)
return
@@ -68,12 +86,19 @@ func McpConsoleCmd(cmd *cobra.Command, con *console.SliverClient, args []string)
runConsoleREPL(ctx, client, cmd.InOrStdin(), cmd.OutOrStdout())
}
func newConsoleClient(transport clientmcp.Transport, targetURL string) (*mcpclient.Client, error) {
func newConsoleClient(transport clientmcp.Transport, targetURL string, authToken string) (*mcpclient.Client, error) {
if strings.TrimSpace(authToken) == "" {
return nil, fmt.Errorf("mcp auth token is required (use --auth-token or start the local MCP server first)")
}
headers := map[string]string{
clientmcp.AuthHeaderName(): authToken,
}
switch transport {
case clientmcp.TransportHTTP:
return mcpclient.NewStreamableHttpClient(targetURL)
return mcpclient.NewStreamableHttpClient(targetURL, mcptransport.WithHTTPHeaders(headers))
case clientmcp.TransportSSE:
return mcpclient.NewSSEMCPClient(targetURL)
return mcpclient.NewSSEMCPClient(targetURL, mcpclient.WithHeaders(headers))
default:
return nil, fmt.Errorf("unsupported transport %q", transport)
}
+6
View File
@@ -26,6 +26,12 @@ func McpCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
} else {
con.PrintErrorf("Endpoint: %s\n", err)
}
if status.AuthHeader != "" {
con.Printf("Auth Header: %s\n", status.AuthHeader)
}
if status.AuthToken != "" {
con.Printf("Auth Token: %s\n", status.AuthToken)
}
if status.Running && !status.StartedAt.IsZero() {
uptime := time.Since(status.StartedAt).Truncate(time.Second)
+7
View File
@@ -43,4 +43,11 @@ func McpStartCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
if err == nil {
con.PrintInfof("Endpoint: %s\n", endpoint)
}
status := slivermcp.GetStatus()
if status.AuthHeader != "" {
con.PrintInfof("Auth Header: %s\n", status.AuthHeader)
}
if status.AuthToken != "" {
con.PrintInfof("Auth Token: %s\n", status.AuthToken)
}
}
+191
View File
@@ -0,0 +1,191 @@
package mcp
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/bishopfox/sliver/client/assets"
"gopkg.in/yaml.v3"
)
const (
authConfigFileName = "mcp.yaml"
authHeaderName = "Authorization"
minAuthTokenLength = 8
authTokenBytes = 16
sseMessagePath = "/message"
)
// AuthInfo describes the persisted MCP HTTP/SSE authentication settings.
type AuthInfo struct {
Header string
Token string
ConfigPath string
}
// AuthHeaderName returns the required header name for MCP HTTP/SSE requests.
func AuthHeaderName() string {
return authHeaderName
}
// AuthConfigPath returns the MCP auth config path in the client app directory.
func AuthConfigPath() string {
rootDir, _ := filepath.Abs(assets.GetRootAppDir())
return filepath.Join(rootDir, authConfigFileName)
}
type authConfig struct {
Token string `yaml:"token"`
}
// LoadAuthInfo loads the persisted MCP auth configuration without creating it.
func LoadAuthInfo() (AuthInfo, error) {
cfg, exists, err := loadAuthConfig()
if err != nil {
return AuthInfo{}, err
}
if !exists {
return AuthInfo{}, os.ErrNotExist
}
cfg.Token = strings.TrimSpace(cfg.Token)
if err := validateAuthToken(cfg.Token); err != nil {
return AuthInfo{}, fmt.Errorf("invalid mcp token in %s: %w", AuthConfigPath(), err)
}
return AuthInfo{
Header: authHeaderName,
Token: cfg.Token,
ConfigPath: AuthConfigPath(),
}, nil
}
// ResolveAuthInfo loads the persisted auth token or creates and saves one on first use.
func ResolveAuthInfo() (AuthInfo, bool, error) {
cfg, exists, err := loadAuthConfig()
if err != nil {
return AuthInfo{}, false, err
}
if exists {
cfg.Token = strings.TrimSpace(cfg.Token)
if err := validateAuthToken(cfg.Token); err != nil {
return AuthInfo{}, false, fmt.Errorf("invalid mcp token in %s: %w", AuthConfigPath(), err)
}
return AuthInfo{
Header: authHeaderName,
Token: cfg.Token,
ConfigPath: AuthConfigPath(),
}, false, nil
}
token, err := generateAuthToken()
if err != nil {
return AuthInfo{}, false, err
}
cfg = &authConfig{Token: token}
if err := saveAuthConfig(cfg); err != nil {
return AuthInfo{}, false, err
}
return AuthInfo{
Header: authHeaderName,
Token: token,
ConfigPath: AuthConfigPath(),
}, true, nil
}
func loadAuthConfig() (*authConfig, bool, error) {
data, err := os.ReadFile(AuthConfigPath())
if os.IsNotExist(err) {
return nil, false, nil
}
if err != nil {
return nil, false, err
}
cfg := &authConfig{}
if err := yaml.Unmarshal(data, cfg); err != nil {
return nil, true, err
}
return cfg, true, nil
}
func saveAuthConfig(cfg *authConfig) error {
data, err := yaml.Marshal(cfg)
if err != nil {
return err
}
return os.WriteFile(AuthConfigPath(), data, 0o600)
}
func generateAuthToken() (string, error) {
tokenBytes := make([]byte, authTokenBytes)
if _, err := rand.Read(tokenBytes); err != nil {
return "", fmt.Errorf("generate mcp auth token: %w", err)
}
return hex.EncodeToString(tokenBytes), nil
}
func validateAuthToken(token string) error {
if len(strings.TrimSpace(token)) < minAuthTokenLength {
return fmt.Errorf("token must be at least %d characters", minAuthTokenLength)
}
return nil
}
func authMiddleware(token string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !authTokenMatches(r.Header.Get(authHeaderName), token) {
http.Error(w, fmt.Sprintf("missing or invalid %s header", authHeaderName), http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
func authTokenMatches(actual string, expected string) bool {
if len(expected) == 0 || len(actual) != len(expected) {
return false
}
return subtle.ConstantTimeCompare([]byte(actual), []byte(expected)) == 1
}
type authenticatedHTTPTransport struct {
server *http.Server
}
func newAuthenticatedTransport(cfg Config, handler http.Handler, token string) serverTransport {
mux := http.NewServeMux()
authenticatedHandler := authMiddleware(token, handler)
switch cfg.Transport {
case TransportHTTP:
mux.Handle("/mcp", authenticatedHandler)
case TransportSSE:
mux.Handle("/sse", authenticatedHandler)
mux.Handle(sseMessagePath, authenticatedHandler)
}
return &authenticatedHTTPTransport{
server: &http.Server{
Handler: mux,
},
}
}
func (t *authenticatedHTTPTransport) Start(addr string) error {
if t.server.Addr == "" {
t.server.Addr = addr
} else if t.server.Addr != addr {
return fmt.Errorf("conflicting listen address: %q vs %q", t.server.Addr, addr)
}
return t.server.ListenAndServe()
}
func (t *authenticatedHTTPTransport) Shutdown(ctx context.Context) error {
return t.server.Shutdown(ctx)
}
+97
View File
@@ -0,0 +1,97 @@
package mcp
import (
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
)
func TestResolveAuthInfoCreatesAndPersistsToken(t *testing.T) {
t.Setenv("SLIVER_CLIENT_ROOT_DIR", t.TempDir())
authInfo, created, err := ResolveAuthInfo()
if err != nil {
t.Fatalf("ResolveAuthInfo returned error: %v", err)
}
if !created {
t.Fatalf("expected first ResolveAuthInfo call to create a token")
}
if authInfo.Header != AuthHeaderName() {
t.Fatalf("expected header %q, got %q", AuthHeaderName(), authInfo.Header)
}
if len(authInfo.Token) != authTokenBytes*2 {
t.Fatalf("expected %d hex characters, got %d", authTokenBytes*2, len(authInfo.Token))
}
data, err := os.ReadFile(AuthConfigPath())
if err != nil {
t.Fatalf("failed to read persisted auth config: %v", err)
}
if !strings.Contains(string(data), "token: "+authInfo.Token) {
t.Fatalf("persisted config did not contain generated token: %s", string(data))
}
authInfoAgain, createdAgain, err := ResolveAuthInfo()
if err != nil {
t.Fatalf("second ResolveAuthInfo returned error: %v", err)
}
if createdAgain {
t.Fatalf("expected second ResolveAuthInfo call to reuse the existing token")
}
if authInfoAgain.Token != authInfo.Token {
t.Fatalf("expected persisted token %q, got %q", authInfo.Token, authInfoAgain.Token)
}
}
func TestResolveAuthInfoRejectsInvalidExistingToken(t *testing.T) {
t.Setenv("SLIVER_CLIENT_ROOT_DIR", t.TempDir())
if err := os.WriteFile(AuthConfigPath(), []byte("token: short\n"), 0o600); err != nil {
t.Fatalf("failed to seed auth config: %v", err)
}
_, _, err := ResolveAuthInfo()
if err == nil {
t.Fatal("expected invalid token error")
}
if !strings.Contains(err.Error(), "at least 8 characters") {
t.Fatalf("expected length validation error, got %v", err)
}
}
func TestAuthMiddlewareRequiresExactToken(t *testing.T) {
const token = "0123456789abcdef"
handler := authMiddleware(token, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
testCases := []struct {
name string
header string
statusCode int
}{
{name: "missing", statusCode: http.StatusUnauthorized},
{name: "wrong", header: "wrong-token", statusCode: http.StatusUnauthorized},
{name: "prefixed", header: "Bearer " + token, statusCode: http.StatusUnauthorized},
{name: "exact", header: token, statusCode: http.StatusNoContent},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/mcp", nil)
if testCase.header != "" {
req.Header.Set(AuthHeaderName(), testCase.header)
}
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
if recorder.Code != testCase.statusCode {
t.Fatalf("expected status %d, got %d", testCase.statusCode, recorder.Code)
}
})
}
}
+24 -11
View File
@@ -20,10 +20,13 @@ var (
// Status describes the running state of the MCP server.
type Status struct {
Running bool
StartedAt time.Time
Config Config
LastError string
Running bool
StartedAt time.Time
Config Config
LastError string
AuthHeader string
AuthToken string
AuthConfigPath string
}
type serverTransport interface {
@@ -42,6 +45,7 @@ type mcpManager struct {
done chan struct{}
logger *log.Logger
logFile *os.File
authInfo AuthInfo
}
func newManager() *mcpManager {
@@ -93,10 +97,13 @@ func (m *mcpManager) status() Status {
m.mu.Lock()
defer m.mu.Unlock()
return Status{
Running: m.running,
StartedAt: m.startedAt,
Config: m.cfg,
LastError: m.lastErr,
Running: m.running,
StartedAt: m.startedAt,
Config: m.cfg,
LastError: m.lastErr,
AuthHeader: m.authInfo.Header,
AuthToken: m.authInfo.Token,
AuthConfigPath: m.authInfo.ConfigPath,
}
}
@@ -105,6 +112,10 @@ func (m *mcpManager) start(cfg Config, rpc rpcpb.SliverRPCClient) error {
if err := cfg.Validate(); err != nil {
return err
}
authInfo, _, err := ResolveAuthInfo()
if err != nil {
return err
}
m.mu.Lock()
if m.running {
@@ -120,9 +131,9 @@ func (m *mcpManager) start(cfg Config, rpc rpcpb.SliverRPCClient) error {
var transport serverTransport
switch cfg.Transport {
case TransportHTTP:
transport = mcpserver.NewStreamableHTTPServer(mcpServer.server)
transport = newAuthenticatedTransport(cfg, mcpserver.NewStreamableHTTPServer(mcpServer.server), authInfo.Token)
case TransportSSE:
transport = mcpserver.NewSSEServer(mcpServer.server)
transport = newAuthenticatedTransport(cfg, mcpserver.NewSSEServer(mcpServer.server), authInfo.Token)
default:
m.mu.Unlock()
logFile.Close()
@@ -138,11 +149,12 @@ func (m *mcpManager) start(cfg Config, rpc rpcpb.SliverRPCClient) error {
m.done = make(chan struct{})
m.logger = logger
m.logFile = logFile
m.authInfo = authInfo
addr := cfg.ListenAddress
done := m.done
m.mu.Unlock()
logger.Printf("starting mcp server transport=%s listen=%s", cfg.Transport, cfg.ListenAddress)
logger.Printf("starting mcp server transport=%s listen=%s auth_header=%s auth_config=%s", cfg.Transport, cfg.ListenAddress, authInfo.Header, authInfo.ConfigPath)
go m.run(addr, transport, done)
return nil
}
@@ -161,6 +173,7 @@ func (m *mcpManager) run(addr string, transport serverTransport, done chan struc
m.running = false
m.transport = nil
m.server = nil
m.authInfo = AuthInfo{}
logFile := m.logFile
m.logFile = nil
m.logger = nil
+229
View File
@@ -0,0 +1,229 @@
# Sliver AI Agentic Loop
## Goal
Port the example agentic loop from `examples/codex/codex-rs` into Sliver's AI chat so the server can:
- run a multi-step Responses API turn
- emit turn and item lifecycle events similar to Codex
- persist reasoning and tool-call items for sync and restoration
- keep those non-chat items out of the model context window
- render the new items in the client TUI
## How The Codex Example Loop Works
The Codex example splits the loop into three layers.
### 1. Provider/Protocol Layer
The protocol layer receives raw model events such as:
- turn started / completed / failed
- reasoning deltas and completed reasoning items
- command/tool begin and end notifications
- assistant messages
These provider events are fine-grained and often arrive as partial updates.
### 2. Event Processor Layer
`event_processor_with_jsonl_output.rs` converts the raw provider stream into a stable thread event model.
The important behaviors are:
- a turn gets a lifecycle: `turn.started`, then `turn.completed` or `turn.failed`
- each logical item gets a stable id
- begin/end pairs are folded into a single item lifecycle
- reasoning is emitted as its own item type
- tool calls and command executions are emitted as their own item types
- active items can be updated in place before completion
This is the key abstraction that makes the TUI and persisted history simple.
### 3. TUI Layer
The Codex TUI consumes those stable events rather than reloading the whole thread.
That lets it:
- append new items incrementally
- update an existing in-flight item in place
- keep the transcript stable while a turn runs
- restore the same item types from stored history later
## Sliver Port
Sliver ports the same shape, adapted to its existing RPC and database model.
### Conversation State
`AIConversation` now stores:
- `ActiveTurnID`
- `TurnState`
- `TargetSessionID`
- `TargetBeaconID`
The turn fields let clients restore "waiting", "completed", and "failed" state without inferring it from the last chat message.
The target fields bind a conversation to the active session or beacon so server-side tools can default to the conversation target.
### Message State
`AIConversationMessage` now stores:
- `Kind`: chat, reasoning, or tool call
- `Visibility`: context or UI-only
- `State`: in progress, completed, or failed
- `TurnID`
- `ItemID`
- tool call metadata: name, arguments, result, error text, tool call id
This keeps one ordered transcript table while still distinguishing chat context from UI-only telemetry.
### UI-Only Persistence
Reasoning and tool-call items are stored as normal conversation messages, but with `Visibility = UI_ONLY`.
That gives Sliver:
- cross-client sync through the existing conversation storage path
- transcript restoration after reconnect or TUI restart
- ordered replay of an entire turn, including intermediate items
At the same time, the model context builder filters out any non-context item, so the provider only receives actual user/assistant chat context.
### Event Model
Sliver now publishes `AIConversationEvent` payloads instead of raw `AIConversation` snapshots.
Supported event types:
- `CONVERSATION_UPDATED`
- `CONVERSATION_DELETED`
- `TURN_STARTED`
- `TURN_COMPLETED`
- `TURN_FAILED`
- `MESSAGE_STARTED`
- `MESSAGE_UPDATED`
- `MESSAGE_COMPLETED`
Each event can carry:
- a conversation summary
- a single message/item
- the turn id
- optional error text
This mirrors the Codex pattern of stable turn and item lifecycle events.
## Server Turn Flow
For each new user message:
1. The user chat message is saved as a normal context-visible chat item.
2. The server resolves runtime provider/model settings.
3. A new turn id is generated and persisted with `TURN_STARTED`.
4. If the runtime supports the Responses API loop, Sliver runs the agentic loop.
5. Reasoning items are persisted as completed UI-only reasoning blocks.
6. Tool calls are persisted as in-progress UI-only tool blocks, executed locally, then updated in place to completed or failed.
7. Tool outputs are fed back into the Responses API with `previous_response_id`.
8. When the model returns final assistant text, that assistant message is persisted as a normal context-visible chat item.
9. The conversation turn state is cleared with `TURN_COMPLETED`.
If anything fails:
- a UI-only failed system message is persisted
- the conversation is marked `TURN_FAILED`
- the failure remains visible in the transcript without polluting future model context
## Tool Surface
The first port exposes a safe read-only tool set:
- `list_sessions_and_beacons`
- `fs_ls`
- `fs_cat`
- `fs_pwd`
Why read-only first:
- it proves the loop, storage, eventing, and TUI behavior
- it avoids surprising destructive remote actions in the first server-side tool release
Filesystem tools accept explicit `session_id` or `beacon_id`, but when omitted they fall back to the conversation target.
Beacon-backed tools wait for task completion on the server before emitting the completed item update.
## Context Filtering
Only messages with:
- `Kind = CHAT`
- `Visibility = CONTEXT`
are included in future provider requests.
That means these items are excluded from the model context window:
- reasoning blocks
- tool call blocks
- failed system status blocks
This is the main rule that preserves transcript richness without degrading prompt quality.
## TUI Behavior
The AI TUI now consumes `AIConversationEvent` incrementally.
Instead of reloading the whole conversation for every server event, it:
- merges conversation summaries into the sidebar
- upserts messages in place by message id or item id
- updates waiting state from `TurnState`
- removes deleted conversations immediately
Reasoning and tool-call blocks render as dedicated dark-grey transcript boxes.
### Reasoning Blocks
Reasoning blocks show:
- the `Reasoning` label
- the persisted reasoning text
- item state metadata
### Tool Call Blocks
Tool blocks show:
- `Tool: <name>`
- arguments
- result
- error text when present
- item state metadata
These blocks are restored exactly like chat messages because they are stored in the same ordered transcript.
## Restore And Sync
Because UI-only items live in the database:
- another Sliver client can attach to the same conversation and see the full turn history
- reconnecting the TUI restores reasoning and tool-call blocks
- turn state survives restart, so "in progress" and "failed" status can be reconstructed from persisted conversation metadata
## Differences From Codex
This port intentionally differs from the example in a few places.
- Sliver currently uses non-streaming Responses API requests, so reasoning items arrive as completed items rather than token deltas.
- Tool execution is currently a bounded read-only server-side function set, not the full Codex local tool surface.
- Sliver persists intermediate items in its conversation DB rather than only in a transient thread runtime.
The important invariant is still the same:
- turn lifecycle is explicit
- item lifecycle is explicit
- UI-only items are persisted and replayable
- only context-visible chat reaches the model
@@ -5,8 +5,8 @@ This document describes the technical design of Sliver.
There are four major components to the Sliver ecosystem:
- **Server Console -** The server console is the main interface, which is started when you run the `sliver-server` executable. The server console is a superset of the client console. All code is shared between the client/server consoles except server-specific commands related to client (i.e., operator) management. The server console talks over an in-memory gRPC interface to the server.
- **Sliver Server -** The Sliver server is also part of the `sliver-server` executable and manages the internal database, starts/stops network listeners (such as C2 listeners, though there are other types). The main interface used to interact with the server is the gRPC interface, thru which all functionality is implemented. By default the server will only start an in-memory gRPC listener that can only be communicated with from the server console. However, the gRPC interface can also be exposed to the network (i.e., multiplayer mode) over mutual TLS (mTLS).
- **Sliver Client -** The client console is the primary user interface that is used to interact with the Sliver server. Note that most of the code in the server console is actually the client console. The client console can also be compiled into a separate client binary file `sliver-client`, which is generally used to connect to the "mutliplayer" gRPC network listener.
- **Sliver Server -** The Sliver server is also part of the `sliver-server` executable and manages the internal database, starts/stops network listeners (such as C2 listeners, though there are other types). The main interface used to interact with the server is the gRPC interface, thru which all functionality is implemented. By default the server will only start an in-memory gRPC listener that can only be communicated with from the server console. However, the gRPC interface can also be exposed to the network in multiplayer mode, either directly over mutual TLS (mTLS) or, by default, through a WireGuard-protected wrapper that carries the same gRPC/mTLS service.
- **Sliver Client -** The client console is the primary user interface that is used to interact with the Sliver server. Note that most of the code in the server console is actually the client console. The client console can also be compiled into a separate client binary file `sliver-client`, which is generally used to connect to the multiplayer gRPC network listener.
- **Implant -** The implant is the actual malicious code run on the target system you want remote access to.
```
@@ -17,7 +17,8 @@ There are four major components to the Sliver ecosystem:
└─────────┘ └───────────────┘
▲
│
│gRPC/mTLS
│WireGuard (default)
│or direct gRPC/mTLS
│
┌────┴────┐
│ Sliver │
@@ -25,4 +26,4 @@ There are four major components to the Sliver ecosystem:
└─────────┘
```
By implementing all functionality over this gRPC interface, and only differing the in-memory/mTLS connection types the client code doesn't "know" if it's running in the server console or the client console. Due to this, a single command implementation will work in both the server console and over the network in multiplayer mode.
By implementing all functionality over this gRPC interface, and only differing the in-memory or remote transport types, the client code doesn't "know" if it's running in the server console or the client console. Due to this, a single command implementation will work in both the server console and over the network in multiplayer mode.
@@ -12,6 +12,7 @@ daemon:
host: ""
port: 31337
tailscale: false
disable_wg: false
logs:
level: 4
grpc_unary_payloads: false
@@ -51,8 +52,9 @@ cxx: {}
- `daemon_mode` - Enable [daemon mode](/docs?name=Daemon+Mode).
- `daemon` - Options for daemon mode.
- `host` - Interface to bind the daemon listener to. Empty string means all interfaces.
- `port` - TCP port for the daemon listener.
- `tailscale` - Enable Tailscale for daemon listener setup.
- `port` - Listen port for the multiplayer listener.
- `tailscale` - Enable Tailscale for daemon listener setup. When enabled, Sliver does not wrap multiplayer in its own WireGuard layer.
- `disable_wg` - Expose multiplayer directly over mTLS instead of the default WireGuard-protected mode.
- `logs` - Server logging options.
- `level` - `logrus` level (`0`-`6`, clamped by Sliver). `4` is `INFO`, `5` is `DEBUG`, `6` is `TRACE`.
- `grpc_unary_payloads` - Log gRPC unary payloads.
@@ -140,8 +142,21 @@ For details on generating them, see [multiplayer mode](/docs?name=Multi-player+M
"lport": 31337,
"ca_certificate": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n",
"private_key": "-----BEGIN EC PRIVATE KEY-----\n...\n-----END EC PRIVATE KEY-----\n",
"certificate": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n"
"certificate": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n",
"wg": {
"server_pub_key": "<multiplayer-wg-server-pubkey>",
"client_private_key": "<operator-wg-private-key>",
"client_pub_key": "<operator-wg-public-key>",
"client_ip": "100.65.0.10",
"server_ip": "100.65.0.1"
}
}
```
**Note:** The `operator` field is mostly a local label in the client. Server-side identity is derived from the client certificate (`CommonName`) and token material generated by the server. If you need to rename an operator, generate a new client configuration.
The `wg` block is optional:
- When present, `sliver-client` automatically brings up the multiplayer WireGuard wrapper and then dials the in-tunnel gRPC/mTLS service.
- When absent, the client connects directly to the multiplayer mTLS listener.
- Generate a direct-only profile with `new-operator --disable-wg` or `sliver-server operator --disable-wg` when you need compatibility with external gRPC clients that do not implement the multiplayer WireGuard wrapper.
@@ -1,9 +1,20 @@
Although the `sliver-client` is the default way to interact with a `sliver-server` and with implant sessions, there might be a time where you would want to automate some tasks upon reception of certain events.
To do so, one case use [sliver-script](https://github.com/moloch--/sliver-script), [sliver-py](https://github.com/moloch--/sliver-py) or write a custom client in another language. As all the communications between the client and the server are based on gRPC, any language with gRPC support should in theory be used to create a custom client.
To do so, you can use [sliver-script](https://github.com/moloch--/sliver-script), [sliver-py](https://github.com/moloch--/sliver-py), or write a custom client in another language.
Current Sliver releases generate multiplayer operator configs with a `wg` block by default. The official `sliver-client`, and Go clients that reuse Sliver's `client/assets` plus `client/transport` packages, will automatically bring up that WireGuard wrapper before dialing the in-tunnel gRPC/mTLS service.
If you are writing a custom client in another language, you have two options:
- Implement the multiplayer WireGuard wrapper described in [multiplayer mode](/docs?name=Multi-player+Mode), then connect to the in-tunnel gRPC/mTLS service.
- Keep multiplayer in direct mode by starting the listener with `multiplayer --disable-wg` or `sliver-server daemon --disable-wg`, then generate operator profiles with `new-operator --disable-wg` or `sliver-server operator --disable-wg`.
Once connected, the client/server API is still gRPC, so any language with gRPC support can in theory be used to create a custom client.
## Writing a Go client
This example uses Sliver's Go client libraries, so it works with either a direct multiplayer profile or a WireGuard-enabled profile.
In this example, we will focus on writing a custom Go client that executes a new system command on every new implant that connects to the sliver server.
Create a new Go project somewhere on your file system:
@@ -1,4 +1,6 @@
Sliver supports running in daemon mode, which automatically starts the multiplayer client listener (but not an interactive console). To connect to a daemonized server, use [multiplayer mode](/docs?name=Multi-player+Mode) operator configs.
Sliver supports running in daemon mode, which automatically starts the multiplayer listener but does not provide an interactive console. To connect to a daemonized server, use [multiplayer mode](/docs?name=Multi-player+Mode) operator configs.
By default, daemon mode starts the same WireGuard-protected multiplayer listener used by the interactive `multiplayer` command. That means the outer listener is UDP on `31337` by default, while the authenticated gRPC/mTLS service stays inside the WireGuard tunnel.
There are two ways to run in daemon mode:
@@ -13,9 +15,11 @@ If `server.yaml` does not exist, Sliver generates it. If a legacy `server.json`
- `-l, --lhost` multiplayer listener host
- `-p, --lport` multiplayer listener port
- `-t, --tailscale` enable Tailscale listener
- `--disable-wg` expose multiplayer directly over mTLS instead of using the default WireGuard wrapper
- `-f, --force` force unpack static assets
- For `sliver-server daemon`, `--lhost` and `--lport` override config values. If omitted, Sliver uses `daemon.host` and `daemon.port` from `server.yaml`.
- For normal startup (`sliver-server`) with `daemon_mode: true`, Sliver uses `daemon.tailscale` from `server.yaml`.
- For normal startup (`sliver-server`) with `daemon_mode: true`, Sliver uses `daemon.tailscale` and `daemon.disable_wg` from `server.yaml`.
- With `--disable-wg` or `daemon.disable_wg: true`, multiplayer falls back to direct TCP mTLS on the configured port.
### Example Config
@@ -25,6 +29,7 @@ daemon:
host: ""
port: 31337
tailscale: false
disable_wg: false
logs:
level: 4
grpc_unary_payloads: false
@@ -44,6 +49,8 @@ Since daemon mode does not provide an interactive server console, generate opera
./sliver-server operator --name zer0cool --lhost 1.2.3.4 --permissions all --save zer0cool.cfg
```
The `operator` CLI matches the daemon's multiplayer exposure by default. If the daemon is running in direct mode or over Tailscale, the generated config omits the multiplayer `wg` block automatically. You can also force a direct-only profile with `--disable-wg`.
### Shutdown Behavior
On Unix-like systems, daemon mode handles `SIGTERM` and exits cleanly.
@@ -1,10 +1,10 @@
This script will install the latest version of Sliver as a systemd service, installs Windows cross-compiler dependencies (mingw), and setup multiplayer for the all local users. After running the script connect locally by running `sliver`.
This script installs the latest version of Sliver as a systemd service, installs Windows cross-compiler dependencies (mingw), and sets up multiplayer for all local users. After running the script, connect locally by running `sliver`.
https://sliver.sh/install
This script should work on Kali, Ubuntu, and RHEL (CentOS, etc) distributions of Linux.
**⚠️ OPSEC:** By default the Linux install script will bind the multiplayer listener to `:31337` i.e. all interfaces. Ensure your firewalls are properly configured if this is a concern, or reconfigure the server to bind to localhost if you only wish to allow local users. Multiplayer Mode is authenticated using mTLS/gRPC and per-operator bearer tokens, so this doesn't present a security problem (notwithstanding bugs in our implementation), but publicly exposing your multiplayer listener will make it trivial to identify what the server is running.
**⚠️ OPSEC:** By default the Linux install script will bind the multiplayer listener to `:31337` on all interfaces. In current releases that is the WireGuard-protected multiplayer listener, so the outer service is UDP/31337 and the authenticated gRPC/mTLS server only exists inside the tunnel. Ensure your firewalls are properly configured if this is a concern, or reconfigure the server to bind to localhost if you only wish to allow local users. Publicly exposing the multiplayer listener still makes the server easier to discover and fingerprint.
### One Liner
+5 -1
View File
@@ -47,6 +47,10 @@ sliver > mcp start --transport http
[*] Starting MCP server (http) on 127.0.0.1:8080
[*] Endpoint: http://127.0.0.1:8080/mcp
[*] Auth Header: Authorization
[*] Auth Token: 6f90c3b3c6058fa59f570e281f3f8d39
```
You can use `mcp stop` to stop the server, see `mcp start --help` for additional options when starting the MCP server. Authentication is not yet supported for MCP over HTTP/SSE.
You can use `mcp stop` to stop the server, see `mcp start --help` for additional options when starting the MCP server.
On first HTTP/SSE use, Sliver generates a random 128-bit token and saves it to `~/.sliver-client/mcp.yaml`. Every MCP HTTP/SSE request must include that token exactly in the `Authorization` header. If `mcp.yaml` already exists, the listener will only start when the token is at least 8 characters long.
@@ -1,4 +1,6 @@
Multiplayer-mode allows multiple operators (players) to connect to the same Sliver server and collaborate on engagements. The easiest way to set up a server for multiplayer is to use the [Linux install script](/docs?name=Linux+Install+Script) which will configure the server as a systemd service. However, any Sliver server binary supports multiplayer mode.
Multiplayer mode allows multiple operators to connect to the same Sliver server and collaborate on engagements. The easiest way to set up a server for multiplayer is to use the [Linux install script](/docs?name=Linux+Install+Script), which configures the server as a systemd service. However, any `sliver-server` binary supports multiplayer mode.
By default, Sliver now protects the operator-facing multiplayer listener with a dedicated WireGuard wrapper. The gRPC/mTLS authentication stack is still used inside the tunnel, but the server no longer exposes the multiplayer gRPC listener directly unless you explicitly opt out with `--disable-wg`.
```
┌──────────────────┐ C2
@@ -8,7 +10,8 @@ Multiplayer-mode allows multiple operators (players) to connect to the same Sliv
└──────────────────┘
▲
│
gRPC/mTLS │
UDP/WireGuard (default) │
gRPC/mTLS inside │
┌────────────┬────────┴─────┬───────────┐
│ │ │ │
┌─────┴──┐ │ │ ┌──┴─────┐
@@ -18,32 +21,39 @@ Multiplayer-mode allows multiple operators (players) to connect to the same Sliv
└────────┘ └────────┘
```
## New Operators
## Default WireGuard-Protected Mode
Operators will need to download their preferred platform's client software from the releases page. Clients can connect to servers of differing platforms (e.g. a Windows client can connect to a Linux server). Operators and servers authenticate using Mutual TLS, however all the certificates are managed automatically for you.
- If you did not install the server as a systemd service, be sure to leave the server console running in screen or [tmux](https://github.com/tmux/tmux) after starting the multiplayer listener.
- If you did install the server as systemd service see the CLI section below for an alternative to the console commands for generating operator configs.
- If you did install the server as a systemd service, see the CLI section below for an alternative to the console commands for generating operator configs.
Operators need a configuration file generated by the Sliver server to connect in multiplayer mode. The Sliver client can support multiple configuration files, but you can only be connected to one server at a time (per process).
From the server use the `new-operator` command to generate a new operator configuration file. You'll need to specify the operator's `--name` and the `--lhost` of the server (e.g., its public IP). Then from the server start a multiplayer listener using the `multiplayer` command to allow operators to connect:
In the default mode:
**Note:** The `new-operator` and `multiplayer` commands are only accessible from the server's console
- `multiplayer` exposes a WireGuard listener on `--lport` (default `31337`) over UDP.
- The authenticated gRPC/mTLS server only listens inside that WireGuard tunnel.
- `new-operator` generates a per-operator WireGuard keypair and tunnel IP, then embeds that information in the operator config's `wg` block.
- The multiplayer listener uses WireGuard keys that are separate from Sliver's implant `wg` C2 listener.
- `sliver-client` automatically uses the WireGuard wrapper when the imported config contains that `wg` block.
From the server, start the multiplayer listener and generate an operator config with the server's public address or hostname in `--lhost`. Use `--permissions all` for a standard interactive operator profile:
**Note:** The `new-operator` and `multiplayer` commands are only available from the server console.
```
[server] sliver > new-operator --name moloch --lhost 1.2.3.4
[*] Generating new client certificate, please wait ...
[*] Saved new client config to: /Users/moloch/Desktop/moloch_example.com.cfg
[server] sliver > multiplayer
[*] Multiplayer mode enabled!
[server] sliver > new-operator --name moloch --lhost 1.2.3.4 --permissions all
[*] Generating new client certificate, please wait ...
[*] Saved new client config to: /Users/moloch/Desktop/moloch_example.com.cfg
```
**IMPORTANT:** Before clients can connect to a server you must start an RPC listener with the `multiplayer` command. The default port is TCP/31337.
**IMPORTANT:** Before clients can connect to a server you must start a multiplayer listener with the `multiplayer` command. In the default WireGuard-protected mode the outer listener is UDP/31337. If you disable the wrapper, multiplayer is exposed directly on TCP/31337 instead.
You can now give this configuration file `moloch_example.com.cfg` to the operator and they can connect to the server using the `sliver-client` binary. The Sliver client will look for configuration files in `~/.sliver-client/configs/` or you can import configs using the `import` CLI. The configs directory can contain multiple configs for different servers.
@@ -56,19 +66,49 @@ $ ./sliver-client
localhost
```
## Direct mTLS Mode
If you want the old behavior, or you need compatibility with third-party gRPC clients that do not implement the multiplayer WireGuard wrapper, disable it on both the listener and the generated operator profile:
```
[server] sliver > multiplayer --disable-wg
[*] Multiplayer mode enabled!
[server] sliver > new-operator --name tester --lhost 1.2.3.4 --permissions all --disable-wg
[*] Generating new client certificate, please wait ...
[*] Saved new client config to: /Users/tester/Desktop/tester_example.com.cfg
```
In direct mode:
- Multiplayer is exposed directly over TCP on `--lport` (default `31337`).
- Generated operator configs omit the `wg` block.
- `sliver-client --disable-wg` forces a direct connection even if the config includes a `wg` block.
- `sliver-client --require-wg` fails fast if the config does not contain a valid `wg` block.
The listener mode and the operator config need to match. A WireGuard-enabled config cannot talk to a direct listener, and a direct-only client cannot talk to the default WireGuard-wrapped listener.
### Server CLI / Daemon Mode Multiplayer
If the server is running in daemon mode (as is the default with the Linux install script), that means the multiplayer listener is started for you without an interactive console. You can use the server binaries' CLI to generate operator configuration files to connect:
If the server is running in daemon mode, the multiplayer listener is started for you without an interactive console. By default the daemon uses the same WireGuard-protected multiplayer mode described above. You can switch the daemon back to direct exposure with `sliver-server daemon --disable-wg` or `daemon.disable_wg: true` in `server.yaml`.
Use the server CLI to generate operator configuration files:
```
./sliver-server operator --name zer0cool --lhost 1.2.3.4 --save zer0cool.cfg
./sliver-server operator --name zer0cool --lhost 1.2.3.4 --permissions all --save zer0cool.cfg
```
The installation scripts by default will place the `sliver-server` binary in `/root`.
When daemon mode is configured for direct multiplayer or Tailscale, the CLI automatically omits the `wg` block by default. You can also force a direct-only profile with `--disable-wg`.
The installation script places the `sliver-server` binary in `/root` by default.
### Tailscale Integration
Sliver v1.6 supports multiplayer integration with [Tailscale](https://tailscale.com/), which is an excellent option when wanting to maintain operational security.
Sliver supports multiplayer integration with [Tailscale](https://tailscale.com/), which is an excellent option when you want to keep the multiplayer listener off the public Internet.
Tailscale mode is separate from the default multiplayer WireGuard wrapper. When you use `multiplayer -T`, Sliver exposes the listener only through Tailscale instead of layering its own multiplayer WireGuard tunnel on top.
```
$ export TS_AUTHKEY=<tailscale authkey>
@@ -77,4 +117,4 @@ $ ./sliver-server
sliver > multiplayer -T
```
You should now see a new Tailscale host named `sliver-server-<machine>`, you can set this using the `--lhost` flag. Use this as the hostname when connecting to the Sliver server from any other host on the Tailscale network.
You should now see a new Tailscale host named `sliver-server-<machine>`. Use that hostname as `--lhost` when generating operator configs for other hosts on the same tailnet. If you generate the operator config before enabling Tailscale multiplayer, add `--disable-wg` explicitly so the profile does not include the multiplayer `wg` block.
@@ -59,14 +59,16 @@ First, install the sliver-py extension using pip.
pip3 install sliver-py
```
Since our extension is essentially going to be another client connection to the sliver server, you’ll also need to enable multiplayer mode and generate a new profile
Since our extension is essentially going to be another client connection to the sliver server, you’ll also need to enable multiplayer mode and generate a new profile.
Current Sliver releases default to a WireGuard-protected multiplayer listener. Because this tutorial uses a third-party Python client, we’ll force direct multiplayer mode so the profile only contains the plain gRPC/mTLS settings:
```bash
[server] sliver > multiplayer
[server] sliver > multiplayer --disable-wg
[*] Multiplayer mode enabled!
[server] sliver > new-operator -n tester -l 127.0.0.1
[server] sliver > new-operator -n tester -l 127.0.0.1 --permissions all --disable-wg
[*] Generating new client certificate, please wait ...
[*] Saved new client config to: /Users/tester/tools/tester_127.0.0.1.cfg
+1 -1
View File
@@ -1 +1 @@
{"tutorials":[{"name":"1 - Getting Started","content":"`sliver-server` is the binary you want to use to run the Sliver C2 server, `sliver-client` is solely a client to connect to a Sliver C2 server. Sliver server also acts as a client on its own, so you don’t necessarily run sliver server and client separately.\n\nFirst time running Sliver will take a couple seconds as it's retrieving its dependencies. Consecutive executions will be much faster. Go ahead and launch the `sliver-server`.\n\n```asciinema\n{\"src\": \"/asciinema/startup.cast\", \"cols\": \"132\", \"rows\": \"28\", \"idleTimeLimit\": 8}\n```\n\nLet's take a couple minutes to discuss what Sliver actually is and how it's set up.\n\n![Alt text](/images/Architecture.png)\n\nNow that Sliver is running, lets generate and execute your first implant to try out some of the basic features of Sliver, for now we’re going to run everything on the local host.\n\nHere's what we're going to do: \n* Generate your implant using the `generate` command as shown below.\n* Start HTTP listener on port 80\n* Execute implant in a separate terminal\n\n```asciinema\n{\"src\": \"/asciinema/first-implant.cast\", \"cols\": \"132\", \"rows\": \"14\", \"idleTimeLimit\": 8}\n```\n\nNow let’s select our implant and run our first command using the `use` command.\n\n```bash\n[server] sliver > use\n? Select a session or beacon: \nSESSION 1884a365 RELATED_EARDRUM [::1]:49153 test.local tester darwin/amd64\n[*] Active session RELATED_EARDRUM (1884a365-085f-4506-b28e-80c481730fd0)\n\n[server] sliver (RELATED_EARDRUM) > pwd\n\n[*] /Users/tester/tools\n```\n\nOnce you have reached this point, go ahead and explore some of the commands listed below. In each case, first check out the command's help using the **`-h`** flag then try it out!\n\n```bash\nExploring and interacting with the filesystem\n\nFilesystem\n cat Dump file to stdout\n cd Change directory\n cp Copy a file\n download Download a file\n grep Search for strings that match a regex within a file or directory\n head Grab the first number of bytes or lines from a file\n ls List current directory\n mkdir Make a directory\n mv Move or rename a file\n pwd Print working directory\n rm Remove a file or directory\n tail Grab the last number of bytes or lines from a file\n upload Upload a file\n```\n\n```asciinema\n{\"src\": \"/asciinema/filesystem.cast\", \"cols\": \"132\", \"rows\": \"14\", \"idleTimeLimit\": 8}\n```\n\nGetting some environmental information\n```bash\nInfo\n env List environment variables\n getgid Get session process GID\n getpid Get session pid\n getuid Get session process UID\n info Get session info\n ping Send round trip message to implant (does not use ICMP)\n whoami Get session user execution context\n```\nExecute a binary\n\n```asciinema\n{\"src\": \"/asciinema/execute.cast\", \"cols\": \"132\", \"rows\": \"14\", \"idleTimeLimit\": 8}\n```\n\nRunning an interactive shell\n\n```asciinema\n{\"src\": \"/asciinema/shell.cast\", \"cols\": \"132\", \"rows\": \"14\", \"idleTimeLimit\": 8}\n```\n\nNot all commands are supported for all operating systems or meant to be used on all operating systems, here are a few examples:\n\n```\nmount Get information on mounted filesystems (only for Windows and Linux)\nscreenshot Take a screenshot (only for Windows and Linux)\nmemfiles List current memfiles (Linux only)\n```\n\nThis is an open-source project if you see a bug please file an issue! Pull requests are always welcome."},{"name":"2 - Beacons vs Sessions","content":"Sliver implants support two types of connections, sessions and beacons.\n\nSessions use long-polling connections, which means they constantly maintain a single TCP connection open while communicating with the server. Beacons on the other hand call back periodically and will sleep when not active which can help keep their presence hidden. You could limit callbacks to once every 6 hours for example and only 'activate' the beacon when needed by switching its sleeping time to a shorter duration.\n\nTypically during an engagement you will want to deploy a beacon on the target system and switch to a session while doing more active enumeration.\n\nLet’s start with generating and deploying a beacon using `http`.\n\n```asciinema\n{\"src\": \"/asciinema/beacon_generation.cast\", \"cols\": \"132\", \"rows\": \"14\", \"idleTimeLimit\": 8}\n```\n\nYou can see the beacon callback times either in the `info` command or using `beacons watch`.\n\n```bash\n[server] sliver > beacons watch\n\n ID Name Transport Username Operating System Last Check-In Next Check-In\n========== =============== =========== ================= ==========Line truncated
{"tutorials":[{"name":"1 - Getting Started","content":"`sliver-server` is the binary you want to use to run the Sliver C2 server, `sliver-client` is solely a client to connect to a Sliver C2 server. Sliver server also acts as a client on its own, so you don’t necessarily run sliver server and client separately.\n\nFirst time running Sliver will take a couple seconds as it's retrieving its dependencies. Consecutive executions will be much faster. Go ahead and launch the `sliver-server`.\n\n```asciinema\n{\"src\": \"/asciinema/startup.cast\", \"cols\": \"132\", \"rows\": \"28\", \"idleTimeLimit\": 8}\n```\n\nLet's take a couple minutes to discuss what Sliver actually is and how it's set up.\n\n![Alt text](/images/Architecture.png)\n\nNow that Sliver is running, lets generate and execute your first implant to try out some of the basic features of Sliver, for now we’re going to run everything on the local host.\n\nHere's what we're going to do: \n* Generate your implant using the `generate` command as shown below.\n* Start HTTP listener on port 80\n* Execute implant in a separate terminal\n\n```asciinema\n{\"src\": \"/asciinema/first-implant.cast\", \"cols\": \"132\", \"rows\": \"14\", \"idleTimeLimit\": 8}\n```\n\nNow let’s select our implant and run our first command using the `use` command.\n\n```bash\n[server] sliver > use\n? Select a session or beacon: \nSESSION 1884a365 RELATED_EARDRUM [::1]:49153 test.local tester darwin/amd64\n[*] Active session RELATED_EARDRUM (1884a365-085f-4506-b28e-80c481730fd0)\n\n[server] sliver (RELATED_EARDRUM) > pwd\n\n[*] /Users/tester/tools\n```\n\nOnce you have reached this point, go ahead and explore some of the commands listed below. In each case, first check out the command's help using the **`-h`** flag then try it out!\n\n```bash\nExploring and interacting with the filesystem\n\nFilesystem\n cat Dump file to stdout\n cd Change directory\n cp Copy a file\n download Download a file\n grep Search for strings that match a regex within a file or directory\n head Grab the first number of bytes or lines from a file\n ls List current directory\n mkdir Make a directory\n mv Move or rename a file\n pwd Print working directory\n rm Remove a file or directory\n tail Grab the last number of bytes or lines from a file\n upload Upload a file\n```\n\n```asciinema\n{\"src\": \"/asciinema/filesystem.cast\", \"cols\": \"132\", \"rows\": \"14\", \"idleTimeLimit\": 8}\n```\n\nGetting some environmental information\n```bash\nInfo\n env List environment variables\n getgid Get session process GID\n getpid Get session pid\n getuid Get session process UID\n info Get session info\n ping Send round trip message to implant (does not use ICMP)\n whoami Get session user execution context\n```\nExecute a binary\n\n```asciinema\n{\"src\": \"/asciinema/execute.cast\", \"cols\": \"132\", \"rows\": \"14\", \"idleTimeLimit\": 8}\n```\n\nRunning an interactive shell\n\n```asciinema\n{\"src\": \"/asciinema/shell.cast\", \"cols\": \"132\", \"rows\": \"14\", \"idleTimeLimit\": 8}\n```\n\nNot all commands are supported for all operating systems or meant to be used on all operating systems, here are a few examples:\n\n```\nmount Get information on mounted filesystems (only for Windows and Linux)\nscreenshot Take a screenshot (only for Windows and Linux)\nmemfiles List current memfiles (Linux only)\n```\n\nThis is an open-source project if you see a bug please file an issue! Pull requests are always welcome."},{"name":"2 - Beacons vs Sessions","content":"Sliver implants support two types of connections, sessions and beacons.\n\nSessions use long-polling connections, which means they constantly maintain a single TCP connection open while communicating with the server. Beacons on the other hand call back periodically and will sleep when not active which can help keep their presence hidden. You could limit callbacks to once every 6 hours for example and only 'activate' the beacon when needed by switching its sleeping time to a shorter duration.\n\nTypically during an engagement you will want to deploy a beacon on the target system and switch to a session while doing more active enumeration.\n\nLet’s start with generating and deploying a beacon using `http`.\n\n```asciinema\n{\"src\": \"/asciinema/beacon_generation.cast\", \"cols\": \"132\", \"rows\": \"14\", \"idleTimeLimit\": 8}\n```\n\nYou can see the beacon callback times either in the `info` command or using `beacons watch`.\n\n```bash\n[server] sliver > beacons watch\n\n ID Name Transport Username Operating System Last Check-In Next Check-In\n========== =============== =========== ================= ==========Line truncated
File diff suppressed because it is too large. Load diff
+58
View File
@@ -1437,6 +1437,41 @@ message AIConversations {
repeated AIConversation Conversations = 1;
}
enum AIConversationTurnState {
AI_TURN_STATE_IDLE = 0;
AI_TURN_STATE_IN_PROGRESS = 1;
AI_TURN_STATE_FAILED = 2;
}
enum AIConversationMessageKind {
AI_MESSAGE_KIND_CHAT = 0;
AI_MESSAGE_KIND_REASONING = 1;
AI_MESSAGE_KIND_TOOL_CALL = 2;
}
enum AIConversationMessageVisibility {
AI_MESSAGE_VISIBILITY_CONTEXT = 0;
AI_MESSAGE_VISIBILITY_UI_ONLY = 1;
}
enum AIConversationMessageState {
AI_MESSAGE_STATE_COMPLETED = 0;
AI_MESSAGE_STATE_IN_PROGRESS = 1;
AI_MESSAGE_STATE_FAILED = 2;
}
enum AIConversationEventType {
AI_CONVERSATION_EVENT_TYPE_UNKNOWN = 0;
AI_CONVERSATION_EVENT_TYPE_CONVERSATION_UPDATED = 1;
AI_CONVERSATION_EVENT_TYPE_CONVERSATION_DELETED = 2;
AI_CONVERSATION_EVENT_TYPE_TURN_STARTED = 3;
AI_CONVERSATION_EVENT_TYPE_TURN_COMPLETED = 4;
AI_CONVERSATION_EVENT_TYPE_TURN_FAILED = 5;
AI_CONVERSATION_EVENT_TYPE_MESSAGE_STARTED = 6;
AI_CONVERSATION_EVENT_TYPE_MESSAGE_UPDATED = 7;
AI_CONVERSATION_EVENT_TYPE_MESSAGE_COMPLETED = 8;
}
message AIConversation {
string ID = 1;
int64 CreatedAt = 2;
@@ -1448,6 +1483,10 @@ message AIConversation {
string Summary = 8;
string SystemPrompt = 9;
repeated AIConversationMessage Messages = 10;
string ActiveTurnID = 11;
AIConversationTurnState TurnState = 12;
string TargetSessionID = 13;
string TargetBeaconID = 14;
}
message AIConversationMessages {
@@ -1468,6 +1507,25 @@ message AIConversationMessage {
string Content = 10;
string ProviderMessageID = 11;
string FinishReason = 12;
AIConversationMessageKind Kind = 13;
AIConversationMessageVisibility Visibility = 14;
AIConversationMessageState State = 15;
string TurnID = 16;
string ItemID = 17;
string ToolCallID = 18;
string ToolName = 19;
string ToolArguments = 20;
string ToolResult = 21;
string ErrorText = 22;
}
message AIConversationEvent {
AIConversationEventType EventType = 1;
string ConversationID = 2;
string TurnID = 3;
string ErrorText = 4;
AIConversation Conversation = 5;
AIConversationMessage Message = 6;
}
// watchtower
+554
View File
@@ -0,0 +1,554 @@
package ai
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"github.com/bishopfox/sliver/protobuf/clientpb"
openai "github.com/openai/openai-go/v2"
openairesponses "github.com/openai/openai-go/v2/responses"
"github.com/openai/openai-go/v2/shared"
)
const maxAgenticLoopIterations = 16
// AgenticToolDefinition describes a custom function tool exposed to the model.
type AgenticToolDefinition struct {
Name string
Description string
Parameters map[string]any
}
// AgenticToolExecutor executes model-requested tools for a conversation turn.
type AgenticToolExecutor interface {
ToolDefinitions() []AgenticToolDefinition
CallTool(context.Context, string, string) (string, error)
}
// AgenticEventSink persists and publishes non-context-window UI items.
type AgenticEventSink interface {
ReasoningItem(context.Context, AgenticReasoningItem) error
ToolCallStarted(context.Context, AgenticToolCall) error
ToolCallCompleted(context.Context, AgenticToolCallResult) error
}
// AgenticReasoningItem is a completed reasoning item emitted by the provider.
type AgenticReasoningItem struct {
ItemID string
Summary string
Content string
EncryptedContent string
Status string
}
// AgenticToolCall is a provider-emitted function tool call.
type AgenticToolCall struct {
ItemID string
CallID string
Name string
Arguments string
Status string
}
// AgenticToolCallResult is the resolved tool call state after local execution.
type AgenticToolCallResult struct {
AgenticToolCall
Output string
Error error
}
// SupportsAgenticConversation reports whether the configured runtime can run the
// Responses API tool loop used by the Sliver AI chat.
func SupportsAgenticConversation(runtime *RuntimeConfig) bool {
if runtime == nil || !runtime.UseResponsesAPI {
return false
}
switch NormalizeProviderName(runtime.Provider) {
case ProviderOpenAI, ProviderOpenAICompat, ProviderOpenRouter:
return true
default:
return false
}
}
// CompleteConversationAgentic runs a Codex-style Responses API loop that emits
// reasoning and tool-call items between the user prompt and the final assistant
// reply. The returned completion is the assistant message that should be stored
// in the conversation context window.
func CompleteConversationAgentic(
ctx context.Context,
runtime *RuntimeConfig,
conversation *clientpb.AIConversation,
tools AgenticToolExecutor,
sink AgenticEventSink,
) (*Completion, error) {
if runtime == nil {
return nil, fmt.Errorf("AI runtime config is required")
}
if conversation == nil {
return nil, fmt.Errorf("AI conversation is required")
}
if !SupportsAgenticConversation(runtime) {
return nil, fmt.Errorf("AI provider %q does not support the agentic Responses API loop", runtime.Provider)
}
systemPrompt, messages, err := conversationHistory(conversation)
if err != nil {
return nil, err
}
client := newOpenAIClient(openAIRequestOptions(runtime)...)
currentInput := buildResponseInput(systemPrompt, messages)
toolParams := buildAgenticToolParams(tools)
previousResponseID := ""
for step := 0; step < maxAgenticLoopIterations; step++ {
params := openairesponses.ResponseNewParams{
Input: openairesponses.ResponseNewParamsInputUnion{
OfInputItemList: currentInput,
},
Model: shared.ResponsesModel(runtime.Model),
}
if previousResponseID != "" {
params.PreviousResponseID = openai.String(previousResponseID)
}
if len(toolParams) > 0 {
params.Tools = toolParams
params.ParallelToolCalls = openai.Bool(false)
}
if runtime.MaxOutputTokens > 0 {
params.MaxOutputTokens = openai.Int(runtime.MaxOutputTokens)
}
if runtime.Temperature != nil {
params.Temperature = openai.Float(*runtime.Temperature)
}
if runtime.TopP != nil {
params.TopP = openai.Float(*runtime.TopP)
}
if effort, ok := reasoningEffortForThinkingLevel(runtime.ThinkingLevel); ok {
params.Reasoning = shared.ReasoningParam{Effort: effort}
}
response, err := client.responses.New(ctx, params)
if err != nil {
return nil, formatOpenAIError(runtime.Provider, err)
}
previousResponseID = strings.TrimSpace(response.ID)
nextInput := make(openairesponses.ResponseInputParam, 0)
finalTexts := make([]string, 0, len(response.Output))
for _, item := range response.Output {
switch output := item.AsAny().(type) {
case openairesponses.ResponseReasoningItem:
if sink != nil {
if err := sink.ReasoningItem(ctx, AgenticReasoningItem{
ItemID: strings.TrimSpace(output.ID),
Summary: responseReasoningSummary(output),
Content: responseReasoningContent(output),
EncryptedContent: strings.TrimSpace(output.EncryptedContent),
Status: strings.TrimSpace(string(output.Status)),
}); err != nil {
return nil, err
}
}
case openairesponses.ResponseFunctionToolCall:
toolCall := AgenticToolCall{
ItemID: strings.TrimSpace(output.ID),
CallID: strings.TrimSpace(output.CallID),
Name: strings.TrimSpace(output.Name),
Arguments: strings.TrimSpace(output.Arguments),
Status: strings.TrimSpace(string(output.Status)),
}
if sink != nil {
if err := sink.ToolCallStarted(ctx, toolCall); err != nil {
return nil, err
}
}
toolOutput, toolErr := agenticToolOutput(ctx, tools, toolCall)
if sink != nil {
if err := sink.ToolCallCompleted(ctx, AgenticToolCallResult{
AgenticToolCall: toolCall,
Output: toolOutput,
Error: toolErr,
}); err != nil {
return nil, err
}
}
nextInput = append(nextInput, openairesponses.ResponseInputItemParamOfFunctionCallOutput(toolCall.CallID, toolOutput))
case openairesponses.ResponseOutputMessage:
if text := strings.TrimSpace(responseOutputMessageText(output)); text != "" {
finalTexts = append(finalTexts, text)
}
}
}
if len(nextInput) > 0 {
currentInput = nextInput
continue
}
finalText := joinTextBlocks(finalTexts)
if finalText == "" {
finalText = strings.TrimSpace(response.OutputText())
}
if finalText == "" {
return nil, fmt.Errorf("%s response did not include assistant text", runtime.Provider)
}
return &Completion{
Provider: runtime.Provider,
Model: fallbackString(response.Model, runtime.Model),
Content: finalText,
ProviderMessageID: strings.TrimSpace(response.ID),
FinishReason: responseFinishReason(response),
}, nil
}
return nil, fmt.Errorf("AI agent loop exceeded %d provider steps", maxAgenticLoopIterations)
}
func buildAgenticToolParams(tools AgenticToolExecutor) []openairesponses.ToolUnionParam {
if tools == nil {
return nil
}
definitions := tools.ToolDefinitions()
if len(definitions) == 0 {
return nil
}
params := make([]openairesponses.ToolUnionParam, 0, len(definitions))
for _, definition := range definitions {
name := strings.TrimSpace(definition.Name)
if name == "" {
continue
}
tool := openairesponses.ToolParamOfFunction(name, normalizeOpenAIStrictSchema(definition.Parameters), true)
if description := strings.TrimSpace(definition.Description); description != "" && tool.OfFunction != nil {
tool.OfFunction.Description = openai.String(description)
}
params = append(params, tool)
}
if len(params) == 0 {
return nil
}
return params
}
func normalizeOpenAIStrictSchema(schema map[string]any) map[string]any {
if len(schema) == 0 {
return map[string]any{
"type": "object",
"properties": map[string]any{},
"required": []string{},
"additionalProperties": false,
}
}
cloned, ok := cloneJSONValue(schema).(map[string]any)
if !ok || cloned == nil {
return schema
}
normalizeOpenAIStrictSchemaNode(cloned)
return cloned
}
func normalizeOpenAIStrictSchemaNode(node map[string]any) {
if node == nil {
return
}
if properties, ok := schemaProperties(node); ok {
required := schemaRequiredSet(node["required"])
names := make([]string, 0, len(properties))
for name, rawProperty := range properties {
names = append(names, name)
property, ok := rawProperty.(map[string]any)
if !ok || property == nil {
continue
}
if !required[name] {
makeJSONSchemaNullable(property)
}
normalizeOpenAIStrictSchemaNode(property)
}
sort.Strings(names)
node["required"] = names
if _, exists := node["additionalProperties"]; !exists {
node["additionalProperties"] = false
}
}
if items, ok := node["items"].(map[string]any); ok {
normalizeOpenAIStrictSchemaNode(items)
}
for _, key := range []string{"anyOf", "allOf", "oneOf"} {
variants, ok := node[key].([]any)
if !ok {
continue
}
for _, rawVariant := range variants {
variant, ok := rawVariant.(map[string]any)
if !ok || variant == nil {
continue
}
normalizeOpenAIStrictSchemaNode(variant)
}
}
}
func schemaProperties(node map[string]any) (map[string]any, bool) {
if node == nil {
return nil, false
}
rawProperties, ok := node["properties"]
if !ok {
return nil, false
}
properties, ok := rawProperties.(map[string]any)
if !ok {
return nil, false
}
return properties, true
}
func schemaRequiredSet(raw any) map[string]bool {
required := map[string]bool{}
switch values := raw.(type) {
case []string:
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
required[value] = true
}
}
case []any:
for _, rawValue := range values {
value, ok := rawValue.(string)
if !ok {
continue
}
value = strings.TrimSpace(value)
if value != "" {
required[value] = true
}
}
}
return required
}
func makeJSONSchemaNullable(node map[string]any) {
if node == nil {
return
}
switch value := node["type"].(type) {
case string:
value = strings.TrimSpace(value)
if value != "" && value != "null" {
node["type"] = []string{value, "null"}
}
case []string:
if !stringSliceContains(value, "null") {
node["type"] = append(append([]string(nil), value...), "null")
}
case []any:
if !anyStringSliceContains(value, "null") {
node["type"] = append(append([]any(nil), value...), "null")
}
default:
if _, ok := node["anyOf"]; ok {
appendSchemaVariant(node, "anyOf", map[string]any{"type": "null"})
} else if _, ok := node["properties"]; ok {
node["type"] = []string{"object", "null"}
} else if _, ok := node["items"]; ok {
node["type"] = []string{"array", "null"}
}
}
if enum, ok := nullableEnum(node["enum"]); ok {
node["enum"] = enum
}
}
func appendSchemaVariant(node map[string]any, key string, variant map[string]any) {
if node == nil || variant == nil {
return
}
rawVariants, ok := node[key].([]any)
if !ok {
node[key] = []any{variant}
return
}
for _, rawVariant := range rawVariants {
existing, ok := rawVariant.(map[string]any)
if !ok || existing == nil {
continue
}
if len(existing) == len(variant) {
matched := true
for variantKey, variantValue := range variant {
if existing[variantKey] != variantValue {
matched = false
break
}
}
if matched {
return
}
}
}
node[key] = append(rawVariants, variant)
}
func nullableEnum(raw any) (any, bool) {
switch values := raw.(type) {
case []any:
for _, value := range values {
if value == nil {
return values, true
}
}
return append(append([]any(nil), values...), nil), true
case []string:
enum := make([]any, 0, len(values)+1)
for _, value := range values {
enum = append(enum, value)
}
enum = append(enum, nil)
return enum, true
default:
return nil, false
}
}
func stringSliceContains(values []string, needle string) bool {
for _, value := range values {
if strings.TrimSpace(value) == needle {
return true
}
}
return false
}
func anyStringSliceContains(values []any, needle string) bool {
for _, rawValue := range values {
value, ok := rawValue.(string)
if ok && strings.TrimSpace(value) == needle {
return true
}
}
return false
}
func cloneJSONValue(value any) any {
switch typed := value.(type) {
case map[string]any:
cloned := make(map[string]any, len(typed))
for key, nested := range typed {
cloned[key] = cloneJSONValue(nested)
}
return cloned
case []any:
cloned := make([]any, len(typed))
for idx, nested := range typed {
cloned[idx] = cloneJSONValue(nested)
}
return cloned
case []string:
cloned := make([]string, len(typed))
copy(cloned, typed)
return cloned
default:
return typed
}
}
func responseOutputMessageText(message openairesponses.ResponseOutputMessage) string {
parts := make([]string, 0, len(message.Content))
for _, content := range message.Content {
switch part := content.AsAny().(type) {
case openairesponses.ResponseOutputText:
if text := strings.TrimSpace(part.Text); text != "" {
parts = append(parts, text)
}
case openairesponses.ResponseOutputRefusal:
if refusal := strings.TrimSpace(part.Refusal); refusal != "" {
parts = append(parts, refusal)
}
}
}
return joinTextBlocks(parts)
}
func responseReasoningSummary(item openairesponses.ResponseReasoningItem) string {
summaries := make([]string, 0, len(item.Summary))
for _, summary := range item.Summary {
if text := strings.TrimSpace(summary.Text); text != "" {
summaries = append(summaries, text)
}
}
return joinTextBlocks(summaries)
}
func responseReasoningContent(item openairesponses.ResponseReasoningItem) string {
content := make([]string, 0, len(item.Content))
for _, block := range item.Content {
if text := strings.TrimSpace(block.Text); text != "" {
content = append(content, text)
}
}
return joinTextBlocks(content)
}
func agenticToolOutput(ctx context.Context, tools AgenticToolExecutor, toolCall AgenticToolCall) (string, error) {
if tools == nil {
err := fmt.Errorf("tool executor is not configured")
return agenticToolErrorJSON(err), err
}
output, err := tools.CallTool(ctx, toolCall.Name, toolCall.Arguments)
if err != nil {
return agenticToolErrorJSON(err), err
}
output = strings.TrimSpace(output)
if output == "" {
empty, marshalErr := json.Marshal(map[string]any{"ok": true})
if marshalErr != nil {
return `{"ok":true}`, nil
}
return string(empty), nil
}
return output, nil
}
func agenticToolErrorJSON(err error) string {
payload := map[string]any{
"ok": false,
"error": "tool call failed",
}
if err != nil && strings.TrimSpace(err.Error()) != "" {
payload["error"] = truncateForError(err.Error())
}
data, marshalErr := json.Marshal(payload)
if marshalErr != nil {
return `{"ok":false,"error":"tool call failed"}`
}
return string(data)
}
+66
View File
@@ -0,0 +1,66 @@
package ai
import (
"context"
"testing"
)
type staticAgenticToolExecutor struct {
definitions []AgenticToolDefinition
}
func (s staticAgenticToolExecutor) ToolDefinitions() []AgenticToolDefinition {
return s.definitions
}
func (staticAgenticToolExecutor) CallTool(_ context.Context, _ string, _ string) (string, error) {
return "", nil
}
func TestBuildAgenticToolParamsNormalizesStrictOpenAISchemas(t *testing.T) {
params := buildAgenticToolParams(staticAgenticToolExecutor{
definitions: []AgenticToolDefinition{
{
Name: "fs_ls",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"session_id": map[string]any{"type": "string"},
"beacon_id": map[string]any{"type": "string"},
"path": map[string]any{"type": "string"},
},
"additionalProperties": false,
},
},
},
})
if len(params) != 1 || params[0].OfFunction == nil {
t.Fatalf("expected one function tool param, got %+v", params)
}
parameters := params[0].OfFunction.Parameters
required := schemaRequiredSet(parameters["required"])
for _, field := range []string{"session_id", "beacon_id", "path"} {
if !required[field] {
t.Fatalf("expected strict schema to require %q, got %#v", field, parameters["required"])
}
}
properties, ok := parameters["properties"].(map[string]any)
if !ok {
t.Fatalf("expected strict schema properties map, got %#v", parameters["properties"])
}
for _, field := range []string{"session_id", "beacon_id", "path"} {
property, ok := properties[field].(map[string]any)
if !ok {
t.Fatalf("expected property %q to be a schema object, got %#v", field, properties[field])
}
typeValues, ok := property["type"].([]string)
if !ok {
t.Fatalf("expected property %q type to be []string, got %#v", field, property["type"])
}
if !stringSliceContains(typeValues, "string") || !stringSliceContains(typeValues, "null") {
t.Fatalf("expected property %q to allow string and null, got %#v", field, typeValues)
}
}
}
+29 -358
View File
@@ -8,12 +8,6 @@ import (
"strings"
"time"
"charm.land/fantasy"
fantasyanthropic "charm.land/fantasy/providers/anthropic"
fantasygoogle "charm.land/fantasy/providers/google"
fantasyopenai "charm.land/fantasy/providers/openai"
fantasyopenaicompat "charm.land/fantasy/providers/openaicompat"
fantasyopenrouter "charm.land/fantasy/providers/openrouter"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/server/configs"
)
@@ -24,8 +18,6 @@ const (
defaultGoogleModel = "gemini-2.5-pro"
defaultOpenRouterModel = "openai/gpt-5"
defaultCompletionTimeout = 2 * time.Minute
anthropicThinkingBudgetLow = 1024
)
var httpClient = &http.Client{Timeout: defaultCompletionTimeout}
@@ -130,6 +122,8 @@ func ResolveRuntimeConfig(cfg *configs.ServerConfig, conversation *clientpb.AICo
return runtime, errors.New(missingProviderConfigError(runtime.Provider))
case runtime.Model == "":
return runtime, fmt.Errorf("server AI provider %q is missing a model; update `ai.model` or choose a provider default", runtime.Provider)
case !completionDriverAvailable(runtime):
return runtime, missingDriverError(runtime)
default:
return runtime, nil
}
@@ -149,229 +143,15 @@ func CompleteConversation(ctx context.Context, runtime *RuntimeConfig, conversat
return nil, err
}
provider, err := newFantasyProvider(runtime)
driver, err := selectCompletionDriver(runtime)
if err != nil {
return nil, err
}
model, err := provider.LanguageModel(ctx, runtime.Model)
if err != nil {
return nil, formatFantasyError(runtime.Provider, err)
}
response, err := model.Generate(ctx, buildFantasyCall(runtime, systemPrompt, messages))
if err != nil {
return nil, formatFantasyError(runtime.Provider, err)
}
content := extractFantasyResponseText(response)
if content == "" {
return nil, fmt.Errorf("%s response did not include assistant text", runtime.Provider)
}
finishReason := strings.TrimSpace(string(response.FinishReason))
if finishReason == "" || finishReason == string(fantasy.FinishReasonUnknown) {
finishReason = "completed"
}
return &Completion{
Provider: fallbackString(model.Provider(), runtime.Provider),
Model: fallbackString(model.Model(), runtime.Model),
Content: content,
ProviderMessageID: extractProviderMessageID(response),
FinishReason: finishReason,
}, nil
}
func newFantasyProvider(runtime *RuntimeConfig) (fantasy.Provider, error) {
if runtime == nil {
return nil, fmt.Errorf("AI runtime config is required")
}
headers := providerHeaders(runtime)
switch runtime.Provider {
case ProviderAnthropic:
opts := []fantasyanthropic.Option{
fantasyanthropic.WithName(ProviderAnthropic),
fantasyanthropic.WithHTTPClient(httpClient),
}
if runtime.APIKey != "" {
opts = append(opts, fantasyanthropic.WithAPIKey(runtime.APIKey))
}
if runtime.BaseURL != "" {
opts = append(opts, fantasyanthropic.WithBaseURL(runtime.BaseURL))
}
if len(headers) > 0 {
opts = append(opts, fantasyanthropic.WithHeaders(headers))
}
if runtime.Project != "" && runtime.Location != "" {
opts = append(opts, fantasyanthropic.WithVertex(runtime.Project, runtime.Location))
}
if runtime.SkipAuth {
opts = append(opts, fantasyanthropic.WithSkipAuth(true))
}
if runtime.UseBedrock {
opts = append(opts, fantasyanthropic.WithBedrock())
}
return fantasyanthropic.New(opts...)
case ProviderGoogle:
opts := []fantasygoogle.Option{
fantasygoogle.WithName(ProviderGoogle),
fantasygoogle.WithHTTPClient(httpClient),
}
if runtime.BaseURL != "" {
opts = append(opts, fantasygoogle.WithBaseURL(runtime.BaseURL))
}
if len(headers) > 0 {
opts = append(opts, fantasygoogle.WithHeaders(headers))
}
if runtime.APIKey != "" {
opts = append(opts, fantasygoogle.WithGeminiAPIKey(runtime.APIKey))
} else if runtime.Project != "" && runtime.Location != "" {
opts = append(opts, fantasygoogle.WithVertex(runtime.Project, runtime.Location))
}
if runtime.SkipAuth {
opts = append(opts, fantasygoogle.WithSkipAuth(true))
}
return fantasygoogle.New(opts...)
case ProviderOpenAI:
opts := []fantasyopenai.Option{
fantasyopenai.WithName(ProviderOpenAI),
fantasyopenai.WithHTTPClient(httpClient),
}
if runtime.APIKey != "" {
opts = append(opts, fantasyopenai.WithAPIKey(runtime.APIKey))
}
if runtime.BaseURL != "" {
opts = append(opts, fantasyopenai.WithBaseURL(runtime.BaseURL))
}
if len(headers) > 0 {
opts = append(opts, fantasyopenai.WithHeaders(headers))
}
if runtime.Organization != "" {
opts = append(opts, fantasyopenai.WithOrganization(runtime.Organization))
}
if runtime.Project != "" {
opts = append(opts, fantasyopenai.WithProject(runtime.Project))
}
if runtime.UseResponsesAPI {
opts = append(opts, fantasyopenai.WithUseResponsesAPI())
}
return fantasyopenai.New(opts...)
case ProviderOpenAICompat:
opts := []fantasyopenaicompat.Option{
fantasyopenaicompat.WithName(ProviderOpenAICompat),
fantasyopenaicompat.WithHTTPClient(httpClient),
}
if runtime.APIKey != "" {
opts = append(opts, fantasyopenaicompat.WithAPIKey(runtime.APIKey))
}
if runtime.BaseURL != "" {
opts = append(opts, fantasyopenaicompat.WithBaseURL(runtime.BaseURL))
}
if len(headers) > 0 {
opts = append(opts, fantasyopenaicompat.WithHeaders(headers))
}
if runtime.UseResponsesAPI {
opts = append(opts, fantasyopenaicompat.WithUseResponsesAPI())
}
return fantasyopenaicompat.New(opts...)
case ProviderOpenRouter:
opts := []fantasyopenrouter.Option{
fantasyopenrouter.WithName(ProviderOpenRouter),
fantasyopenrouter.WithHTTPClient(httpClient),
}
if runtime.APIKey != "" {
opts = append(opts, fantasyopenrouter.WithAPIKey(runtime.APIKey))
}
if len(headers) > 0 {
opts = append(opts, fantasyopenrouter.WithHeaders(headers))
}
return fantasyopenrouter.New(opts...)
default:
return nil, fmt.Errorf("unsupported AI provider %q", runtime.Provider)
}
}
func buildFantasyCall(runtime *RuntimeConfig, systemPrompt string, messages []providerMessage) fantasy.Call {
prompt := make(fantasy.Prompt, 0, len(messages)+1)
if strings.TrimSpace(systemPrompt) != "" {
prompt = append(prompt, fantasy.Message{
Role: fantasy.MessageRoleSystem,
Content: []fantasy.MessagePart{
fantasy.TextPart{Text: strings.TrimSpace(systemPrompt)},
},
})
}
for _, message := range messages {
prompt = append(prompt, fantasy.Message{
Role: toFantasyMessageRole(message.Role),
Content: []fantasy.MessagePart{
fantasy.TextPart{Text: message.Content},
},
})
}
call := fantasy.Call{
Prompt: prompt,
ProviderOptions: fantasyProviderOptions(runtime),
MaxOutputTokens: optionalRuntimeInt(runtime.MaxOutputTokens),
Temperature: copyOptionalFloat(runtime.Temperature),
TopP: copyOptionalFloat(runtime.TopP),
TopK: copyOptionalInt(runtime.TopK),
PresencePenalty: copyOptionalFloat(runtime.PresencePenalty),
FrequencyPenalty: copyOptionalFloat(runtime.FrequencyPenalty),
}
return call
}
func fantasyProviderOptions(runtime *RuntimeConfig) fantasy.ProviderOptions {
if runtime == nil {
return nil
}
switch runtime.Provider {
case ProviderAnthropic:
if budget := anthropicThinkingBudget(runtime.ThinkingLevel); budget > 0 {
return fantasyanthropic.NewProviderOptions(&fantasyanthropic.ProviderOptions{
Thinking: &fantasyanthropic.ThinkingProviderOption{BudgetTokens: budget},
})
}
case ProviderGoogle:
if thinkingConfig := googleThinkingConfig(runtime.ThinkingLevel); thinkingConfig != nil {
return fantasy.ProviderOptions{
fantasygoogle.Name: &fantasygoogle.ProviderOptions{
ThinkingConfig: thinkingConfig,
},
}
}
case ProviderOpenAI:
if effort := openAIReasoningEffort(runtime.ThinkingLevel); effort != nil {
if runtime.UseResponsesAPI && fantasyopenai.IsResponsesModel(runtime.Model) {
return fantasyopenai.NewResponsesProviderOptions(&fantasyopenai.ResponsesProviderOptions{
ReasoningEffort: effort,
})
}
return fantasyopenai.NewProviderOptions(&fantasyopenai.ProviderOptions{
ReasoningEffort: effort,
})
}
case ProviderOpenAICompat:
if effort := openAIReasoningEffort(runtime.ThinkingLevel); effort != nil {
return fantasyopenaicompat.NewProviderOptions(&fantasyopenaicompat.ProviderOptions{
ReasoningEffort: effort,
})
}
case ProviderOpenRouter:
if reasoning := openRouterReasoningOptions(runtime.ThinkingLevel); reasoning != nil {
return fantasyopenrouter.NewProviderOptions(&fantasyopenrouter.ProviderOptions{
Reasoning: reasoning,
})
}
}
return nil
return driver.CompleteConversation(ctx, runtime, &completionRequest{
SystemPrompt: systemPrompt,
Messages: messages,
})
}
func conversationHistory(conversation *clientpb.AIConversation) (string, []providerMessage, error) {
@@ -385,6 +165,12 @@ func conversationHistory(conversation *clientpb.AIConversation) (string, []provi
if message == nil {
continue
}
if message.GetVisibility() == clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY {
continue
}
if message.GetKind() != clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT {
continue
}
role := strings.ToLower(strings.TrimSpace(message.GetRole()))
content := strings.TrimSpace(message.GetContent())
@@ -432,6 +218,22 @@ func defaultModelForProvider(provider string) string {
}
}
func completionDriverAvailable(runtime *RuntimeConfig) bool {
_, err := selectCompletionDriver(runtime)
return err == nil
}
func missingDriverError(runtime *RuntimeConfig) error {
if runtime == nil {
return fmt.Errorf("AI runtime config is required")
}
_, err := selectCompletionDriver(runtime)
if err != nil {
return err
}
return nil
}
func normalizeThinkingLevel(cfg *configs.ServerConfig) string {
if cfg == nil || cfg.AI == nil {
return ""
@@ -444,72 +246,6 @@ func normalizeThinkingLevel(cfg *configs.ServerConfig) string {
}
}
func openAIReasoningEffort(thinkingLevel string) *fantasyopenai.ReasoningEffort {
switch strings.ToLower(strings.TrimSpace(thinkingLevel)) {
case "low":
return fantasyopenai.ReasoningEffortOption(fantasyopenai.ReasoningEffortLow)
case "medium":
return fantasyopenai.ReasoningEffortOption(fantasyopenai.ReasoningEffortMedium)
case "high":
return fantasyopenai.ReasoningEffortOption(fantasyopenai.ReasoningEffortHigh)
default:
return nil
}
}
func anthropicThinkingBudget(thinkingLevel string) int64 {
switch strings.ToLower(strings.TrimSpace(thinkingLevel)) {
case "low":
return anthropicThinkingBudgetLow
case "medium":
return anthropicThinkingBudgetLow * 2
case "high":
return anthropicThinkingBudgetLow * 4
default:
return 0
}
}
func googleThinkingConfig(thinkingLevel string) *fantasygoogle.ThinkingConfig {
var budget int64
switch strings.ToLower(strings.TrimSpace(thinkingLevel)) {
case "low":
budget = 1024
case "medium":
budget = 2048
case "high":
budget = 4096
default:
return nil
}
return &fantasygoogle.ThinkingConfig{
ThinkingBudget: fantasy.Opt(budget),
}
}
func openRouterReasoningOptions(thinkingLevel string) *fantasyopenrouter.ReasoningOptions {
switch strings.ToLower(strings.TrimSpace(thinkingLevel)) {
case "low":
return &fantasyopenrouter.ReasoningOptions{
Enabled: fantasy.Opt(true),
Effort: fantasyopenrouter.ReasoningEffortOption(fantasyopenrouter.ReasoningEffortLow),
}
case "medium":
return &fantasyopenrouter.ReasoningOptions{
Enabled: fantasy.Opt(true),
Effort: fantasyopenrouter.ReasoningEffortOption(fantasyopenrouter.ReasoningEffortMedium),
}
case "high":
return &fantasyopenrouter.ReasoningOptions{
Enabled: fantasy.Opt(true),
Effort: fantasyopenrouter.ReasoningEffortOption(fantasyopenrouter.ReasoningEffortHigh),
}
default:
return nil
}
}
func applyProviderRuntimeConfig(runtime *RuntimeConfig, providerConfig *configs.AIProviderConfig) {
if runtime == nil || providerConfig == nil {
return
@@ -583,71 +319,6 @@ func runtimeProviderConfigured(runtime *RuntimeConfig) bool {
}
}
func extractFantasyResponseText(response *fantasy.Response) string {
if response == nil {
return ""
}
blocks := make([]string, 0, len(response.Content))
for _, content := range response.Content {
if content == nil || content.GetType() != fantasy.ContentTypeText {
continue
}
textContent, ok := fantasy.AsContentType[fantasy.TextContent](content)
if !ok {
continue
}
if strings.TrimSpace(textContent.Text) == "" {
continue
}
blocks = append(blocks, textContent.Text)
}
return joinTextBlocks(blocks)
}
func extractProviderMessageID(_ *fantasy.Response) string {
// Fantasy's unified response model does not currently expose a stable top-level
// provider message ID for plain text generations.
return ""
}
func formatFantasyError(provider string, err error) error {
if err == nil {
return nil
}
var providerErr *fantasy.ProviderError
if errors.As(err, &providerErr) {
message := strings.TrimSpace(providerErr.Error())
if providerErr.StatusCode > 0 {
if message == "" {
message = http.StatusText(providerErr.StatusCode)
}
return fmt.Errorf("%s API request failed with HTTP %d: %s", provider, providerErr.StatusCode, truncateForError(message))
}
if message != "" {
return fmt.Errorf("%s API request failed: %s", provider, truncateForError(message))
}
}
return err
}
func toFantasyMessageRole(role string) fantasy.MessageRole {
switch strings.ToLower(strings.TrimSpace(role)) {
case "assistant":
return fantasy.MessageRoleAssistant
default:
return fantasy.MessageRoleUser
}
}
func optionalRuntimeInt(value int64) *int64 {
if value <= 0 {
return nil
}
return fantasy.Opt(value)
}
func copyOptionalFloat(value *float64) *float64 {
if value == nil {
return nil
+173 -63
View File
@@ -108,17 +108,16 @@ func TestCompleteConversationOpenAIUsesConfiguredCredentialsAndSettings(t *testi
if completion.Content != "OpenAI assistant reply" {
t.Fatalf("unexpected completion content: %q", completion.Content)
}
if completion.ProviderMessageID != "" {
t.Fatalf("expected fantasy-backed completion to leave provider message id empty, got %q", completion.ProviderMessageID)
if completion.ProviderMessageID != "resp_123" {
t.Fatalf("unexpected provider message id: %q", completion.ProviderMessageID)
}
}
func TestCompleteConversationAnthropicUsesConfiguredCredentialsAndSettings(t *testing.T) {
func TestCompleteConversationOpenAIUsesDefaultBaseURLWhenUnset(t *testing.T) {
type capturedRequest struct {
Path string
APIKey string
AnthropicVersion string
Body string
Path string
Authorization string
Body string
}
requests := make(chan capturedRequest, 1)
@@ -132,25 +131,80 @@ func TestCompleteConversationAnthropicUsesConfiguredCredentialsAndSettings(t *te
}
requests <- capturedRequest{
Path: r.URL.Path,
APIKey: r.Header.Get("x-api-key"),
AnthropicVersion: r.Header.Get("anthropic-version"),
Body: string(payload),
Path: r.URL.Path,
Authorization: r.Header.Get("Authorization"),
Body: string(payload),
}
return jsonResponse(http.StatusOK, `{
"id": "msg_123",
"model": "claude-sonnet-4-0",
"stop_reason": "end_turn",
"content": [
{"type": "thinking", "thinking": "internal reasoning"},
{"type": "text", "text": "Anthropic assistant reply"}
"id": "resp_default_base",
"model": "gpt-5.4",
"status": "completed",
"output": [
{
"type": "message",
"role": "assistant",
"content": [
{"type": "output_text", "text": "OpenAI default-base reply"}
]
}
]
}`), nil
}),
})
defer restoreClient()
cfg := &configs.ServerConfig{
AI: &configs.AIConfig{
Provider: ProviderOpenAI,
Model: "gpt-5.4",
OpenAI: &configs.AIProviderConfig{
APIKey: "openai-key",
UseResponsesAPI: boolPtr(true),
},
Anthropic: &configs.AIProviderConfig{},
},
}
conversation := &clientpb.AIConversation{
Provider: ProviderOpenAI,
Model: "gpt-5.4",
Messages: []*clientpb.AIConversationMessage{
{Role: "user", Content: "Say hi."},
},
}
runtime, err := ResolveRuntimeConfig(cfg, conversation)
if err != nil {
t.Fatalf("resolve runtime config: %v", err)
}
if runtime.BaseURL != "" {
t.Fatalf("expected openai runtime base url to remain unset, got %q", runtime.BaseURL)
}
completion, err := CompleteConversation(context.Background(), runtime, conversation)
if err != nil {
t.Fatalf("complete conversation: %v", err)
}
request := <-requests
if request.Path != "/v1/responses" {
t.Fatalf("unexpected openai default-base request path: got=%q want=%q", request.Path, "/v1/responses")
}
if request.Authorization != "Bearer openai-key" {
t.Fatalf("unexpected authorization header: %q", request.Authorization)
}
if !strings.Contains(request.Body, `"Say hi."`) {
t.Fatalf("expected openai request body to contain the user prompt, got %s", request.Body)
}
if completion.Content != "OpenAI default-base reply" {
t.Fatalf("unexpected completion content: %q", completion.Content)
}
if completion.ProviderMessageID != "resp_default_base" {
t.Fatalf("unexpected provider message id: %q", completion.ProviderMessageID)
}
}
func TestResolveRuntimeConfigAnthropicRequiresAnInstalledDriver(t *testing.T) {
cfg := &configs.ServerConfig{
AI: &configs.AIConfig{
Provider: ProviderAnthropic,
@@ -162,57 +216,16 @@ func TestCompleteConversationAnthropicUsesConfiguredCredentialsAndSettings(t *te
OpenAI: &configs.AIProviderConfig{},
},
}
conversation := &clientpb.AIConversation{
Provider: ProviderAnthropic,
SystemPrompt: "Use short answers.",
Messages: []*clientpb.AIConversationMessage{
{Role: "user", Content: "Hello"},
{Role: "assistant", Content: "Hi there"},
{Role: "user", Content: "What changed?"},
},
}
runtime, err := ResolveRuntimeConfig(cfg, conversation)
if err != nil {
t.Fatalf("resolve runtime config: %v", err)
}
runtime, err := ResolveRuntimeConfig(cfg, &clientpb.AIConversation{Provider: ProviderAnthropic})
if runtime.Model != defaultAnthropicModel {
t.Fatalf("unexpected default anthropic model: got=%q want=%q", runtime.Model, defaultAnthropicModel)
}
completion, err := CompleteConversation(context.Background(), runtime, conversation)
if err != nil {
t.Fatalf("complete conversation: %v", err)
if err == nil {
t.Fatal("expected anthropic runtime resolution to require a dedicated driver")
}
request := <-requests
if request.Path != "/edge/v1/messages" {
t.Fatalf("unexpected anthropic request path: got=%q want=%q", request.Path, "/edge/v1/messages")
}
if request.APIKey != "anthropic-key" {
t.Fatalf("unexpected anthropic api key header: %q", request.APIKey)
}
if request.AnthropicVersion == "" {
t.Fatal("expected anthropic version header to be set")
}
for _, fragment := range []string{
`"model":"claude-sonnet-4-0"`,
`"Use short answers."`,
`"budget_tokens":2048`,
`"Hello"`,
`"Hi there"`,
`"What changed?"`,
} {
if !strings.Contains(request.Body, fragment) {
t.Fatalf("expected anthropic request body to contain %q, got %s", fragment, request.Body)
}
}
if completion.Content != "Anthropic assistant reply" {
t.Fatalf("unexpected completion content: %q", completion.Content)
}
if completion.FinishReason != "stop" {
t.Fatalf("unexpected finish reason: %q", completion.FinishReason)
if !strings.Contains(err.Error(), `provider "anthropic"`) || !strings.Contains(err.Error(), "SDK driver") {
t.Fatalf("unexpected error: %v", err)
}
}
@@ -330,6 +343,103 @@ func TestCompleteConversationOpenAICompatUsesBaseURLWithoutAuth(t *testing.T) {
if completion.Content != "OpenAI-compatible assistant reply" {
t.Fatalf("unexpected completion content: %q", completion.Content)
}
if completion.ProviderMessageID != "chatcmpl_123" {
t.Fatalf("unexpected provider message id: %q", completion.ProviderMessageID)
}
}
func TestCompleteConversationOpenRouterUsesDefaultBaseURL(t *testing.T) {
type capturedRequest struct {
Path string
Authorization string
Body string
}
requests := make(chan capturedRequest, 1)
restoreClient := SetHTTPClientForTests(&http.Client{
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
defer r.Body.Close()
payload, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read request body: %v", err)
}
requests <- capturedRequest{
Path: r.URL.Path,
Authorization: r.Header.Get("Authorization"),
Body: string(payload),
}
return jsonResponse(http.StatusOK, `{
"id": "chatcmpl_or_123",
"object": "chat.completion",
"model": "openai/gpt-5",
"choices": [
{
"index": 0,
"finish_reason": "stop",
"message": {
"role": "assistant",
"content": "OpenRouter assistant reply"
}
}
]
}`), nil
}),
})
defer restoreClient()
cfg := &configs.ServerConfig{
AI: &configs.AIConfig{
Provider: ProviderOpenRouter,
ThinkingLevel: "medium",
OpenRouter: &configs.AIProviderConfig{
APIKey: "openrouter-key",
},
},
}
conversation := &clientpb.AIConversation{
Provider: ProviderOpenRouter,
Messages: []*clientpb.AIConversationMessage{
{Role: "user", Content: "Say hi."},
},
}
runtime, err := ResolveRuntimeConfig(cfg, conversation)
if err != nil {
t.Fatalf("resolve runtime config: %v", err)
}
if runtime.Model != defaultOpenRouterModel {
t.Fatalf("unexpected default openrouter model: got=%q want=%q", runtime.Model, defaultOpenRouterModel)
}
completion, err := CompleteConversation(context.Background(), runtime, conversation)
if err != nil {
t.Fatalf("complete conversation: %v", err)
}
request := <-requests
if request.Path != "/api/v1/chat/completions" {
t.Fatalf("unexpected openrouter request path: got=%q want=%q", request.Path, "/api/v1/chat/completions")
}
if request.Authorization != "Bearer openrouter-key" {
t.Fatalf("unexpected authorization header: %q", request.Authorization)
}
for _, fragment := range []string{
`"model":"openai/gpt-5"`,
`"Say hi."`,
} {
if !strings.Contains(request.Body, fragment) {
t.Fatalf("expected openrouter request body to contain %q, got %s", fragment, request.Body)
}
}
if completion.Content != "OpenRouter assistant reply" {
t.Fatalf("unexpected completion content: %q", completion.Content)
}
if completion.ProviderMessageID != "chatcmpl_or_123" {
t.Fatalf("unexpected provider message id: %q", completion.ProviderMessageID)
}
}
type roundTripFunc func(*http.Request) (*http.Response, error)
+38
View File
@@ -0,0 +1,38 @@
package ai
import (
"context"
"fmt"
)
type completionRequest struct {
SystemPrompt string
Messages []providerMessage
}
type sdkDriver interface {
Name() string
Supports(*RuntimeConfig) bool
CompleteConversation(context.Context, *RuntimeConfig, *completionRequest) (*Completion, error)
}
var completionDrivers = []sdkDriver{
newOpenAIDriver(),
}
func selectCompletionDriver(runtime *RuntimeConfig) (sdkDriver, error) {
if runtime == nil {
return nil, fmt.Errorf("AI runtime config is required")
}
for _, driver := range completionDrivers {
if driver.Supports(runtime) {
return driver, nil
}
}
return nil, fmt.Errorf(
"server AI provider %q does not have an available SDK driver; use openai, openai-compat, or openrouter, or add a driver in server/ai",
runtime.Provider,
)
}
+312
View File
@@ -0,0 +1,312 @@
package ai
import (
"context"
"errors"
"fmt"
"strings"
openai "github.com/openai/openai-go/v2"
"github.com/openai/openai-go/v2/option"
openairesponses "github.com/openai/openai-go/v2/responses"
"github.com/openai/openai-go/v2/shared"
)
const (
openAIDriverName = "openai-go"
defaultOpenRouterBaseURL = "https://openrouter.ai/api/v1"
)
type openAIDriver struct{}
type openAIClient struct {
chat openai.ChatCompletionService
responses openairesponses.ResponseService
}
func newOpenAIDriver() sdkDriver {
return &openAIDriver{}
}
func (d *openAIDriver) Name() string {
return openAIDriverName
}
func (d *openAIDriver) Supports(runtime *RuntimeConfig) bool {
if runtime == nil {
return false
}
switch NormalizeProviderName(runtime.Provider) {
case ProviderOpenAI, ProviderOpenAICompat, ProviderOpenRouter:
return true
default:
return false
}
}
func (d *openAIDriver) CompleteConversation(ctx context.Context, runtime *RuntimeConfig, request *completionRequest) (*Completion, error) {
if runtime == nil {
return nil, fmt.Errorf("AI runtime config is required")
}
if request == nil {
return nil, fmt.Errorf("AI completion request is required")
}
client := newOpenAIClient(openAIRequestOptions(runtime)...)
if runtime.UseResponsesAPI {
return d.completeResponses(ctx, client, runtime, request)
}
return d.completeChat(ctx, client, runtime, request)
}
func newOpenAIClient(opts ...option.RequestOption) *openAIClient {
return &openAIClient{
chat: openai.NewChatCompletionService(opts...),
responses: openairesponses.NewResponseService(opts...),
}
}
func openAIRequestOptions(runtime *RuntimeConfig) []option.RequestOption {
opts := []option.RequestOption{
option.WithHTTPClient(httpClient),
option.WithRequestTimeout(defaultCompletionTimeout),
option.WithMaxRetries(0),
}
if runtime != nil && NormalizeProviderName(runtime.Provider) == ProviderOpenAI {
opts = append(opts, option.WithEnvironmentProduction())
}
if baseURL := openAIBaseURL(runtime); baseURL != "" {
opts = append(opts, option.WithBaseURL(baseURL))
}
if runtime != nil {
if runtime.APIKey != "" {
opts = append(opts, option.WithAPIKey(runtime.APIKey))
}
if runtime.Organization != "" {
opts = append(opts, option.WithOrganization(runtime.Organization))
}
if runtime.Project != "" {
opts = append(opts, option.WithProject(runtime.Project))
}
for key, value := range providerHeaders(runtime) {
opts = append(opts, option.WithHeader(key, value))
}
if runtime.TopK != nil && NormalizeProviderName(runtime.Provider) != ProviderOpenAI {
opts = append(opts, option.WithJSONSet("top_k", *runtime.TopK))
}
}
return opts
}
func openAIBaseURL(runtime *RuntimeConfig) string {
if runtime == nil {
return ""
}
if runtime.BaseURL != "" {
return runtime.BaseURL
}
if NormalizeProviderName(runtime.Provider) == ProviderOpenRouter {
return defaultOpenRouterBaseURL
}
return ""
}
func (d *openAIDriver) completeResponses(ctx context.Context, client *openAIClient, runtime *RuntimeConfig, request *completionRequest) (*Completion, error) {
params := openairesponses.ResponseNewParams{
Input: openairesponses.ResponseNewParamsInputUnion{
OfInputItemList: buildResponseInput(request.SystemPrompt, request.Messages),
},
Model: shared.ResponsesModel(runtime.Model),
}
if runtime.MaxOutputTokens > 0 {
params.MaxOutputTokens = openai.Int(runtime.MaxOutputTokens)
}
if runtime.Temperature != nil {
params.Temperature = openai.Float(*runtime.Temperature)
}
if runtime.TopP != nil {
params.TopP = openai.Float(*runtime.TopP)
}
if effort, ok := reasoningEffortForThinkingLevel(runtime.ThinkingLevel); ok {
params.Reasoning = shared.ReasoningParam{Effort: effort}
}
response, err := client.responses.New(ctx, params)
if err != nil {
return nil, formatOpenAIError(runtime.Provider, err)
}
content := strings.TrimSpace(response.OutputText())
if content == "" {
return nil, fmt.Errorf("%s response did not include assistant text", runtime.Provider)
}
return &Completion{
Provider: runtime.Provider,
Model: fallbackString(response.Model, runtime.Model),
Content: content,
ProviderMessageID: strings.TrimSpace(response.ID),
FinishReason: responseFinishReason(response),
}, nil
}
func (d *openAIDriver) completeChat(ctx context.Context, client *openAIClient, runtime *RuntimeConfig, request *completionRequest) (*Completion, error) {
params := openai.ChatCompletionNewParams{
Messages: buildChatMessages(request.SystemPrompt, request.Messages),
Model: shared.ChatModel(runtime.Model),
}
if runtime.MaxOutputTokens > 0 {
if NormalizeProviderName(runtime.Provider) == ProviderOpenAI {
params.MaxCompletionTokens = openai.Int(runtime.MaxOutputTokens)
} else {
params.MaxTokens = openai.Int(runtime.MaxOutputTokens)
}
}
if runtime.Temperature != nil {
params.Temperature = openai.Float(*runtime.Temperature)
}
if runtime.TopP != nil {
params.TopP = openai.Float(*runtime.TopP)
}
if runtime.PresencePenalty != nil {
params.PresencePenalty = openai.Float(*runtime.PresencePenalty)
}
if runtime.FrequencyPenalty != nil {
params.FrequencyPenalty = openai.Float(*runtime.FrequencyPenalty)
}
if effort, ok := reasoningEffortForThinkingLevel(runtime.ThinkingLevel); ok {
params.ReasoningEffort = effort
}
response, err := client.chat.New(ctx, params)
if err != nil {
return nil, formatOpenAIError(runtime.Provider, err)
}
content, finishReason := extractChatCompletionResult(response)
if content == "" {
return nil, fmt.Errorf("%s response did not include assistant text", runtime.Provider)
}
return &Completion{
Provider: runtime.Provider,
Model: fallbackString(response.Model, runtime.Model),
Content: content,
ProviderMessageID: strings.TrimSpace(response.ID),
FinishReason: finishReason,
}, nil
}
func buildResponseInput(systemPrompt string, messages []providerMessage) openairesponses.ResponseInputParam {
input := make(openairesponses.ResponseInputParam, 0, len(messages)+1)
if strings.TrimSpace(systemPrompt) != "" {
input = append(input, openairesponses.ResponseInputItemParamOfMessage(strings.TrimSpace(systemPrompt), openairesponses.EasyInputMessageRoleSystem))
}
for _, message := range messages {
input = append(input, openairesponses.ResponseInputItemParamOfMessage(message.Content, responseMessageRole(message.Role)))
}
return input
}
func buildChatMessages(systemPrompt string, messages []providerMessage) []openai.ChatCompletionMessageParamUnion {
chatMessages := make([]openai.ChatCompletionMessageParamUnion, 0, len(messages)+1)
if strings.TrimSpace(systemPrompt) != "" {
chatMessages = append(chatMessages, openai.SystemMessage(strings.TrimSpace(systemPrompt)))
}
for _, message := range messages {
switch strings.ToLower(strings.TrimSpace(message.Role)) {
case "assistant":
chatMessages = append(chatMessages, openai.AssistantMessage(message.Content))
default:
chatMessages = append(chatMessages, openai.UserMessage(message.Content))
}
}
return chatMessages
}
func responseMessageRole(role string) openairesponses.EasyInputMessageRole {
switch strings.ToLower(strings.TrimSpace(role)) {
case "assistant":
return openairesponses.EasyInputMessageRoleAssistant
default:
return openairesponses.EasyInputMessageRoleUser
}
}
func extractChatCompletionResult(response *openai.ChatCompletion) (string, string) {
if response == nil || len(response.Choices) == 0 {
return "", ""
}
for _, choice := range response.Choices {
content := strings.TrimSpace(choice.Message.Content)
if content == "" {
continue
}
finishReason := strings.TrimSpace(choice.FinishReason)
if finishReason == "" {
finishReason = "completed"
}
return content, finishReason
}
return "", ""
}
func responseFinishReason(response *openairesponses.Response) string {
if response == nil {
return ""
}
if response.Status == openairesponses.ResponseStatusIncomplete && strings.TrimSpace(response.IncompleteDetails.Reason) != "" {
return strings.TrimSpace(response.IncompleteDetails.Reason)
}
finishReason := strings.TrimSpace(string(response.Status))
if finishReason == "" {
return "completed"
}
return finishReason
}
func reasoningEffortForThinkingLevel(thinkingLevel string) (shared.ReasoningEffort, bool) {
switch strings.ToLower(strings.TrimSpace(thinkingLevel)) {
case "low":
return shared.ReasoningEffortLow, true
case "medium":
return shared.ReasoningEffortMedium, true
case "high":
return shared.ReasoningEffortHigh, true
default:
return "", false
}
}
func formatOpenAIError(provider string, err error) error {
if err == nil {
return nil
}
var apiErr *openai.Error
if errors.As(err, &apiErr) {
message := strings.TrimSpace(apiErr.Message)
if message == "" {
message = strings.TrimSpace(apiErr.RawJSON())
}
if message == "" {
message = strings.TrimSpace(err.Error())
}
if apiErr.StatusCode > 0 {
return fmt.Errorf("%s API request failed with HTTP %d: %s", provider, apiErr.StatusCode, truncateForError(message))
}
if message != "" {
return fmt.Errorf("%s API request failed: %s", provider, truncateForError(message))
}
}
return err
}
+37 -4
View File
@@ -42,7 +42,18 @@ func aiConfigCmd(_ *cobra.Command, args []string) {
}
result := currentAIConfigFormResult(serverConfig)
if err := forms.AIConfig(result); err != nil {
originalProvider := ai.NormalizeProviderName(result.Provider)
if err := forms.SelectAIProvider(result); err != nil {
if errors.Is(err, forms.ErrUserAborted) {
return
}
fmt.Printf(Warn+"AI configuration form failed: %v\n", err)
return
}
if ai.NormalizeProviderName(result.Provider) != originalProvider {
result = currentAIConfigFormResultForProvider(serverConfig, result.Provider)
}
if err := forms.EditAIConfig(result); err != nil {
if errors.Is(err, forms.ErrUserAborted) {
return
}
@@ -68,15 +79,30 @@ func aiConfigCmd(_ *cobra.Command, args []string) {
}
func currentAIConfigFormResult(serverConfig *configs.ServerConfig) *forms.AIConfigFormResult {
return currentAIConfigFormResultForProvider(serverConfig, selectedAIProvider(serverConfigAIConfig(serverConfig)))
}
func currentAIConfigFormResultForProvider(serverConfig *configs.ServerConfig, provider string) *forms.AIConfigFormResult {
if serverConfig == nil {
return &forms.AIConfigFormResult{Provider: ai.ProviderOpenAI, UseResponsesAPI: true}
provider = ai.NormalizeProviderName(provider)
if !ai.IsSupportedProvider(provider) {
provider = ai.ProviderOpenAI
}
return &forms.AIConfigFormResult{
Provider: provider,
UseResponsesAPI: provider == ai.ProviderOpenAI,
}
}
provider := selectedAIProvider(serverConfig.AI)
provider = ai.NormalizeProviderName(provider)
if !ai.IsSupportedProvider(provider) {
provider = selectedAIProvider(serverConfig.AI)
}
providerConfig := aiProviderConfig(serverConfig.AI, provider)
result := &forms.AIConfigFormResult{
Provider: provider,
Provider: provider,
UseResponsesAPI: provider == ai.ProviderOpenAI,
}
if serverConfig.AI != nil {
result.Model = strings.TrimSpace(serverConfig.AI.Model)
@@ -99,6 +125,13 @@ func currentAIConfigFormResult(serverConfig *configs.ServerConfig) *forms.AIConf
return result
}
func serverConfigAIConfig(serverConfig *configs.ServerConfig) *configs.AIConfig {
if serverConfig == nil {
return nil
}
return serverConfig.AI
}
func applyAIConfigFormResult(serverConfig *configs.ServerConfig, result *forms.AIConfigFormResult) {
if serverConfig == nil || result == nil {
return
+54
View File
@@ -31,6 +31,60 @@ func TestCurrentAIConfigFormResultUsesConfiguredProviderFallback(t *testing.T) {
}
}
func TestCurrentAIConfigFormResultForProviderLoadsRequestedProviderSettings(t *testing.T) {
serverConfig := &configs.ServerConfig{
AI: &configs.AIConfig{
Provider: ai.ProviderAnthropic,
Model: "shared-model",
ThinkingLevel: "medium",
Anthropic: &configs.AIProviderConfig{APIKey: "anthropic-key"},
Google: &configs.AIProviderConfig{},
OpenAI: &configs.AIProviderConfig{
APIKey: "openai-key",
BaseURL: "https://api.openai.test",
Organization: "org-test",
Project: "proj-test",
UseResponsesAPI: boolPtr(true),
},
OpenAICompat: &configs.AIProviderConfig{},
OpenRouter: &configs.AIProviderConfig{},
},
}
result := currentAIConfigFormResultForProvider(serverConfig, ai.ProviderOpenAI)
if result.Provider != ai.ProviderOpenAI {
t.Fatalf("expected %q provider, got %q", ai.ProviderOpenAI, result.Provider)
}
if result.Model != "shared-model" {
t.Fatalf("expected shared model, got %q", result.Model)
}
if result.APIKey != "openai-key" {
t.Fatalf("expected openai API key, got %q", result.APIKey)
}
if result.BaseURL != "https://api.openai.test" {
t.Fatalf("expected openai base url, got %q", result.BaseURL)
}
if result.Organization != "org-test" {
t.Fatalf("expected openai organization, got %q", result.Organization)
}
if result.Project != "proj-test" {
t.Fatalf("expected openai project, got %q", result.Project)
}
if !result.UseResponsesAPI {
t.Fatal("expected openai responses api to remain enabled")
}
}
func TestCurrentAIConfigFormResultForProviderDefaultsResponsesAPIForOpenAI(t *testing.T) {
result := currentAIConfigFormResultForProvider(&configs.ServerConfig{}, ai.ProviderOpenAI)
if result.Provider != ai.ProviderOpenAI {
t.Fatalf("expected %q provider, got %q", ai.ProviderOpenAI, result.Provider)
}
if !result.UseResponsesAPI {
t.Fatal("expected openai to default to responses API")
}
}
func TestApplyAIConfigFormResultUpdatesOnlySelectedProvider(t *testing.T) {
serverConfig := &configs.ServerConfig{
AI: &configs.AIConfig{
+42 -17
View File
@@ -31,6 +31,18 @@ func AIConfig(result *AIConfigFormResult) error {
return errors.New("AI config result is required")
}
if err := SelectAIProvider(result); err != nil {
return err
}
return EditAIConfig(result)
}
// SelectAIProvider prompts for the AI provider to edit.
func SelectAIProvider(result *AIConfigFormResult) error {
if result == nil {
return errors.New("AI config result is required")
}
normalizeAIConfigResult(result)
providerOptions := []huh.Option[string]{
@@ -40,15 +52,8 @@ func AIConfig(result *AIConfigFormResult) error {
huh.NewOption("OpenAI-Compatible", ai.ProviderOpenAICompat),
huh.NewOption("OpenRouter", ai.ProviderOpenRouter),
}
thinkingOptions := []huh.Option[string]{
huh.NewOption("Provider default", ""),
huh.NewOption("Disabled", "disabled"),
huh.NewOption("Low", "low"),
huh.NewOption("Medium", "medium"),
huh.NewOption("High", "high"),
}
providerForm := huh.NewForm(
providerForm := newConsoleForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("AI provider").
@@ -64,11 +69,31 @@ func AIConfig(result *AIConfigFormResult) error {
}),
),
)
if err := providerForm.Run(); err != nil {
if err := runConsoleForm(providerForm); err != nil {
return err
}
detailsForm := huh.NewForm(
normalizeAIConfigResult(result)
return nil
}
// EditAIConfig prompts for the provider-independent and provider-specific AI settings.
func EditAIConfig(result *AIConfigFormResult) error {
if result == nil {
return errors.New("AI config result is required")
}
normalizeAIConfigResult(result)
thinkingOptions := []huh.Option[string]{
huh.NewOption("Provider default", ""),
huh.NewOption("Disabled", "disabled"),
huh.NewOption("Low", "low"),
huh.NewOption("Medium", "medium"),
huh.NewOption("High", "high"),
}
detailsForm := newConsoleForm(
huh.NewGroup(
huh.NewNote().
Title("Server AI configuration").
@@ -116,7 +141,7 @@ func AIConfig(result *AIConfigFormResult) error {
Value(&result.UserAgent),
),
)
if err := detailsForm.Run(); err != nil {
if err := runConsoleForm(detailsForm); err != nil {
return err
}
@@ -142,7 +167,7 @@ func providerSpecificAIConfig(result *AIConfigFormResult) error {
}
func openAIAIConfig(result *AIConfigFormResult) error {
form := huh.NewForm(
form := newConsoleForm(
huh.NewGroup(
huh.NewInput().
Title("Organization").
@@ -160,11 +185,11 @@ func openAIAIConfig(result *AIConfigFormResult) error {
Value(&result.UseResponsesAPI),
),
)
return form.Run()
return runConsoleForm(form)
}
func anthropicAIConfig(result *AIConfigFormResult) error {
form := huh.NewForm(
form := newConsoleForm(
huh.NewGroup(
huh.NewInput().
Title("Vertex project").
@@ -186,11 +211,11 @@ func anthropicAIConfig(result *AIConfigFormResult) error {
Value(&result.UseBedrock),
),
)
return form.Run()
return runConsoleForm(form)
}
func googleAIConfig(result *AIConfigFormResult) error {
form := huh.NewForm(
form := newConsoleForm(
huh.NewGroup(
huh.NewInput().
Title("Vertex project").
@@ -208,7 +233,7 @@ func googleAIConfig(result *AIConfigFormResult) error {
Value(&result.SkipAuth),
),
)
return form.Run()
return runConsoleForm(form)
}
func normalizeAIConfigResult(result *AIConfigFormResult) {
+36
View File
@@ -0,0 +1,36 @@
package forms
import (
"errors"
tea "charm.land/bubbletea/v2"
"charm.land/huh/v2"
)
func newConsoleForm(groups ...*huh.Group) *huh.Form {
return huh.NewForm(groups...)
}
func runConsoleForm(form *huh.Form) error {
if form == nil {
return errors.New("form is required")
}
ttyIn, ttyOut, err := tea.OpenTTY()
if err == nil {
if ttyIn != nil {
defer ttyIn.Close()
}
if ttyOut != nil && (ttyIn == nil || ttyOut.Fd() != ttyIn.Fd()) {
defer ttyOut.Close()
}
if ttyIn != nil {
form.WithInput(ttyIn)
}
if ttyOut != nil {
form.WithOutput(ttyOut)
}
}
return form.Run()
}
+2 -2
View File
@@ -35,8 +35,8 @@ func SelectOperator(title string, operators []string, value *string) error {
return nil
})
form := huh.NewForm(huh.NewGroup(field))
return form.Run()
form := newConsoleForm(huh.NewGroup(field))
return runConsoleForm(form)
}
func ensureSelectedValue(options []string, value *string) {
+37 -3
View File
@@ -1438,6 +1438,10 @@ func SaveAIConversation(conversation *clientpb.AIConversation, operatorName stri
existing.Title = dbConversation.Title
existing.Summary = dbConversation.Summary
existing.SystemPrompt = dbConversation.SystemPrompt
existing.ActiveTurnID = dbConversation.ActiveTurnID
existing.TurnState = dbConversation.TurnState
existing.TargetSessionID = dbConversation.TargetSessionID
existing.TargetBeaconID = dbConversation.TargetBeaconID
if err := dbSession.Save(existing).Error; err != nil {
return nil, err
@@ -1509,6 +1513,20 @@ func SaveAIConversationMessage(message *clientpb.AIConversationMessage, operator
dbSession := Session()
var savedMessage *models.AIConversationMessage
err = dbSession.Transaction(func(tx *gorm.DB) error {
if dbMessage.ID == uuid.Nil && strings.TrimSpace(dbMessage.ItemID) != "" {
existingByItem := &models.AIConversationMessage{}
err := tx.Where("conversation_id = ?", dbMessage.ConversationID).
Where("item_id = ?", strings.TrimSpace(dbMessage.ItemID)).
First(existingByItem).Error
if err != nil {
if !errors.Is(err, ErrRecordNotFound) {
return err
}
} else {
dbMessage.ID = existingByItem.ID
}
}
if dbMessage.ID == uuid.Nil {
if dbMessage.Sequence == 0 {
lastMessage := &models.AIConversationMessage{}
@@ -1539,15 +1557,31 @@ func SaveAIConversationMessage(message *clientpb.AIConversationMessage, operator
if dbMessage.OperatorName != "" {
existing.OperatorName = dbMessage.OperatorName
}
existing.Provider = dbMessage.Provider
existing.Model = dbMessage.Model
if dbMessage.Provider != "" {
existing.Provider = dbMessage.Provider
}
if dbMessage.Model != "" {
existing.Model = dbMessage.Model
}
if dbMessage.Sequence != 0 {
existing.Sequence = dbMessage.Sequence
}
existing.Role = dbMessage.Role
if dbMessage.Role != "" {
existing.Role = dbMessage.Role
}
existing.Content = dbMessage.Content
existing.ProviderMessageID = dbMessage.ProviderMessageID
existing.FinishReason = dbMessage.FinishReason
existing.Kind = dbMessage.Kind
existing.Visibility = dbMessage.Visibility
existing.State = dbMessage.State
existing.TurnID = dbMessage.TurnID
existing.ItemID = dbMessage.ItemID
existing.ToolCallID = dbMessage.ToolCallID
existing.ToolName = dbMessage.ToolName
existing.ToolArguments = dbMessage.ToolArguments
existing.ToolResult = dbMessage.ToolResult
existing.ErrorText = dbMessage.ErrorText
if err := tx.Save(existing).Error; err != nil {
return err
+68 -26
View File
@@ -28,15 +28,19 @@ import (
// AIConversation - A server-side AI conversation thread.
type AIConversation struct {
ID uuid.UUID `gorm:"primaryKey;->;<-:create;type:uuid;"`
CreatedAt time.Time `gorm:"->;<-:create;"`
UpdatedAt time.Time
OperatorName string `gorm:"index"`
Provider string `gorm:"index"`
Model string
Title string
Summary string
SystemPrompt string `gorm:"type:text;"`
ID uuid.UUID `gorm:"primaryKey;->;<-:create;type:uuid;"`
CreatedAt time.Time `gorm:"->;<-:create;"`
UpdatedAt time.Time
OperatorName string `gorm:"index"`
Provider string `gorm:"index"`
Model string
Title string
Summary string
SystemPrompt string `gorm:"type:text;"`
ActiveTurnID string `gorm:"index"`
TurnState int32
TargetSessionID string `gorm:"index"`
TargetBeaconID string `gorm:"index"`
Messages []AIConversationMessage `gorm:"foreignKey:ConversationID;constraint:OnDelete:CASCADE;"`
}
@@ -61,16 +65,20 @@ func (a *AIConversation) ToProtobuf() *clientpb.AIConversation {
}
return &clientpb.AIConversation{
ID: a.ID.String(),
CreatedAt: a.CreatedAt.Unix(),
UpdatedAt: a.UpdatedAt.Unix(),
OperatorName: a.OperatorName,
Provider: a.Provider,
Model: a.Model,
Title: a.Title,
Summary: a.Summary,
SystemPrompt: a.SystemPrompt,
Messages: messages,
ID: a.ID.String(),
CreatedAt: a.CreatedAt.Unix(),
UpdatedAt: a.UpdatedAt.Unix(),
OperatorName: a.OperatorName,
Provider: a.Provider,
Model: a.Model,
Title: a.Title,
Summary: a.Summary,
SystemPrompt: a.SystemPrompt,
Messages: messages,
ActiveTurnID: a.ActiveTurnID,
TurnState: clientpb.AIConversationTurnState(a.TurnState),
TargetSessionID: a.TargetSessionID,
TargetBeaconID: a.TargetBeaconID,
}
}
@@ -83,13 +91,17 @@ func AIConversationFromProtobuf(pbConversation *clientpb.AIConversation) *AIConv
id, _ := uuid.FromString(pbConversation.ID)
return &AIConversation{
ID: id,
OperatorName: pbConversation.OperatorName,
Provider: pbConversation.Provider,
Model: pbConversation.Model,
Title: pbConversation.Title,
Summary: pbConversation.Summary,
SystemPrompt: pbConversation.SystemPrompt,
ID: id,
OperatorName: pbConversation.OperatorName,
Provider: pbConversation.Provider,
Model: pbConversation.Model,
Title: pbConversation.Title,
Summary: pbConversation.Summary,
SystemPrompt: pbConversation.SystemPrompt,
ActiveTurnID: pbConversation.ActiveTurnID,
TurnState: int32(pbConversation.TurnState),
TargetSessionID: pbConversation.TargetSessionID,
TargetBeaconID: pbConversation.TargetBeaconID,
}
}
@@ -107,6 +119,16 @@ type AIConversationMessage struct {
Content string `gorm:"type:text;"`
ProviderMessageID string
FinishReason string
Kind int32
Visibility int32
State int32
TurnID string `gorm:"index"`
ItemID string `gorm:"index"`
ToolCallID string `gorm:"index"`
ToolName string
ToolArguments string `gorm:"type:text;"`
ToolResult string `gorm:"type:text;"`
ErrorText string `gorm:"type:text;"`
}
// BeforeCreate - GORM hook.
@@ -136,6 +158,16 @@ func (a *AIConversationMessage) ToProtobuf() *clientpb.AIConversationMessage {
Content: a.Content,
ProviderMessageID: a.ProviderMessageID,
FinishReason: a.FinishReason,
Kind: clientpb.AIConversationMessageKind(a.Kind),
Visibility: clientpb.AIConversationMessageVisibility(a.Visibility),
State: clientpb.AIConversationMessageState(a.State),
TurnID: a.TurnID,
ItemID: a.ItemID,
ToolCallID: a.ToolCallID,
ToolName: a.ToolName,
ToolArguments: a.ToolArguments,
ToolResult: a.ToolResult,
ErrorText: a.ErrorText,
}
}
@@ -159,5 +191,15 @@ func AIConversationMessageFromProtobuf(pbMessage *clientpb.AIConversationMessage
Content: pbMessage.Content,
ProviderMessageID: pbMessage.ProviderMessageID,
FinishReason: pbMessage.FinishReason,
Kind: int32(pbMessage.Kind),
Visibility: int32(pbMessage.Visibility),
State: int32(pbMessage.State),
TurnID: pbMessage.TurnID,
ItemID: pbMessage.ItemID,
ToolCallID: pbMessage.ToolCallID,
ToolName: pbMessage.ToolName,
ToolArguments: pbMessage.ToolArguments,
ToolResult: pbMessage.ToolResult,
ErrorText: pbMessage.ErrorText,
}
}
+302
View File
@@ -0,0 +1,302 @@
package rpc
import (
"context"
"strings"
consts "github.com/bishopfox/sliver/client/constants"
"github.com/bishopfox/sliver/protobuf/clientpb"
serverai "github.com/bishopfox/sliver/server/ai"
"github.com/bishopfox/sliver/server/core"
"github.com/bishopfox/sliver/server/db"
"google.golang.org/protobuf/proto"
)
func publishAIConversationEvent(event *clientpb.AIConversationEvent) {
if event == nil {
return
}
if strings.TrimSpace(event.GetConversationID()) == "" {
switch {
case event.GetConversation() != nil:
event.ConversationID = event.GetConversation().GetID()
case event.GetMessage() != nil:
event.ConversationID = event.GetMessage().GetConversationID()
}
}
if event.GetConversation() != nil && len(event.GetConversation().GetMessages()) > 0 {
event.Conversation = aiConversationSummary(event.GetConversation())
}
data, err := proto.Marshal(event)
if err != nil {
rpcLog.Warnf("Failed to marshal AI conversation event: %s", err)
return
}
core.EventBroker.Publish(core.Event{
EventType: consts.AIConversationEvent,
Data: data,
})
}
func aiConversationSummary(conversation *clientpb.AIConversation) *clientpb.AIConversation {
if conversation == nil {
return nil
}
summary, ok := proto.Clone(conversation).(*clientpb.AIConversation)
if !ok {
return conversation
}
summary.Messages = nil
return summary
}
func loadAIConversationSummary(conversationID string) (*clientpb.AIConversation, error) {
conversation, err := db.AIConversationByID(conversationID, "", false)
if err != nil {
return nil, err
}
return aiConversationSummary(conversation), nil
}
func publishAIConversationSummaryEvent(eventType clientpb.AIConversationEventType, conversation *clientpb.AIConversation, turnID string, errText string) {
publishAIConversationEvent(&clientpb.AIConversationEvent{
EventType: eventType,
ConversationID: selectedAIConversationID(conversation),
TurnID: strings.TrimSpace(turnID),
ErrorText: strings.TrimSpace(errText),
Conversation: aiConversationSummary(conversation),
})
}
func publishAIConversationMessageEvent(
eventType clientpb.AIConversationEventType,
conversation *clientpb.AIConversation,
message *clientpb.AIConversationMessage,
turnID string,
errText string,
) {
publishAIConversationEvent(&clientpb.AIConversationEvent{
EventType: eventType,
ConversationID: selectedAIConversationID(conversation),
TurnID: strings.TrimSpace(turnID),
ErrorText: strings.TrimSpace(errText),
Conversation: aiConversationSummary(conversation),
Message: message,
})
}
func selectedAIConversationID(conversation *clientpb.AIConversation) string {
if conversation == nil {
return ""
}
return strings.TrimSpace(conversation.GetID())
}
type aiConversationEventSink struct {
conversationID string
operatorName string
runtime *serverai.RuntimeConfig
turnID string
}
func newAIConversationEventSink(conversationID string, operatorName string, runtime *serverai.RuntimeConfig, turnID string) *aiConversationEventSink {
return &aiConversationEventSink{
conversationID: strings.TrimSpace(conversationID),
operatorName: strings.TrimSpace(operatorName),
runtime: runtime,
turnID: strings.TrimSpace(turnID),
}
}
func (s *aiConversationEventSink) TurnStarted() error {
conversation, err := s.updateConversationTurnState(s.turnID, clientpb.AIConversationTurnState_AI_TURN_STATE_IN_PROGRESS)
if err != nil {
return err
}
publishAIConversationSummaryEvent(clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_TURN_STARTED, conversation, s.turnID, "")
return nil
}
func (s *aiConversationEventSink) TurnCompleted() error {
conversation, err := s.updateConversationTurnState("", clientpb.AIConversationTurnState_AI_TURN_STATE_IDLE)
if err != nil {
return err
}
publishAIConversationSummaryEvent(clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_TURN_COMPLETED, conversation, s.turnID, "")
return nil
}
func (s *aiConversationEventSink) TurnFailed(failure error) error {
errText := ""
if failure != nil {
errText = strings.TrimSpace(failure.Error())
}
conversation, err := s.updateConversationTurnState("", clientpb.AIConversationTurnState_AI_TURN_STATE_FAILED)
if err != nil {
return err
}
publishAIConversationSummaryEvent(clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_TURN_FAILED, conversation, s.turnID, errText)
return nil
}
func (s *aiConversationEventSink) ReasoningItem(ctx context.Context, item serverai.AgenticReasoningItem) error {
content := strings.TrimSpace(item.Content)
summary := strings.TrimSpace(item.Summary)
if content == "" {
content = summary
} else if summary != "" && summary != content {
content = "Summary:\n" + summary + "\n\n" + content
}
message := &clientpb.AIConversationMessage{
ConversationID: s.conversationID,
OperatorName: s.operatorName,
Provider: s.provider(),
Model: s.model(),
Role: "assistant",
Content: content,
FinishReason: strings.TrimSpace(item.Status),
Kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_REASONING,
Visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY,
State: normalizeAIMessageState(item.Status, clientpb.AIConversationMessageState_AI_MESSAGE_STATE_COMPLETED),
TurnID: s.turnID,
ItemID: strings.TrimSpace(item.ItemID),
}
_, err := s.saveMessageAndPublish(message, clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_MESSAGE_COMPLETED, "")
return err
}
func (s *aiConversationEventSink) ToolCallStarted(ctx context.Context, item serverai.AgenticToolCall) error {
message := &clientpb.AIConversationMessage{
ConversationID: s.conversationID,
OperatorName: s.operatorName,
Provider: s.provider(),
Model: s.model(),
Role: "assistant",
Kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_TOOL_CALL,
Visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY,
State: clientpb.AIConversationMessageState_AI_MESSAGE_STATE_IN_PROGRESS,
TurnID: s.turnID,
ItemID: strings.TrimSpace(item.ItemID),
ToolCallID: strings.TrimSpace(item.CallID),
ToolName: strings.TrimSpace(item.Name),
ToolArguments: strings.TrimSpace(item.Arguments),
FinishReason: strings.TrimSpace(item.Status),
}
_, err := s.saveMessageAndPublish(message, clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_MESSAGE_STARTED, "")
return err
}
func (s *aiConversationEventSink) ToolCallCompleted(ctx context.Context, item serverai.AgenticToolCallResult) error {
errText := ""
state := clientpb.AIConversationMessageState_AI_MESSAGE_STATE_COMPLETED
if item.Error != nil {
errText = strings.TrimSpace(item.Error.Error())
state = clientpb.AIConversationMessageState_AI_MESSAGE_STATE_FAILED
}
message := &clientpb.AIConversationMessage{
ConversationID: s.conversationID,
OperatorName: s.operatorName,
Provider: s.provider(),
Model: s.model(),
Role: "assistant",
Kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_TOOL_CALL,
Visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY,
State: state,
TurnID: s.turnID,
ItemID: strings.TrimSpace(item.ItemID),
ToolCallID: strings.TrimSpace(item.CallID),
ToolName: strings.TrimSpace(item.Name),
ToolArguments: strings.TrimSpace(item.Arguments),
ToolResult: strings.TrimSpace(item.Output),
ErrorText: errText,
FinishReason: strings.TrimSpace(item.Status),
}
_, err := s.saveMessageAndPublish(message, clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_MESSAGE_COMPLETED, errText)
return err
}
func (s *aiConversationEventSink) SaveFailureMessage(content string, errText string) error {
message := &clientpb.AIConversationMessage{
ConversationID: s.conversationID,
OperatorName: s.operatorName,
Provider: s.provider(),
Model: s.model(),
Role: "system",
Content: strings.TrimSpace(content),
FinishReason: "error",
Kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT,
Visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY,
State: clientpb.AIConversationMessageState_AI_MESSAGE_STATE_FAILED,
TurnID: s.turnID,
ErrorText: strings.TrimSpace(errText),
}
_, err := s.saveMessageAndPublish(message, clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_MESSAGE_COMPLETED, errText)
return err
}
func (s *aiConversationEventSink) saveMessageAndPublish(
message *clientpb.AIConversationMessage,
eventType clientpb.AIConversationEventType,
errText string,
) (*clientpb.AIConversationMessage, error) {
savedMessage, err := db.SaveAIConversationMessage(message, s.operatorName)
if err != nil {
return nil, err
}
conversation, err := loadAIConversationSummary(s.conversationID)
if err != nil {
return nil, err
}
publishAIConversationMessageEvent(eventType, conversation, savedMessage, s.turnID, errText)
return savedMessage, nil
}
func (s *aiConversationEventSink) updateConversationTurnState(activeTurnID string, state clientpb.AIConversationTurnState) (*clientpb.AIConversation, error) {
conversation, err := db.AIConversationByID(s.conversationID, "", false)
if err != nil {
return nil, err
}
conversation.ActiveTurnID = strings.TrimSpace(activeTurnID)
conversation.TurnState = state
if conversation.GetProvider() == "" {
conversation.Provider = s.provider()
}
if conversation.GetModel() == "" {
conversation.Model = s.model()
}
if _, err := db.SaveAIConversation(conversation, s.operatorName); err != nil {
return nil, err
}
return loadAIConversationSummary(s.conversationID)
}
func (s *aiConversationEventSink) provider() string {
if s != nil && s.runtime != nil {
return strings.TrimSpace(s.runtime.Provider)
}
return ""
}
func (s *aiConversationEventSink) model() string {
if s != nil && s.runtime != nil {
return strings.TrimSpace(s.runtime.Model)
}
return ""
}
func normalizeAIMessageState(status string, fallback clientpb.AIConversationMessageState) clientpb.AIConversationMessageState {
switch strings.ToLower(strings.TrimSpace(status)) {
case "in_progress":
return clientpb.AIConversationMessageState_AI_MESSAGE_STATE_IN_PROGRESS
case "incomplete", "failed", "error":
return clientpb.AIConversationMessageState_AI_MESSAGE_STATE_FAILED
case "completed":
return clientpb.AIConversationMessageState_AI_MESSAGE_STATE_COMPLETED
default:
return fallback
}
}
+576
View File
@@ -0,0 +1,576 @@
package rpc
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"time"
"unicode/utf8"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/protobuf/commonpb"
"github.com/bishopfox/sliver/protobuf/sliverpb"
serverai "github.com/bishopfox/sliver/server/ai"
"github.com/bishopfox/sliver/server/db"
"github.com/bishopfox/sliver/util/encoders"
"google.golang.org/protobuf/proto"
)
const (
aiToolDefaultWaitSeconds = int64(60)
)
type aiToolExecutor struct {
rpc *Server
conversation *clientpb.AIConversation
}
type aiTargetArgs struct {
SessionID string `json:"session_id,omitempty"`
BeaconID string `json:"beacon_id,omitempty"`
}
type aiLSToolArgs struct {
aiTargetArgs
Path string `json:"path,omitempty"`
}
type aiCatToolArgs struct {
aiTargetArgs
Path string `json:"path,omitempty"`
MaxBytes int64 `json:"max_bytes,omitempty"`
MaxLines int64 `json:"max_lines,omitempty"`
}
type aiLSFileEntry struct {
Name string `json:"name"`
IsDir bool `json:"is_dir"`
Size int64 `json:"size"`
ModTime int64 `json:"mod_time"`
Mode string `json:"mode"`
Link string `json:"link,omitempty"`
UID string `json:"uid,omitempty"`
GID string `json:"gid,omitempty"`
}
type aiLSResult struct {
Path string `json:"path"`
Exists bool `json:"exists"`
Timezone string `json:"timezone"`
TimezoneOffset int32 `json:"timezone_offset"`
Files []aiLSFileEntry `json:"files"`
}
type aiCatResult struct {
Path string `json:"path"`
RequestedPath string `json:"requested_path"`
Exists bool `json:"exists"`
IsDir bool `json:"is_dir"`
Encoder string `json:"encoder,omitempty"`
Decoded bool `json:"decoded"`
ByteLen int `json:"byte_len"`
DataBase64 string `json:"data_base64"`
Text string `json:"text,omitempty"`
}
type aiPathResult struct {
Path string `json:"path"`
}
type aiSessionSummary struct {
ID string `json:"id"`
Name string `json:"name"`
Transport string `json:"transport"`
RemoteAddress string `json:"remote_address"`
Hostname string `json:"hostname"`
Username string `json:"username"`
PID int32 `json:"pid"`
OS string `json:"os"`
Arch string `json:"arch"`
Locale string `json:"locale"`
LastCheckin int64 `json:"last_checkin"`
IsDead bool `json:"is_dead"`
Integrity string `json:"integrity"`
}
type aiBeaconSummary struct {
ID string `json:"id"`
Name string `json:"name"`
Transport string `json:"transport"`
RemoteAddress string `json:"remote_address"`
Hostname string `json:"hostname"`
Username string `json:"username"`
PID int32 `json:"pid"`
OS string `json:"os"`
Arch string `json:"arch"`
Locale string `json:"locale"`
LastCheckin int64 `json:"last_checkin"`
NextCheckin int64 `json:"next_checkin"`
Interval int64 `json:"interval"`
Jitter int64 `json:"jitter"`
TasksCount int64 `json:"tasks_count"`
TasksCountCompleted int64 `json:"tasks_count_completed"`
IsDead bool `json:"is_dead"`
Integrity string `json:"integrity"`
}
type aiSessionsAndBeaconsResult struct {
Sessions []aiSessionSummary `json:"sessions"`
SessionsCount int `json:"sessions_count"`
Beacons []aiBeaconSummary `json:"beacons"`
BeaconsCount int `json:"beacons_count"`
}
func newAIToolExecutor(rpc *Server, conversation *clientpb.AIConversation) *aiToolExecutor {
return &aiToolExecutor{
rpc: rpc,
conversation: conversation,
}
}
func (e *aiToolExecutor) ToolDefinitions() []serverai.AgenticToolDefinition {
return []serverai.AgenticToolDefinition{
{
Name: "list_sessions_and_beacons",
Description: "List active sessions and beacons so the model can discover valid target IDs before invoking filesystem tools.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{},
"additionalProperties": false,
},
},
{
Name: "fs_ls",
Description: "List the contents of a remote directory. If session_id and beacon_id are both omitted, the conversation target is used when available.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"session_id": map[string]any{"type": "string", "description": "Interactive session ID to run the command against."},
"beacon_id": map[string]any{"type": "string", "description": "Beacon ID to run the command against."},
"path": map[string]any{"type": "string", "description": "Directory path to list. Defaults to . when omitted."},
},
"additionalProperties": false,
},
},
{
Name: "fs_cat",
Description: "Download and return the contents of a remote file. If session_id and beacon_id are both omitted, the conversation target is used when available.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"session_id": map[string]any{"type": "string", "description": "Interactive session ID to run the command against."},
"beacon_id": map[string]any{"type": "string", "description": "Beacon ID to run the command against."},
"path": map[string]any{"type": "string", "description": "Remote file path to download."},
"max_bytes": map[string]any{"type": "integer", "description": "Optional byte limit for the download."},
"max_lines": map[string]any{"type": "integer", "description": "Optional line limit for the download."},
},
"required": []string{"path"},
"additionalProperties": false,
},
},
{
Name: "fs_pwd",
Description: "Return the current working directory of a remote session or beacon. If session_id and beacon_id are both omitted, the conversation target is used when available.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"session_id": map[string]any{"type": "string", "description": "Interactive session ID to run the command against."},
"beacon_id": map[string]any{"type": "string", "description": "Beacon ID to run the command against."},
},
"additionalProperties": false,
},
},
}
}
func (e *aiToolExecutor) CallTool(ctx context.Context, name string, arguments string) (string, error) {
switch strings.TrimSpace(name) {
case "list_sessions_and_beacons":
return e.callListSessionsAndBeacons(ctx)
case "fs_ls":
var args aiLSToolArgs
if err := decodeAIToolArgs(arguments, &args); err != nil {
return "", err
}
return e.callLS(ctx, args)
case "fs_cat":
var args aiCatToolArgs
if err := decodeAIToolArgs(arguments, &args); err != nil {
return "", err
}
return e.callCat(ctx, args)
case "fs_pwd":
var args aiTargetArgs
if err := decodeAIToolArgs(arguments, &args); err != nil {
return "", err
}
return e.callPWD(ctx, args)
default:
return "", fmt.Errorf("unsupported AI tool %q", name)
}
}
func (e *aiToolExecutor) callListSessionsAndBeacons(ctx context.Context) (string, error) {
if e == nil || e.rpc == nil {
return "", fmt.Errorf("AI tool executor is unavailable")
}
sessionsResp, err := e.rpc.GetSessions(ctx, &commonpb.Empty{})
if err != nil {
return "", err
}
beaconsResp, err := e.rpc.GetBeacons(ctx, &commonpb.Empty{})
if err != nil {
return "", err
}
result := aiSessionsAndBeaconsResult{
Sessions: make([]aiSessionSummary, 0, len(sessionsResp.GetSessions())),
Beacons: make([]aiBeaconSummary, 0, len(beaconsResp.GetBeacons())),
}
for _, session := range sessionsResp.GetSessions() {
if session == nil {
continue
}
result.Sessions = append(result.Sessions, aiSessionSummary{
ID: session.ID,
Name: session.Name,
Transport: session.Transport,
RemoteAddress: session.RemoteAddress,
Hostname: session.Hostname,
Username: session.Username,
PID: session.PID,
OS: session.OS,
Arch: session.Arch,
Locale: session.Locale,
LastCheckin: session.LastCheckin,
IsDead: session.IsDead,
Integrity: session.Integrity,
})
}
for _, beacon := range beaconsResp.GetBeacons() {
if beacon == nil {
continue
}
result.Beacons = append(result.Beacons, aiBeaconSummary{
ID: beacon.ID,
Name: beacon.Name,
Transport: beacon.Transport,
RemoteAddress: beacon.RemoteAddress,
Hostname: beacon.Hostname,
Username: beacon.Username,
PID: beacon.PID,
OS: beacon.OS,
Arch: beacon.Arch,
Locale: beacon.Locale,
LastCheckin: beacon.LastCheckin,
NextCheckin: beacon.NextCheckin,
Interval: beacon.Interval,
Jitter: beacon.Jitter,
TasksCount: beacon.TasksCount,
TasksCountCompleted: beacon.TasksCountCompleted,
IsDead: beacon.IsDead,
Integrity: beacon.Integrity,
})
}
result.SessionsCount = len(result.Sessions)
result.BeaconsCount = len(result.Beacons)
return marshalAIToolResult(result)
}
func (e *aiToolExecutor) callLS(ctx context.Context, args aiLSToolArgs) (string, error) {
target, err := e.resolveTarget(args.SessionID, args.BeaconID)
if err != nil {
return "", err
}
if strings.TrimSpace(args.Path) == "" {
args.Path = "."
}
req, isBeacon, callCtx, cancel, err := e.buildRequestContext(ctx, target)
if err != nil {
return "", err
}
if cancel != nil {
defer cancel()
}
resp, err := e.rpc.Ls(callCtx, &sliverpb.LsReq{
Request: req,
Path: args.Path,
})
if err != nil {
return "", err
}
if err := aiGenericResponseError(resp.Response); err != nil {
return "", err
}
if isBeacon && resp.Response != nil && resp.Response.Async {
resolved := &sliverpb.Ls{}
if err := waitForAIBeaconTaskResponse(callCtx, resp.Response.TaskID, resolved); err != nil {
return "", err
}
resp = resolved
if err := aiGenericResponseError(resp.Response); err != nil {
return "", err
}
}
result := aiLSResult{
Path: resp.Path,
Exists: resp.Exists,
Timezone: resp.Timezone,
TimezoneOffset: resp.TimezoneOffset,
Files: make([]aiLSFileEntry, 0, len(resp.Files)),
}
for _, fileInfo := range resp.Files {
if fileInfo == nil {
continue
}
result.Files = append(result.Files, aiLSFileEntry{
Name: fileInfo.Name,
IsDir: fileInfo.IsDir,
Size: fileInfo.Size,
ModTime: fileInfo.ModTime,
Mode: fileInfo.Mode,
Link: fileInfo.Link,
UID: fileInfo.Uid,
GID: fileInfo.Gid,
})
}
return marshalAIToolResult(result)
}
func (e *aiToolExecutor) callCat(ctx context.Context, args aiCatToolArgs) (string, error) {
target, err := e.resolveTarget(args.SessionID, args.BeaconID)
if err != nil {
return "", err
}
if strings.TrimSpace(args.Path) == "" {
return "", fmt.Errorf("path is required")
}
req, isBeacon, callCtx, cancel, err := e.buildRequestContext(ctx, target)
if err != nil {
return "", err
}
if cancel != nil {
defer cancel()
}
resp, err := e.rpc.Download(callCtx, &sliverpb.DownloadReq{
Request: req,
RestrictedToFile: true,
Path: args.Path,
MaxBytes: args.MaxBytes,
MaxLines: args.MaxLines,
})
if err != nil {
return "", err
}
if err := aiGenericResponseError(resp.Response); err != nil {
return "", err
}
if isBeacon && resp.Response != nil && resp.Response.Async {
resolved := &sliverpb.Download{}
if err := waitForAIBeaconTaskResponse(callCtx, resp.Response.TaskID, resolved); err != nil {
return "", err
}
resp = resolved
if err := aiGenericResponseError(resp.Response); err != nil {
return "", err
}
}
result, err := buildAICatResult(resp, args.Path)
if err != nil {
return "", err
}
return marshalAIToolResult(result)
}
func (e *aiToolExecutor) callPWD(ctx context.Context, args aiTargetArgs) (string, error) {
target, err := e.resolveTarget(args.SessionID, args.BeaconID)
if err != nil {
return "", err
}
req, isBeacon, callCtx, cancel, err := e.buildRequestContext(ctx, target)
if err != nil {
return "", err
}
if cancel != nil {
defer cancel()
}
resp, err := e.rpc.Pwd(callCtx, &sliverpb.PwdReq{Request: req})
if err != nil {
return "", err
}
if err := aiGenericResponseError(resp.Response); err != nil {
return "", err
}
if isBeacon && resp.Response != nil && resp.Response.Async {
resolved := &sliverpb.Pwd{}
if err := waitForAIBeaconTaskResponse(callCtx, resp.Response.TaskID, resolved); err != nil {
return "", err
}
resp = resolved
if err := aiGenericResponseError(resp.Response); err != nil {
return "", err
}
}
return marshalAIToolResult(aiPathResult{Path: resp.Path})
}
type aiToolTarget struct {
SessionID string
BeaconID string
}
func (e *aiToolExecutor) resolveTarget(sessionID, beaconID string) (aiToolTarget, error) {
sessionID = strings.TrimSpace(sessionID)
beaconID = strings.TrimSpace(beaconID)
if sessionID != "" && beaconID != "" {
return aiToolTarget{}, fmt.Errorf("provide only one of session_id or beacon_id")
}
if sessionID == "" && beaconID == "" && e != nil && e.conversation != nil {
sessionID = strings.TrimSpace(e.conversation.GetTargetSessionID())
beaconID = strings.TrimSpace(e.conversation.GetTargetBeaconID())
}
if sessionID == "" && beaconID == "" {
return aiToolTarget{}, fmt.Errorf("session_id or beacon_id is required; call list_sessions_and_beacons or start a conversation from an active target")
}
return aiToolTarget{
SessionID: sessionID,
BeaconID: beaconID,
}, nil
}
func (e *aiToolExecutor) buildRequestContext(ctx context.Context, target aiToolTarget) (*commonpb.Request, bool, context.Context, context.CancelFunc, error) {
if strings.TrimSpace(target.SessionID) != "" && strings.TrimSpace(target.BeaconID) != "" {
return nil, false, nil, nil, fmt.Errorf("provide only one of session_id or beacon_id")
}
timeoutSeconds := aiToolDefaultWaitSeconds
req := &commonpb.Request{
Timeout: (int64(time.Second) * timeoutSeconds) - 1,
}
isBeacon := false
if strings.TrimSpace(target.SessionID) != "" {
req.SessionID = strings.TrimSpace(target.SessionID)
req.Async = false
} else {
req.BeaconID = strings.TrimSpace(target.BeaconID)
req.Async = true
isBeacon = true
}
callCtx, cancel := context.WithTimeout(ctx, time.Duration(timeoutSeconds)*time.Second)
return req, isBeacon, callCtx, cancel, nil
}
func decodeAIToolArgs(raw string, out any) error {
raw = strings.TrimSpace(raw)
if raw == "" {
raw = "{}"
}
if err := json.Unmarshal([]byte(raw), out); err != nil {
return fmt.Errorf("invalid tool arguments: %w", err)
}
return nil
}
func marshalAIToolResult(result any) (string, error) {
data, err := json.Marshal(result)
if err != nil {
return "", err
}
return string(data), nil
}
func aiGenericResponseError(resp *commonpb.Response) error {
if resp == nil || strings.TrimSpace(resp.Err) == "" {
return nil
}
return fmt.Errorf("%s", strings.TrimSpace(resp.Err))
}
func waitForAIBeaconTaskResponse(ctx context.Context, taskID string, out proto.Message) error {
task, err := waitForAIBeaconTask(ctx, taskID)
if err != nil {
return err
}
state := strings.ToLower(strings.TrimSpace(task.GetState()))
if state != "completed" {
if state == "" {
state = "unknown"
}
return fmt.Errorf("beacon task %s %s", task.GetID(), state)
}
if len(task.GetResponse()) == 0 {
return fmt.Errorf("beacon task %s returned empty response", task.GetID())
}
return proto.Unmarshal(task.GetResponse(), out)
}
func waitForAIBeaconTask(ctx context.Context, taskID string) (*clientpb.BeaconTask, error) {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
task, err := db.BeaconTaskByID(taskID)
if err != nil {
return nil, err
}
switch strings.ToLower(strings.TrimSpace(task.GetState())) {
case "completed", "failed", "canceled":
return task, nil
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-ticker.C:
}
}
}
func buildAICatResult(download *sliverpb.Download, requestedPath string) (*aiCatResult, error) {
data := download.Data
decoded := false
if download.Encoder == "gzip" {
decoded = true
var err error
data, err = new(encoders.Gzip).Decode(download.Data)
if err != nil {
return nil, err
}
}
result := &aiCatResult{
Path: download.Path,
RequestedPath: requestedPath,
Exists: download.Exists,
IsDir: download.IsDir,
Encoder: download.Encoder,
Decoded: decoded,
ByteLen: len(data),
DataBase64: base64.StdEncoding.EncodeToString(data),
}
if len(data) == 0 {
return result, nil
}
if utf8.Valid(data) {
result.Text = string(data)
}
return result, nil
}
+99 -69
View File
@@ -5,16 +5,14 @@ import (
"fmt"
"strings"
consts "github.com/bishopfox/sliver/client/constants"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/protobuf/commonpb"
serverai "github.com/bishopfox/sliver/server/ai"
"github.com/bishopfox/sliver/server/configs"
"github.com/bishopfox/sliver/server/core"
"github.com/bishopfox/sliver/server/db"
"github.com/gofrs/uuid"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
)
func (rpc *Server) aiOperatorName(ctx context.Context, fallback string) string {
@@ -35,23 +33,6 @@ func validateAIProvider(provider string) error {
return nil
}
func publishAIConversationEvent(conversation *clientpb.AIConversation) {
if conversation == nil {
return
}
data, err := proto.Marshal(conversation)
if err != nil {
rpcLog.Warnf("Failed to marshal AI conversation event: %s", err)
return
}
core.EventBroker.Publish(core.Event{
EventType: consts.AIConversationEvent,
Data: data,
})
}
func (rpc *Server) GetAIProviders(ctx context.Context, _ *commonpb.Empty) (*clientpb.AIProviderConfigs, error) {
return &clientpb.AIProviderConfigs{
Providers: serverai.ConfiguredProviders(),
@@ -92,7 +73,7 @@ func (rpc *Server) SaveAIConversation(ctx context.Context, req *clientpb.AIConve
if err != nil {
return nil, rpcError(err)
}
publishAIConversationEvent(conversation)
publishAIConversationSummaryEvent(clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_CONVERSATION_UPDATED, conversation, "", "")
return conversation, nil
}
@@ -106,9 +87,13 @@ func (rpc *Server) DeleteAIConversation(ctx context.Context, req *clientpb.AICon
if err != nil {
return nil, rpcError(err)
}
publishAIConversationEvent(&clientpb.AIConversation{
ID: req.ID,
OperatorName: operatorName,
publishAIConversationEvent(&clientpb.AIConversationEvent{
EventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_CONVERSATION_DELETED,
ConversationID: req.ID,
Conversation: &clientpb.AIConversation{
ID: req.ID,
OperatorName: operatorName,
},
})
return &commonpb.Empty{}, nil
}
@@ -147,13 +132,19 @@ func (rpc *Server) SaveAIConversationMessage(ctx context.Context, req *clientpb.
return nil, rpcError(err)
}
conversation, err := db.AIConversationByID(message.ConversationID, "", false)
conversation, err := loadAIConversationSummary(message.GetConversationID())
if err != nil {
return nil, rpcError(err)
}
publishAIConversationEvent(conversation)
publishAIConversationMessageEvent(
clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_MESSAGE_COMPLETED,
conversation,
message,
message.GetTurnID(),
message.GetErrorText(),
)
if shouldTriggerAICompletion(req, message) {
go runAIConversationCompletion(message.GetConversationID(), message.GetOperatorName())
go rpc.runAIConversationCompletion(message.GetConversationID(), message.GetOperatorName())
}
return message, nil
@@ -169,7 +160,7 @@ func shouldTriggerAICompletion(req *clientpb.AIConversationMessage, saved *clien
return strings.EqualFold(strings.TrimSpace(saved.GetRole()), "user")
}
func runAIConversationCompletion(conversationID string, operatorName string) {
func (rpc *Server) runAIConversationCompletion(conversationID string, operatorName string) {
conversation, err := db.AIConversationByID(conversationID, "", true)
if err != nil {
rpcLog.Warnf("Failed to load AI conversation %q for completion: %s", conversationID, err)
@@ -184,18 +175,46 @@ func runAIConversationCompletion(conversationID string, operatorName string) {
conversation = updatedConversation
}
}
if resolveErr != nil {
persistAIConversationFailure(conversationID, operatorName, runtime, resolveErr)
return
}
completion, err := serverai.CompleteConversation(context.Background(), runtime, conversation)
turnID, err := newAITurnID()
if err != nil {
persistAIConversationFailure(conversationID, operatorName, runtime, err)
rpcLog.Warnf("Failed to create AI turn id for %q: %s", conversationID, err)
return
}
sink := newAIConversationEventSink(conversationID, operatorName, runtime, turnID)
if err := sink.TurnStarted(); err != nil {
rpcLog.Warnf("Failed to mark AI turn %q as started for %q: %s", turnID, conversationID, err)
return
}
if _, err := db.SaveAIConversationMessage(&clientpb.AIConversationMessage{
if resolveErr != nil {
persistAIConversationFailure(sink, resolveErr)
if err := sink.TurnFailed(resolveErr); err != nil {
rpcLog.Warnf("Failed to mark AI turn %q as failed for %q: %s", turnID, conversationID, err)
}
return
}
var completion *serverai.Completion
if serverai.SupportsAgenticConversation(runtime) {
completion, err = serverai.CompleteConversationAgentic(
context.Background(),
runtime,
conversation,
newAIToolExecutor(rpc, conversation),
sink,
)
} else {
completion, err = serverai.CompleteConversation(context.Background(), runtime, conversation)
}
if err != nil {
persistAIConversationFailure(sink, err)
if turnErr := sink.TurnFailed(err); turnErr != nil {
rpcLog.Warnf("Failed to mark AI turn %q as failed for %q: %s", turnID, conversationID, turnErr)
}
return
}
message, err := db.SaveAIConversationMessage(&clientpb.AIConversationMessage{
ConversationID: conversationID,
OperatorName: operatorName,
Provider: completion.Provider,
@@ -204,12 +223,31 @@ func runAIConversationCompletion(conversationID string, operatorName string) {
Content: completion.Content,
ProviderMessageID: completion.ProviderMessageID,
FinishReason: completion.FinishReason,
}, operatorName); err != nil {
Kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT,
Visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_CONTEXT,
State: clientpb.AIConversationMessageState_AI_MESSAGE_STATE_COMPLETED,
TurnID: turnID,
}, operatorName)
if err != nil {
rpcLog.Warnf("Failed to save AI assistant reply for %q: %s", conversationID, err)
return
}
publishAIConversationByID(conversationID)
summary, err := loadAIConversationSummary(conversationID)
if err != nil {
rpcLog.Warnf("Failed to load AI conversation summary for %q: %s", conversationID, err)
} else {
publishAIConversationMessageEvent(
clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_MESSAGE_COMPLETED,
summary,
message,
turnID,
"",
)
}
if err := sink.TurnCompleted(); err != nil {
rpcLog.Warnf("Failed to mark AI turn %q as completed for %q: %s", turnID, conversationID, err)
}
}
func persistAIConversationRuntime(conversation *clientpb.AIConversation, operatorName string, runtime *serverai.RuntimeConfig) (*clientpb.AIConversation, error) {
@@ -227,49 +265,41 @@ func persistAIConversationRuntime(conversation *clientpb.AIConversation, operato
}
return db.SaveAIConversation(&clientpb.AIConversation{
ID: conversation.GetID(),
OperatorName: conversation.GetOperatorName(),
Provider: provider,
Model: model,
Title: conversation.GetTitle(),
Summary: conversation.GetSummary(),
SystemPrompt: conversation.GetSystemPrompt(),
ID: conversation.GetID(),
OperatorName: conversation.GetOperatorName(),
Provider: provider,
Model: model,
Title: conversation.GetTitle(),
Summary: conversation.GetSummary(),
SystemPrompt: conversation.GetSystemPrompt(),
ActiveTurnID: conversation.GetActiveTurnID(),
TurnState: conversation.GetTurnState(),
TargetSessionID: conversation.GetTargetSessionID(),
TargetBeaconID: conversation.GetTargetBeaconID(),
}, operatorName)
}
func persistAIConversationFailure(conversationID string, operatorName string, runtime *serverai.RuntimeConfig, failure error) {
func persistAIConversationFailure(sink *aiConversationEventSink, failure error) {
content := "AI request failed."
errText := ""
if failure != nil && strings.TrimSpace(failure.Error()) != "" {
content = "AI request failed: " + strings.TrimSpace(failure.Error())
errText = strings.TrimSpace(failure.Error())
content = "AI request failed: " + errText
}
message := &clientpb.AIConversationMessage{
ConversationID: conversationID,
OperatorName: operatorName,
Role: "system",
Content: content,
FinishReason: "error",
}
if runtime != nil {
message.Provider = runtime.Provider
message.Model = runtime.Model
}
if _, err := db.SaveAIConversationMessage(message, operatorName); err != nil {
rpcLog.Warnf("Failed to save AI failure message for %q: %s", conversationID, err)
if sink == nil {
return
}
publishAIConversationByID(conversationID)
if err := sink.SaveFailureMessage(content, errText); err != nil {
rpcLog.Warnf("Failed to save AI failure message for %q: %s", sink.conversationID, err)
}
}
func publishAIConversationByID(conversationID string) {
conversation, err := db.AIConversationByID(conversationID, "", false)
func newAITurnID() (string, error) {
id, err := uuid.NewV4()
if err != nil {
rpcLog.Warnf("Failed to publish AI conversation update for %q: %s", conversationID, err)
return
return "", err
}
publishAIConversationEvent(conversation)
return id.String(), nil
}
func fallbackAIString(value string, fallback string) string {
+675 -14
View File
@@ -2,6 +2,7 @@ package rpc
import (
"context"
"encoding/json"
"io"
"net/http"
"path/filepath"
@@ -78,22 +79,59 @@ func TestSaveAIConversationMessageCompletesConversationAndPublishesEvents(t *tes
waitForAIConversationEvent(t, eventStream, conversation.GetID())
if _, err := client.SaveAIConversationMessage(context.Background(), &clientpb.AIConversationMessage{
savedUserMessage, err := client.SaveAIConversationMessage(context.Background(), &clientpb.AIConversationMessage{
ConversationID: conversation.GetID(),
OperatorName: "alice",
Role: "user",
Content: "Walk me through the gRPC flow.",
}); err != nil {
})
if err != nil {
t.Fatalf("save conversation message: %v", err)
}
current := waitForAIConversationRole(t, client, eventStream, conversation.GetID(), "assistant", 2)
events := waitForAIConversationFlow(t, eventStream, conversation.GetID(), clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_TURN_COMPLETED)
assertAIConversationEventSequence(t, events, []aiConversationEventExpectation{
{
eventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_MESSAGE_COMPLETED,
role: "user",
kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT,
visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_CONTEXT,
state: clientpb.AIConversationMessageState_AI_MESSAGE_STATE_COMPLETED,
},
{
eventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_TURN_STARTED,
},
{
eventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_MESSAGE_COMPLETED,
role: "assistant",
kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT,
visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_CONTEXT,
state: clientpb.AIConversationMessageState_AI_MESSAGE_STATE_COMPLETED,
},
{
eventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_TURN_COMPLETED,
},
})
if got := events[0].GetMessage().GetID(); got != savedUserMessage.GetID() {
t.Fatalf("expected first flow event to reference saved user message %q, got %q", savedUserMessage.GetID(), got)
}
current, err := client.GetAIConversation(context.Background(), &clientpb.AIConversationReq{
ID: conversation.GetID(),
IncludeMessages: true,
})
if err != nil {
t.Fatalf("refresh ai conversation: %v", err)
}
if len(current.GetMessages()) != 2 {
t.Fatalf("unexpected message count: got=%d want=%d", len(current.GetMessages()), 2)
}
if current.GetMessages()[1].GetContent() != "Assistant reply from the provider" {
t.Fatalf("unexpected assistant reply: %q", current.GetMessages()[1].GetContent())
}
if current.GetMessages()[1].GetProviderMessageID() != "resp_success" {
t.Fatalf("unexpected provider message id: %q", current.GetMessages()[1].GetProviderMessageID())
}
if current.GetModel() != "gpt-5.2" {
t.Fatalf("unexpected conversation model: %q", current.GetModel())
}
@@ -105,6 +143,111 @@ func TestSaveAIConversationMessageCompletesConversationAndPublishesEvents(t *tes
}
}
func TestSaveAIConversationMessageCompletesOpenAIWithoutExplicitBaseURL(t *testing.T) {
setupAIRPCTestEnv(t)
requests := make(chan string, 1)
restoreClient := serverai.SetHTTPClientForTests(&http.Client{
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
requests <- r.URL.Path
return jsonResponse(http.StatusOK, `{
"id": "resp_default_base",
"model": "gpt-5.4",
"status": "completed",
"output": [
{
"type": "message",
"role": "assistant",
"content": [
{"type": "output_text", "text": "Assistant reply from default OpenAI base URL"}
]
}
]
}`), nil
}),
})
defer restoreClient()
saveOpenAICompletionConfig(t, "gpt-5.4", "high", "openai-key", "")
client, cleanup := newBufnetRPCClient(t)
defer cleanup()
streamCtx, cancelStream := context.WithTimeout(context.Background(), 10*time.Second)
defer cancelStream()
eventStream, err := client.Events(streamCtx, &commonpb.Empty{})
if err != nil {
t.Fatalf("start events stream: %v", err)
}
conversation, err := client.SaveAIConversation(context.Background(), &clientpb.AIConversation{
OperatorName: "alice",
Provider: serverai.ProviderOpenAI,
Title: "OpenAI default base URL",
})
if err != nil {
t.Fatalf("save conversation: %v", err)
}
waitForAIConversationEvent(t, eventStream, conversation.GetID())
if _, err := client.SaveAIConversationMessage(context.Background(), &clientpb.AIConversationMessage{
ConversationID: conversation.GetID(),
OperatorName: "alice",
Role: "user",
Content: "Confirm the default OpenAI base URL path works.",
}); err != nil {
t.Fatalf("save conversation message: %v", err)
}
events := waitForAIConversationFlow(t, eventStream, conversation.GetID(), clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_TURN_COMPLETED)
assertAIConversationEventSequence(t, events, []aiConversationEventExpectation{
{
eventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_MESSAGE_COMPLETED,
role: "user",
kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT,
visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_CONTEXT,
state: clientpb.AIConversationMessageState_AI_MESSAGE_STATE_COMPLETED,
},
{
eventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_TURN_STARTED,
},
{
eventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_MESSAGE_COMPLETED,
role: "assistant",
kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT,
visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_CONTEXT,
state: clientpb.AIConversationMessageState_AI_MESSAGE_STATE_COMPLETED,
},
{
eventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_TURN_COMPLETED,
},
})
current, err := client.GetAIConversation(context.Background(), &clientpb.AIConversationReq{
ID: conversation.GetID(),
IncludeMessages: true,
})
if err != nil {
t.Fatalf("refresh ai conversation: %v", err)
}
if len(current.GetMessages()) != 2 {
t.Fatalf("unexpected message count: got=%d want=%d", len(current.GetMessages()), 2)
}
if current.GetMessages()[1].GetContent() != "Assistant reply from default OpenAI base URL" {
t.Fatalf("unexpected assistant reply: %q", current.GetMessages()[1].GetContent())
}
select {
case gotPath := <-requests:
if gotPath != "/v1/responses" {
t.Fatalf("unexpected default-base provider path: got=%q want=%q", gotPath, "/v1/responses")
}
default:
t.Fatal("expected the server to call the provider API with the default OpenAI base URL")
}
}
func TestSaveAIConversationMessagePublishesFailureMessageWhenProviderErrors(t *testing.T) {
setupAIRPCTestEnv(t)
@@ -138,16 +281,50 @@ func TestSaveAIConversationMessagePublishesFailureMessageWhenProviderErrors(t *t
waitForAIConversationEvent(t, eventStream, conversation.GetID())
if _, err := client.SaveAIConversationMessage(context.Background(), &clientpb.AIConversationMessage{
savedUserMessage, err := client.SaveAIConversationMessage(context.Background(), &clientpb.AIConversationMessage{
ConversationID: conversation.GetID(),
OperatorName: "alice",
Role: "user",
Content: "Trigger a provider error.",
}); err != nil {
})
if err != nil {
t.Fatalf("save conversation message: %v", err)
}
current := waitForAIConversationRole(t, client, eventStream, conversation.GetID(), "system", 2)
events := waitForAIConversationFlow(t, eventStream, conversation.GetID(), clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_TURN_FAILED)
assertAIConversationEventSequence(t, events, []aiConversationEventExpectation{
{
eventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_MESSAGE_COMPLETED,
role: "user",
kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT,
visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_CONTEXT,
state: clientpb.AIConversationMessageState_AI_MESSAGE_STATE_COMPLETED,
},
{
eventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_TURN_STARTED,
},
{
eventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_MESSAGE_COMPLETED,
role: "system",
kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT,
visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY,
state: clientpb.AIConversationMessageState_AI_MESSAGE_STATE_FAILED,
},
{
eventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_TURN_FAILED,
},
})
if got := events[0].GetMessage().GetID(); got != savedUserMessage.GetID() {
t.Fatalf("expected first flow event to reference saved user message %q, got %q", savedUserMessage.GetID(), got)
}
current, err := client.GetAIConversation(context.Background(), &clientpb.AIConversationReq{
ID: conversation.GetID(),
IncludeMessages: true,
})
if err != nil {
t.Fatalf("refresh ai conversation: %v", err)
}
if len(current.GetMessages()) != 2 {
t.Fatalf("unexpected message count: got=%d want=%d", len(current.GetMessages()), 2)
}
@@ -158,6 +335,233 @@ func TestSaveAIConversationMessagePublishesFailureMessageWhenProviderErrors(t *t
if lastMessage.GetFinishReason() != "error" {
t.Fatalf("unexpected finish reason: %q", lastMessage.GetFinishReason())
}
if lastMessage.GetVisibility() != clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY {
t.Fatalf("expected failure message to stay out of the context window, got %+v", lastMessage)
}
}
func TestSaveAIConversationMessagePersistsReasoningAndToolBlocks(t *testing.T) {
setupAIRPCTestEnv(t)
requestCount := 0
restoreClient := serverai.SetHTTPClientForTests(&http.Client{
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path != "/v1/responses" {
t.Fatalf("unexpected provider request path: %q", r.URL.Path)
}
var payload map[string]any
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode provider request: %v", err)
}
requestCount++
switch requestCount {
case 1:
tools, ok := payload["tools"].([]any)
if !ok || len(tools) == 0 {
t.Fatalf("expected first request to advertise tools, got %#v", payload["tools"])
}
assertStrictResponseToolSchema(t, tools, "list_sessions_and_beacons", []string{}, map[string][]string{})
assertStrictResponseToolSchema(t, tools, "fs_ls", []string{"beacon_id", "path", "session_id"}, map[string][]string{
"beacon_id": []string{"string", "null"},
"path": []string{"string", "null"},
"session_id": []string{"string", "null"},
})
assertStrictResponseToolSchema(t, tools, "fs_cat", []string{"beacon_id", "max_bytes", "max_lines", "path", "session_id"}, map[string][]string{
"beacon_id": []string{"string", "null"},
"max_bytes": []string{"integer", "null"},
"max_lines": []string{"integer", "null"},
"path": []string{"string"},
"session_id": []string{"string", "null"},
})
assertStrictResponseToolSchema(t, tools, "fs_pwd", []string{"beacon_id", "session_id"}, map[string][]string{
"beacon_id": []string{"string", "null"},
"session_id": []string{"string", "null"},
})
return jsonResponse(http.StatusOK, `{
"id": "resp_step1",
"model": "gpt-5.2",
"status": "completed",
"output": [
{
"id": "reasoning_1",
"type": "reasoning",
"summary": [
{"type": "summary_text", "text": "Need to inspect the available targets first."}
],
"content": [
{"type": "reasoning_text", "text": "Need to inspect the available targets first."}
],
"status": "completed"
},
{
"id": "call_item_1",
"type": "function_call",
"call_id": "call_1",
"name": "list_sessions_and_beacons",
"arguments": "{}",
"status": "completed"
}
]
}`), nil
case 2:
if payload["previous_response_id"] != "resp_step1" {
t.Fatalf("expected previous_response_id to be chained, got %#v", payload["previous_response_id"])
}
input, ok := payload["input"].([]any)
if !ok || len(input) != 1 {
t.Fatalf("expected tool output input item, got %#v", payload["input"])
}
return jsonResponse(http.StatusOK, `{
"id": "resp_step2",
"model": "gpt-5.2",
"status": "completed",
"output": [
{
"id": "msg_1",
"type": "message",
"role": "assistant",
"status": "completed",
"content": [
{"type": "output_text", "text": "Assistant reply after a tool call"}
]
}
]
}`), nil
default:
t.Fatalf("unexpected provider request count %d", requestCount)
return nil, nil
}
}),
})
defer restoreClient()
saveOpenAICompletionConfig(t, "gpt-5.2", "high", "openai-key", "https://openai.example/v1")
client, cleanup := newBufnetRPCClient(t)
defer cleanup()
streamCtx, cancelStream := context.WithTimeout(context.Background(), 10*time.Second)
defer cancelStream()
eventStream, err := client.Events(streamCtx, &commonpb.Empty{})
if err != nil {
t.Fatalf("start events stream: %v", err)
}
conversation, err := client.SaveAIConversation(context.Background(), &clientpb.AIConversation{
OperatorName: "alice",
Provider: serverai.ProviderOpenAI,
Title: "Agentic workflow",
})
if err != nil {
t.Fatalf("save conversation: %v", err)
}
waitForAIConversationEvent(t, eventStream, conversation.GetID())
savedUserMessage, err := client.SaveAIConversationMessage(context.Background(), &clientpb.AIConversationMessage{
ConversationID: conversation.GetID(),
OperatorName: "alice",
Role: "user",
Content: "Figure out what targets are available and summarize them.",
})
if err != nil {
t.Fatalf("save conversation message: %v", err)
}
events := waitForAIConversationFlow(t, eventStream, conversation.GetID(), clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_TURN_COMPLETED)
assertAIConversationEventSequence(t, events, []aiConversationEventExpectation{
{
eventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_MESSAGE_COMPLETED,
role: "user",
kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT,
visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_CONTEXT,
state: clientpb.AIConversationMessageState_AI_MESSAGE_STATE_COMPLETED,
},
{
eventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_TURN_STARTED,
},
{
eventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_MESSAGE_COMPLETED,
role: "assistant",
kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_REASONING,
visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY,
state: clientpb.AIConversationMessageState_AI_MESSAGE_STATE_COMPLETED,
itemID: "reasoning_1",
},
{
eventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_MESSAGE_STARTED,
role: "assistant",
kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_TOOL_CALL,
visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY,
state: clientpb.AIConversationMessageState_AI_MESSAGE_STATE_IN_PROGRESS,
itemID: "call_item_1",
toolName: "list_sessions_and_beacons",
},
{
eventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_MESSAGE_COMPLETED,
role: "assistant",
kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_TOOL_CALL,
visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY,
state: clientpb.AIConversationMessageState_AI_MESSAGE_STATE_COMPLETED,
itemID: "call_item_1",
toolName: "list_sessions_and_beacons",
},
{
eventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_MESSAGE_COMPLETED,
role: "assistant",
kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT,
visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_CONTEXT,
state: clientpb.AIConversationMessageState_AI_MESSAGE_STATE_COMPLETED,
},
{
eventType: clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_TURN_COMPLETED,
},
})
if got := events[0].GetMessage().GetID(); got != savedUserMessage.GetID() {
t.Fatalf("expected first flow event to reference saved user message %q, got %q", savedUserMessage.GetID(), got)
}
current, err := client.GetAIConversation(context.Background(), &clientpb.AIConversationReq{
ID: conversation.GetID(),
IncludeMessages: true,
})
if err != nil {
t.Fatalf("refresh ai conversation: %v", err)
}
if len(current.GetMessages()) != 4 {
t.Fatalf("unexpected message count: got=%d want=%d", len(current.GetMessages()), 4)
}
reasoning := current.GetMessages()[1]
if reasoning.GetKind() != clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_REASONING {
t.Fatalf("expected reasoning block, got %+v", reasoning)
}
if reasoning.GetVisibility() != clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY {
t.Fatalf("expected reasoning block to be UI-only, got %+v", reasoning)
}
toolCall := current.GetMessages()[2]
if toolCall.GetKind() != clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_TOOL_CALL {
t.Fatalf("expected tool call block, got %+v", toolCall)
}
if toolCall.GetToolName() != "list_sessions_and_beacons" {
t.Fatalf("unexpected tool name: %+v", toolCall)
}
if strings.TrimSpace(toolCall.GetToolResult()) == "" {
t.Fatalf("expected tool result to be stored, got %+v", toolCall)
}
if toolCall.GetVisibility() != clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_UI_ONLY {
t.Fatalf("expected tool call to be UI-only, got %+v", toolCall)
}
reply := current.GetMessages()[3]
if reply.GetContent() != "Assistant reply after a tool call" {
t.Fatalf("unexpected assistant reply: %q", reply.GetContent())
}
if reply.GetVisibility() != clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_CONTEXT {
t.Fatalf("expected assistant reply to remain in the context window, got %+v", reply)
}
}
func TestAIConversationsAreSharedAcrossOperators(t *testing.T) {
@@ -253,7 +657,12 @@ func setupAIRPCTestEnv(t *testing.T) {
if err != nil {
t.Fatalf("open test db: %v", err)
}
if err := testDB.AutoMigrate(&models.AIConversation{}, &models.AIConversationMessage{}); err != nil {
if err := testDB.AutoMigrate(
&models.AIConversation{},
&models.AIConversationMessage{},
&models.Beacon{},
&models.BeaconTask{},
); err != nil {
t.Fatalf("migrate test db: %v", err)
}
db.Client = testDB
@@ -299,13 +708,13 @@ func waitForAIConversationEvent(t *testing.T, eventStream rpcpb.SliverRPC_Events
continue
}
conversation := &clientpb.AIConversation{}
conversationEvent := &clientpb.AIConversationEvent{}
if len(event.GetData()) > 0 {
if err := proto.Unmarshal(event.GetData(), conversation); err != nil {
if err := proto.Unmarshal(event.GetData(), conversationEvent); err != nil {
t.Fatalf("unmarshal ai conversation event: %v", err)
}
}
if conversation.GetID() == conversationID {
if conversationEvent.GetConversationID() == conversationID {
return
}
}
@@ -324,13 +733,13 @@ func waitForAIConversationRole(t *testing.T, client rpcpb.SliverRPCClient, event
continue
}
conversation := &clientpb.AIConversation{}
conversationEvent := &clientpb.AIConversationEvent{}
if len(event.GetData()) > 0 {
if err := proto.Unmarshal(event.GetData(), conversation); err != nil {
if err := proto.Unmarshal(event.GetData(), conversationEvent); err != nil {
t.Fatalf("unmarshal ai conversation event: %v", err)
}
}
if conversation.GetID() != conversationID {
if conversationEvent.GetConversationID() != conversationID {
continue
}
@@ -346,12 +755,264 @@ func waitForAIConversationRole(t *testing.T, client rpcpb.SliverRPCClient, event
if len(messages) == 0 {
continue
}
if messages[len(messages)-1].GetRole() == role && eventCount >= minEvents {
if aiConversationHasRole(current, role) && eventCount >= minEvents {
return current
}
}
}
type aiConversationEventExpectation struct {
eventType clientpb.AIConversationEventType
role string
kind clientpb.AIConversationMessageKind
visibility clientpb.AIConversationMessageVisibility
state clientpb.AIConversationMessageState
itemID string
toolName string
}
func waitForAIConversationFlow(t *testing.T, eventStream rpcpb.SliverRPC_EventsClient, conversationID string, terminalType clientpb.AIConversationEventType) []*clientpb.AIConversationEvent {
t.Helper()
events := []*clientpb.AIConversationEvent{}
for {
event, err := eventStream.Recv()
if err != nil {
t.Fatalf("receive ai conversation event: %v", err)
}
if event.GetEventType() != consts.AIConversationEvent {
continue
}
conversationEvent := &clientpb.AIConversationEvent{}
if len(event.GetData()) > 0 {
if err := proto.Unmarshal(event.GetData(), conversationEvent); err != nil {
t.Fatalf("unmarshal ai conversation event: %v", err)
}
}
if conversationEvent.GetConversationID() != conversationID {
continue
}
events = append(events, conversationEvent)
if conversationEvent.GetEventType() == terminalType {
return events
}
}
}
func assertAIConversationEventSequence(t *testing.T, events []*clientpb.AIConversationEvent, expectations []aiConversationEventExpectation) {
t.Helper()
if len(events) != len(expectations) {
t.Fatalf("unexpected ai event count: got=%d want=%d\nflow:\n%s", len(events), len(expectations), formatAIConversationEventFlow(events))
}
for idx, expectation := range expectations {
event := events[idx]
if event.GetEventType() != expectation.eventType {
t.Fatalf("unexpected ai event[%d] type: got=%s want=%s\nflow:\n%s", idx, event.GetEventType(), expectation.eventType, formatAIConversationEventFlow(events))
}
if expectation.role == "" {
if event.GetMessage() != nil {
t.Fatalf("expected ai event[%d] to omit a message payload, got %+v", idx, event.GetMessage())
}
continue
}
message := event.GetMessage()
if message == nil {
t.Fatalf("expected ai event[%d] to include a message payload\nflow:\n%s", idx, formatAIConversationEventFlow(events))
}
if got := strings.ToLower(strings.TrimSpace(message.GetRole())); got != expectation.role {
t.Fatalf("unexpected ai event[%d] role: got=%q want=%q\nflow:\n%s", idx, got, expectation.role, formatAIConversationEventFlow(events))
}
if message.GetKind() != expectation.kind {
t.Fatalf("unexpected ai event[%d] kind: got=%s want=%s\nflow:\n%s", idx, message.GetKind(), expectation.kind, formatAIConversationEventFlow(events))
}
if message.GetVisibility() != expectation.visibility {
t.Fatalf("unexpected ai event[%d] visibility: got=%s want=%s\nflow:\n%s", idx, message.GetVisibility(), expectation.visibility, formatAIConversationEventFlow(events))
}
if message.GetState() != expectation.state {
t.Fatalf("unexpected ai event[%d] state: got=%s want=%s\nflow:\n%s", idx, message.GetState(), expectation.state, formatAIConversationEventFlow(events))
}
if expectation.itemID != "" && strings.TrimSpace(message.GetItemID()) != expectation.itemID {
t.Fatalf("unexpected ai event[%d] item id: got=%q want=%q\nflow:\n%s", idx, message.GetItemID(), expectation.itemID, formatAIConversationEventFlow(events))
}
if expectation.toolName != "" && strings.TrimSpace(message.GetToolName()) != expectation.toolName {
t.Fatalf("unexpected ai event[%d] tool name: got=%q want=%q\nflow:\n%s", idx, message.GetToolName(), expectation.toolName, formatAIConversationEventFlow(events))
}
}
}
func formatAIConversationEventFlow(events []*clientpb.AIConversationEvent) string {
parts := make([]string, 0, len(events))
for _, event := range events {
if event == nil {
parts = append(parts, "<nil>")
continue
}
part := event.GetEventType().String()
if message := event.GetMessage(); message != nil {
part += " role=" + strings.TrimSpace(message.GetRole())
part += " kind=" + message.GetKind().String()
part += " visibility=" + message.GetVisibility().String()
part += " state=" + message.GetState().String()
if itemID := strings.TrimSpace(message.GetItemID()); itemID != "" {
part += " item=" + itemID
}
if toolName := strings.TrimSpace(message.GetToolName()); toolName != "" {
part += " tool=" + toolName
}
}
parts = append(parts, part)
}
return strings.Join(parts, "\n")
}
func assertStrictResponseToolSchema(t *testing.T, tools []any, name string, required []string, propertyTypes map[string][]string) {
t.Helper()
schema := responseToolSchemaByName(t, tools, name)
if strict, ok := schema["strict"].(bool); !ok || !strict {
t.Fatalf("expected tool %q to enable strict mode, got %#v", name, schema["strict"])
}
parameters, ok := schema["parameters"].(map[string]any)
if !ok {
t.Fatalf("expected tool %q parameters map, got %#v", name, schema["parameters"])
}
if additionalProperties, ok := parameters["additionalProperties"].(bool); !ok || additionalProperties {
t.Fatalf("expected tool %q to disable additionalProperties, got %#v", name, parameters["additionalProperties"])
}
assertJSONSchemaStringSet(t, name+" required", parameters["required"], required)
properties, ok := parameters["properties"].(map[string]any)
if !ok {
t.Fatalf("expected tool %q properties map, got %#v", name, parameters["properties"])
}
if len(properties) != len(propertyTypes) {
t.Fatalf("unexpected tool %q property count: got=%d want=%d", name, len(properties), len(propertyTypes))
}
for propertyName, expectedTypes := range propertyTypes {
property, ok := properties[propertyName].(map[string]any)
if !ok {
t.Fatalf("expected tool %q property %q to be a schema object, got %#v", name, propertyName, properties[propertyName])
}
assertJSONSchemaStringSet(t, name+"."+propertyName+" type", property["type"], expectedTypes)
}
}
func responseToolSchemaByName(t *testing.T, tools []any, name string) map[string]any {
t.Helper()
for _, rawTool := range tools {
tool, ok := rawTool.(map[string]any)
if !ok {
continue
}
if strings.TrimSpace(anyStringValue(tool["name"])) == name {
return tool
}
}
t.Fatalf("expected tools payload to include %q, got %#v", name, tools)
return nil
}
func assertJSONSchemaStringSet(t *testing.T, label string, raw any, want []string) {
t.Helper()
got := jsonStringSet(raw)
if len(got) != len(want) {
t.Fatalf("unexpected %s count: got=%d want=%d raw=%#v", label, len(got), len(want), raw)
}
for _, expected := range want {
if !got[expected] {
t.Fatalf("expected %s to contain %q, got %#v", label, expected, raw)
}
}
}
func jsonStringSet(raw any) map[string]bool {
values := map[string]bool{}
switch typed := raw.(type) {
case []any:
for _, rawValue := range typed {
if value, ok := rawValue.(string); ok {
value = strings.TrimSpace(value)
if value != "" {
values[value] = true
}
}
}
case []string:
for _, value := range typed {
value = strings.TrimSpace(value)
if value != "" {
values[value] = true
}
}
case string:
value := strings.TrimSpace(typed)
if value != "" {
values[value] = true
}
}
return values
}
func anyStringValue(raw any) string {
value, _ := raw.(string)
return value
}
func aiConversationHasRole(conversation *clientpb.AIConversation, role string) bool {
if conversation == nil {
return false
}
role = strings.TrimSpace(strings.ToLower(role))
messages := conversation.GetMessages()
for i := len(messages) - 1; i >= 0; i-- {
message := messages[i]
if message == nil || strings.TrimSpace(strings.ToLower(message.GetRole())) != role {
continue
}
if role == "assistant" {
return message.GetKind() == clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT &&
message.GetVisibility() == clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_CONTEXT
}
return true
}
return false
}
func waitForAIConversationTurnEvent(t *testing.T, eventStream rpcpb.SliverRPC_EventsClient, conversationID string, wantType clientpb.AIConversationEventType) {
t.Helper()
for {
event, err := eventStream.Recv()
if err != nil {
t.Fatalf("receive ai conversation event: %v", err)
}
if event.GetEventType() != consts.AIConversationEvent {
continue
}
conversationEvent := &clientpb.AIConversationEvent{}
if len(event.GetData()) > 0 {
if err := proto.Unmarshal(event.GetData(), conversationEvent); err != nil {
t.Fatalf("unmarshal ai conversation event: %v", err)
}
}
if conversationEvent.GetConversationID() != conversationID {
continue
}
if conversationEvent.GetEventType() == wantType {
return
}
}
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {