mirror of
https://github.com/BishopFox/sliver.git
synced 2026-11-09 01:49:40 -10:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
593925b4a5 | ||
|
|
255477e572 | ||
|
|
0acc6911d8 | ||
|
|
4ca026906b | ||
|
|
97a249e28e | ||
|
|
9f331687d3 | ||
|
|
14d68d6fcd | ||
|
|
05c5334fea | ||
|
|
d277a14c57 | ||
|
|
a7b5dbe6ee | ||
|
|
c188dc1e69 | ||
|
|
9fc9d1762e | ||
|
|
4ef86448d0 | ||
|
|
4cd010afb9 |
No files matched your search
@@ -1,5 +1,7 @@
|
||||
/crush
|
||||
|
||||
/examples
|
||||
|
||||
/.vscode
|
||||
/.vscode/*
|
||||
/.devcontainer
|
||||
|
||||
+24
-19
@@ -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()),
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 @@
|
||||
{"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\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\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
|
||||
+866
-345
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user