merge: sync official main into PR branch

This commit is contained in:
pphua committed 2026-08-28 14:14:41 +08:00
commit d803ae2b11
23 files changed
+1325 -68

No files matched your search

+2 -1
View File
@@ -286,7 +286,8 @@
- [@Autumn-27](https://github.com/Autumn-27) - [@Autumn-27](https://github.com/Autumn-27)
- [@neouks](https://github.com/neouks) - [@neouks](https://github.com/neouks)
[Unreleased]: https://github.com/Autumn-27/ARTEX/compare/v0.3.4...HEAD [Unreleased]: https://github.com/Autumn-27/ARTEX/compare/v0.3.5...HEAD
[0.3.5]: https://github.com/Autumn-27/ARTEX/compare/v0.3.4...v0.3.5
[0.3.4]: https://github.com/Autumn-27/ARTEX/compare/v0.3.3...v0.3.4 [0.3.4]: https://github.com/Autumn-27/ARTEX/compare/v0.3.3...v0.3.4
[0.3.3]: https://github.com/Autumn-27/ARTEX/compare/v0.3.2...v0.3.3 [0.3.3]: https://github.com/Autumn-27/ARTEX/compare/v0.3.2...v0.3.3
[0.3.2]: https://github.com/Autumn-27/ARTEX/compare/v0.3.1...v0.3.2 [0.3.2]: https://github.com/Autumn-27/ARTEX/compare/v0.3.1...v0.3.2
+5 -1
View File
@@ -242,7 +242,10 @@ const plannerDefaultTmpl = `你是一个授权渗透测试系统的"规划者"
任务目标:{{.Goal}} 任务目标:{{.Goal}}
⚠️ 最重要的原则:**这一轮生成 0 个意图是完全正常、而且是最常见的结果。** 你不是"每次都要产出意图"的机器。绝大多数唤醒,frontier 里已有的意图已经覆盖了所有已知方向,你应当什么都不加、直接结束。重复/换措辞地生成已经存在的意图是严重错误。 ⚠️ **生成 0 个意图是合法、正常的结果,但它有【具体的正当理由】,不是"少派更稳"的默认姿态。** 只在下列情况才该产出 0 个意图:
- **已覆盖**:你想到的方向都已被 open / running / recent_done 意图覆盖——重复或换措辞地再生成已存在的意图是严重错误;或
- **等待依赖**:下一步依赖【当前正在运行的 work】的产出,而产出还没出来——此时应【等这些 work 跑完、图更新后的下一次唤醒再规划】,现在硬派会让下游 worker 拿不到还不存在的前置而空转。
反过来:若确有【未被任何意图覆盖、且不依赖在跑 work】的新方向,或【目标尚未达成且范围内仍有未测面】,就【不要】因为"0 意图常见"而收手——该派就派。别把 0 意图当成偷懒的默认。
决策流程(每次唤醒): 决策流程(每次唤醒):
1. **完整态势已直接附在本提示下方(就是 graph_overview 的返回,无需再调它)**:task(**原始任务标题+目标**,即根节点)、资产计数、goals 及其状态、open/running/recent_done 意图、sites_without_endpoints、findings(**确认漏洞**数)、facts(**探索事实/结论**数,与漏洞是两类)、recent_facts(最近事实的 {id, summary, confidence?},含"端口关闭/不可注入"等**否定结论**——据此别再为已探明的死路生成意图;但 confidence=inferred 的否定结论只是【推断】、证据弱,别当铁案,若该方向对目标很关键值得派一条复核意图)。**这里的探索节点(goals/意图/facts/findings)都只含本任务的**(绝不会有别的任务的目标);**资产图则全局共享**(多任务同一份,资产计数是全局在范围内的数据,非本任务独有)。需要更深的细节时才按需调:list_facts 分页列事实(最新在前,默认 20 条,可传 q 关键词过滤、before 翻页,返回带 total/has_more)、list_findings 列全部漏洞、**node_detail(id)** 取某条的完整证据/详情(列表/recent_facts 只给摘要)。 1. **完整态势已直接附在本提示下方(就是 graph_overview 的返回,无需再调它)**:task(**原始任务标题+目标**,即根节点)、资产计数、goals 及其状态、open/running/recent_done 意图、sites_without_endpoints、findings(**确认漏洞**数)、facts(**探索事实/结论**数,与漏洞是两类)、recent_facts(最近事实的 {id, summary, confidence?},含"端口关闭/不可注入"等**否定结论**——据此别再为已探明的死路生成意图;但 confidence=inferred 的否定结论只是【推断】、证据弱,别当铁案,若该方向对目标很关键值得派一条复核意图)。**这里的探索节点(goals/意图/facts/findings)都只含本任务的**(绝不会有别的任务的目标);**资产图则全局共享**(多任务同一份,资产计数是全局在范围内的数据,非本任务独有)。需要更深的细节时才按需调:list_facts 分页列事实(最新在前,默认 20 条,可传 q 关键词过滤、before 翻页,返回带 total/has_more)、list_findings 列全部漏洞、**node_detail(id)** 取某条的完整证据/详情(列表/recent_facts 只给摘要)。
@@ -250,6 +253,7 @@ const plannerDefaultTmpl = `你是一个授权渗透测试系统的"规划者"
- sites_without_endpoints / findings——"哪些方向【可能】需要探索"。 - sites_without_endpoints / findings——"哪些方向【可能】需要探索"。
- 只有需要某一片的细节时,才**按需**调 list_assets(pull 模式:可用 q 关键字搜索,可叠加 type/company_id/task_id 过滤,分页 limit/offset;或用 id/ids 直接取)、asset_neighbors、list_findings。资产图全局共享,别默认拉全量。资产中可能包含非本次任务涉及到的资产,所以需要主要出现非本次任务相关的资产时忽略这些资产。 - 只有需要某一片的细节时,才**按需**调 list_assets(pull 模式:可用 q 关键字搜索,可叠加 type/company_id/task_id 过滤,分页 limit/offset;或用 id/ids 直接取)、asset_neighbors、list_findings。资产图全局共享,别默认拉全量。资产中可能包含非本次任务涉及到的资产,所以需要主要出现非本次任务相关的资产时忽略这些资产。
2. 判目标(核心职责):graph_overview 的 goals 字段已含目标与状态;对已被某发现/事实证明的未达成目标,调 prove_goal(goal_id,evidence_id,reason) 标记 met。**当你用 prove_goal 标记的这一个恰好是最后一个未完成目标时,系统会自动判定整个任务完成**——收官完全由逐个 prove_goal 驱动,你无需、也没有别的“一键完成”手段。 2. 判目标(核心职责):graph_overview 的 goals 字段已含目标与状态;对已被某发现/事实证明的未达成目标,调 prove_goal(goal_id,evidence_id,reason) 标记 met。**当你用 prove_goal 标记的这一个恰好是最后一个未完成目标时,系统会自动判定整个任务完成**——收官完全由逐个 prove_goal 驱动,你无需、也没有别的“一键完成”手段。
⚠️ **量化验收核对(严禁提前盖章)**:若某目标含【可量化的验收条件】(如"资产测试覆盖度达到 X%"、"拿到 N 个 flag"、"获得某权限"),prove_goal **前【必须】核对本提示上方 graph_overview 里的实测值**(coverage.pct、findings 计数等):实测【未达标】就【禁止】prove_goal,改为派意图补足差距——**【不得】以"主要部分已完成/大体达成/核心目标已拿下"为由提前标 met**。例:目标要求覆盖度 100%、而 graph_overview 实测 coverage.pct=40%,则该目标【未达成】,继续派补测意图,不许 prove_goal。
2.5. **(可选,仅限开局、极轻量)探测理解**:你具备 Bash 等执行能力,它的**唯一正当用途**是——当**图里几乎还没有事实**(recent_facts 基本为空、任务刚开始)、仅凭态势无法把初始意图描述具体时,对目标做**极少量、只读**的探测(如 1–2 次 curl 看首页/指纹),据此产出更精准的**初始意图**。 2.5. **(可选,仅限开局、极轻量)探测理解**:你具备 Bash 等执行能力,它的**唯一正当用途**是——当**图里几乎还没有事实**(recent_facts 基本为空、任务刚开始)、仅凭态势无法把初始意图描述具体时,对目标做**极少量、只读**的探测(如 1–2 次 curl 看首页/指纹),据此产出更精准的**初始意图**。
⚠️ **牢记你的身份边界:你是"规划者",不是"执行者"。你在这里做的一切都只为【生成/说清意图】,绝不是【在 plan 里把活干了】。** 探测的**唯一合法产物是一句更精准的意图描述**——绝不能是漏洞的发现、验证、利用,也不能是端点/目录/参数的枚举结果。任何"我顺手把这个也测了/确认了"的念头都是越界:那是 worker 在 work 阶段该做的事,你只需把它**写成一个意图派下去**。 ⚠️ **牢记你的身份边界:你是"规划者",不是"执行者"。你在这里做的一切都只为【生成/说清意图】,绝不是【在 plan 里把活干了】。** 探测的**唯一合法产物是一句更精准的意图描述**——绝不能是漏洞的发现、验证、利用,也不能是端点/目录/参数的枚举结果。任何"我顺手把这个也测了/确认了"的念头都是越界:那是 worker 在 work 阶段该做的事,你只需把它**写成一个意图派下去**。
**三条硬性边界,务必守住**: **三条硬性边界,务必守住**:
+53 -2
View File
@@ -19,6 +19,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/Autumn-27/artex/llmrec"
"github.com/Autumn-27/norma/agentcore" "github.com/Autumn-27/norma/agentcore"
"github.com/Autumn-27/norma/compaction" "github.com/Autumn-27/norma/compaction"
"github.com/Autumn-27/norma/llm" "github.com/Autumn-27/norma/llm"
@@ -131,6 +132,12 @@ func FromEnv() (Config, bool) {
if c.Model == "" { if c.Model == "" {
c.Model = "gpt-4o" c.Model = "gpt-4o"
} }
case "openai-responses":
c.Format = llm.FormatOpenAIResponses
c.APIKey = oaiKey
if c.Model == "" {
c.Model = "gpt-5"
}
default: default:
c.Format = llm.FormatAnthropic c.Format = llm.FormatAnthropic
c.APIKey = anthKey c.APIKey = anthKey
@@ -164,6 +171,13 @@ func ConfigFrom(provider, model, baseURL, apiKey, proxy string) Config {
if c.Model == "" { if c.Model == "" {
c.Model = "gpt-4o" c.Model = "gpt-4o"
} }
case "openai-responses":
c.Format = llm.FormatOpenAIResponses
// provider appends "/responses"; tolerate a full endpoint URL.
c.BaseURL = strings.TrimRight(strings.TrimSuffix(c.BaseURL, "/responses"), "/")
if c.Model == "" {
c.Model = "gpt-5"
}
default: default:
c.Format = llm.FormatAnthropic c.Format = llm.FormatAnthropic
// provider appends "/v1/messages". // provider appends "/v1/messages".
@@ -187,8 +201,11 @@ func isFalsy(s string) bool {
// Provider returns the short provider name ("anthropic"/"openai"). // Provider returns the short provider name ("anthropic"/"openai").
func (c Config) Provider() string { func (c Config) Provider() string {
if c.Format == llm.FormatOpenAI { switch c.Format {
case llm.FormatOpenAI:
return "openai" return "openai"
case llm.FormatOpenAIResponses:
return "openai-responses"
} }
return "anthropic" return "anthropic"
} }
@@ -265,10 +282,25 @@ func IsQuotaExhaustedMessage(message string) bool {
type quotaAwareTransport struct{ base http.RoundTripper } type quotaAwareTransport struct{ base http.RoundTripper }
func (t quotaAwareTransport) RoundTrip(req *http.Request) (*http.Response, error) { func (t quotaAwareTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// When LLM recording is on, the Recorder puts a Capture on the context so the
// raw wire bodies can be persisted. This is the only layer that still sees
// them: norma builds the request body internally and decodes the SSE response
// before either reaches the recorder.
capt := llmrec.CaptureFrom(req.Context())
capt.SetRequest(requestBodySnapshot(req))
resp, err := t.base.RoundTrip(req) resp, err := t.base.RoundTrip(req)
if err != nil || resp == nil || resp.StatusCode != http.StatusTooManyRequests { if err != nil || resp == nil {
return resp, err return resp, err
} }
// Tee rather than read: a 200 is an SSE stream that must keep streaming. The
// 429 branch below reads through this wrapper, so its body lands in the
// capture before being replaced.
resp.Body = capt.TeeResponse(resp.StatusCode, resp.Body)
if resp.StatusCode != http.StatusTooManyRequests {
return resp, nil
}
body, readErr := io.ReadAll(resp.Body) body, readErr := io.ReadAll(resp.Body)
_ = resp.Body.Close() _ = resp.Body.Close()
resp.Body = io.NopCloser(bytes.NewReader(body)) resp.Body = io.NopCloser(bytes.NewReader(body))
@@ -283,6 +315,25 @@ func (t quotaAwareTransport) RoundTrip(req *http.Request) (*http.Response, error
return resp, nil return resp, nil
} }
// requestBodySnapshot copies an outgoing request body without consuming it.
// norma builds every model request from a *bytes.Reader, so net/http populates
// GetBody and the copy has no effect on what gets sent.
func requestBodySnapshot(req *http.Request) string {
if req.GetBody == nil {
return ""
}
rc, err := req.GetBody()
if err != nil {
return ""
}
defer rc.Close()
b, err := io.ReadAll(rc)
if err != nil {
return ""
}
return string(b)
}
func quotaAwareHTTPClient(proxy string) (*http.Client, error) { func quotaAwareHTTPClient(proxy string) (*http.Client, error) {
transport := http.DefaultTransport.(*http.Transport).Clone() transport := http.DefaultTransport.(*http.Transport).Clone()
proxy = strings.TrimSpace(proxy) proxy = strings.TrimSpace(proxy)
+131
View File
@@ -0,0 +1,131 @@
package agent
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Autumn-27/artex/llmrec"
"github.com/Autumn-27/norma/llm"
)
// End-to-end through a real norma provider: the Capture rides the context into
// norma, survives its internal request building, and comes back holding the
// exact bytes buildBody() put on the wire. This is the load-bearing assumption
// of the whole feature — norma must propagate the caller's context down to
// http.NewRequestWithContext.
func TestCapturePropagatesThroughNormaProvider(t *testing.T) {
sse := strings.Join([]string{
`event: message_start`,
`data: {"type":"message_start","message":{"id":"msg_1","usage":{"input_tokens":11,"output_tokens":1}}}`,
``,
`event: content_block_delta`,
`data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hello"}}`,
``,
`event: message_delta`,
`data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":7}}`,
``,
`event: message_stop`,
`data: {"type":"message_stop"}`,
``,
}, "\n")
// Record exactly what the server receives, so the capture can be compared
// against it byte for byte rather than merely spot-checked for fields.
var gotPath, serverSaw string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
b, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("server read body: %v", err)
}
serverSaw = string(b)
w.Header().Set("content-type", "text/event-stream")
_, _ = io.WriteString(w, sse)
}))
defer srv.Close()
cfg := Config{
Format: llm.FormatAnthropic,
BaseURL: srv.URL,
APIKey: "test-key",
Model: "claude-test",
}
prov, err := cfg.NewProvider()
if err != nil {
t.Fatalf("NewProvider: %v", err)
}
ctx, capt := llmrec.NewCapture(context.Background())
req := llm.CompletionRequest{
System: []string{"you are a scanner"},
Messages: []llm.Message{{Role: "user", Content: []llm.ContentBlock{{Type: "text", Text: "go"}}}},
Tools: []llm.ToolSchema{{
Name: "bash",
Description: "run a shell command",
InputSchema: map[string]any{"type": "object", "properties": map[string]any{"command": map[string]any{"type": "string"}}},
}},
MaxTokens: 1024,
}
var text strings.Builder
for ev, err := range prov.Stream(ctx, req) {
if err != nil {
t.Fatalf("stream: %v", err)
}
if ev.Type == llm.SETextDelta {
text.WriteString(ev.Text)
}
}
if text.String() != "hello" {
t.Fatalf("stream text=%q, capture interfered with delivery", text.String())
}
if gotPath != "/v1/messages" {
t.Fatalf("path=%q", gotPath)
}
// The raw request must be what norma actually sent, not the recorder's
// re-serialization — which is exactly why it carries fields the normalized
// view drops.
raw := capt.RawRequest()
if raw == "" {
t.Fatal("no raw request captured — context did not reach the transport")
}
// The load-bearing claim: what we stored equals, byte for byte, what the
// server received — not merely "has the right fields".
if raw != serverSaw {
t.Fatalf("captured request != what the server received:\n got: %q\nsaw: %q", raw, serverSaw)
}
var body map[string]any
if err := json.Unmarshal([]byte(raw), &body); err != nil {
t.Fatalf("raw request is not valid JSON: %v\n%s", err, raw)
}
if body["model"] != "claude-test" {
t.Errorf("model=%v want claude-test (absent from CompletionRequest)", body["model"])
}
if body["stream"] != true {
t.Errorf("stream=%v want true", body["stream"])
}
// The full tool schema is the headline gain: the normalized view keeps names only.
tools, _ := body["tools"].([]any)
if len(tools) != 1 {
t.Fatalf("tools=%v", body["tools"])
}
tool, _ := tools[0].(map[string]any)
if tool["description"] != "run a shell command" {
t.Errorf("tool description missing: %v", tool)
}
if tool["input_schema"] == nil {
t.Errorf("tool input_schema missing: %v", tool)
}
// And the response is the untouched SSE frames, including the events the
// recorder never turns into stored output.
if capt.RawResponse() != sse {
t.Errorf("RawResponse mismatch:\n got: %q\nwant: %q", capt.RawResponse(), sse)
}
}
+125
View File
@@ -0,0 +1,125 @@
package agent
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Autumn-27/artex/llmrec"
)
// The transport is the only layer that still sees the wire bodies: norma builds
// the request body internally and decodes the SSE response before the recorder
// gets it. This checks the round trip preserves both directions untouched.
func TestRoundTripCapturesRawBodies(t *testing.T) {
const sse = "event: message_start\ndata: {\"type\":\"message_start\"}\n\nevent: message_stop\ndata: {}\n\n"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("content-type", "text/event-stream")
_, _ = io.WriteString(w, sse)
}))
defer srv.Close()
client, err := quotaAwareHTTPClient("")
if err != nil {
t.Fatalf("client: %v", err)
}
const reqBody = `{"model":"claude","messages":[{"role":"user","content":"hi"}],"tools":[{"name":"t","input_schema":{}}]}`
req, err := http.NewRequest(http.MethodPost, srv.URL, strings.NewReader(reqBody))
if err != nil {
t.Fatalf("request: %v", err)
}
ctx, capt := llmrec.NewCapture(req.Context())
req = req.WithContext(ctx)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("do: %v", err)
}
got, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read: %v", err)
}
_ = resp.Body.Close()
if string(got) != sse {
t.Fatal("capture altered the response delivered to norma")
}
if capt.RawRequest() != reqBody {
t.Fatalf("RawRequest()=%q want %q", capt.RawRequest(), reqBody)
}
if capt.RawResponse() != sse {
t.Fatalf("RawResponse()=%q want the raw SSE frames", capt.RawResponse())
}
}
// A 429 body is read and replaced in-place by the quota check. Capturing must
// still see it, and the replacement body must remain readable downstream.
func TestRoundTripCaptures429BodyAlongsideQuotaRewrite(t *testing.T) {
const body = `{"error":{"message":"insufficient_quota"}}`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTooManyRequests)
_, _ = io.WriteString(w, body)
}))
defer srv.Close()
client, err := quotaAwareHTTPClient("")
if err != nil {
t.Fatalf("client: %v", err)
}
req, err := http.NewRequest(http.MethodPost, srv.URL, strings.NewReader("{}"))
if err != nil {
t.Fatalf("request: %v", err)
}
ctx, capt := llmrec.NewCapture(req.Context())
req = req.WithContext(ctx)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("do: %v", err)
}
defer resp.Body.Close()
// Quota exhaustion is normalized to 402 so the router fails over.
if resp.StatusCode != http.StatusPaymentRequired {
t.Fatalf("status=%d want 402", resp.StatusCode)
}
if capt.RawResponse() != body {
t.Fatalf("RawResponse()=%q want %q", capt.RawResponse(), body)
}
rest, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read replaced body: %v", err)
}
if string(rest) != body {
t.Fatalf("replaced body=%q want it still readable", rest)
}
}
// Recording off = no Capture on the context. The transport must behave exactly
// as before, including the quota rewrite.
func TestRoundTripWithoutCaptureIsUnchanged(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, "ok")
}))
defer srv.Close()
client, err := quotaAwareHTTPClient("")
if err != nil {
t.Fatalf("client: %v", err)
}
resp, err := client.Post(srv.URL, "application/json", strings.NewReader("{}"))
if err != nil {
t.Fatalf("post: %v", err)
}
defer resp.Body.Close()
got, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read: %v", err)
}
if string(got) != "ok" {
t.Fatalf("body=%q", got)
}
}
+30
View File
@@ -0,0 +1,30 @@
package agent
import (
"testing"
"github.com/Autumn-27/norma/llm"
)
// TestConfigFromOpenAIResponses locks the openai-responses format wiring:
// ConfigFrom resolves the Responses format, strips a full /responses endpoint
// back to the API base, and Provider() round-trips the short name. NewProvider
// must build a working provider for it.
func TestConfigFromOpenAIResponses(t *testing.T) {
c := ConfigFrom("openai-responses", "gpt-5", "https://gw.example/v1/responses", "sk-x", "")
if c.Format != llm.FormatOpenAIResponses {
t.Fatalf("format=%v, want FormatOpenAIResponses", c.Format)
}
if c.BaseURL != "https://gw.example/v1" {
t.Fatalf("base_url=%q, want the /responses suffix stripped", c.BaseURL)
}
if c.Provider() != "openai-responses" {
t.Fatalf("Provider()=%q", c.Provider())
}
if !c.Stream { // default streaming preserved
t.Fatal("Stream should default true")
}
if _, err := c.NewProvider(); err != nil {
t.Fatalf("NewProvider: %v", err)
}
}
+1 -1
View File
@@ -169,7 +169,7 @@ const workerDefaultTmpl = `你是一个授权渗透测试系统的"执行者"(wo
可用工具: 可用工具:
- insert_assets:登记新资产(资产图)。**你只传原始信息,key 与父子关联由代码算**:新接口→传完整 url+method(代码自动建 domain→site→endpoint、自动抽 URL 里的 query 参数,body/header 参数放 params);指纹→type=tech,name=技术名,on_url=站点地址,props填{version,category}。多个资产放 assets 数组一次批量登记。属性写在资产自己的 props 上,探索结论不要写这里。 - insert_assets:登记新资产(资产图)。**你只传原始信息,key 与父子关联由代码算**:新接口→传完整 url+method(代码自动建 domain→site→endpoint、自动抽 URL 里的 query 参数,body/header 参数放 params);指纹→type=tech,name=技术名,on_url=站点地址,props填{version,category}。多个资产放 assets 数组一次批量登记。属性写在资产自己的 props 上,探索结论不要写这里。
- record_fact:把探索【事实/结论】写入探索图并连到意图(传 intent_id)。正向/否定结论、观察、判断用它;**一次探索的多个观察汇总成一条事实**(summary 总结一句话 + detail 放细节),不要一个属性一条。真有多条不同结论才用 facts 数组。**只写你真实看到的**:给 evidence(一行关键证据:命令+关键输出,简洁,别粘大段——细节在 detail)、标 confidence(observed 直接看到 / inferred 推断);**否定结论**(不可注入/端口关闭等)尤其要给证据、证据弱就标 inferred,别让错的否定误导规划者放弃方向。 - record_fact:把探索【事实/结论】写入探索图并连到意图(传 intent_id)。正向/否定结论、观察、判断用它;**一次探索的多个观察汇总成一条事实**(summary 总结一句话 + detail 放细节),不要一个属性一条。真有多条不同结论才用 facts 数组。**只写你真实看到的**:给 evidence(一行关键证据:命令+关键输出,简洁,别粘大段——细节在 detail)、标 confidence(observed 直接看到 / inferred 推断);**否定结论的证据门槛**(不可注入/端口关闭/无登录入口等【可能让规划者放弃一整条方向】的结论):下结论前先确认你已【穷尽这条意图内的合理手段】(换编码/换参数/换路径/换方法);手段没走完、或证据只是"看起来像/大概率如此",一律标 confidence=inferred。**宁可标 inferred 让规划者复核,也别用一个轻率的 observed 否定把一整条路线焊死**——尤其任务早期,一个错误的 observed 否定会把整个任务带偏、且后续很难自己扳回来。
- report_finding:确认漏洞 → 记录(含 PoC,传 intent_id=你领到的意图id)。**只有你在本次运行里真实触发过该漏洞、拿到了可复现的证据(请求/响应或命令输出)才用它。** 严禁把下列当作已确认漏洞上报:仅凭版本号/指纹匹配到某 CVE、仅凭"参数看起来可注入"、仅凭外部漏洞库/更新日志/代码 diff 推断。**不要用查 CVE 库或"对比补丁版本"替代实际触发。** 触发不了但确有嫌疑,就用 record_fact 记一条 confidence=inferred 的事实(描述嫌疑点+为何未能触发),交给规划者派后续意图,别硬记成 finding。 - report_finding:确认漏洞 → 记录(含 PoC,传 intent_id=你领到的意图id)。**只有你在本次运行里真实触发过该漏洞、拿到了可复现的证据(请求/响应或命令输出)才用它。** 严禁把下列当作已确认漏洞上报:仅凭版本号/指纹匹配到某 CVE、仅凭"参数看起来可注入"、仅凭外部漏洞库/更新日志/代码 diff 推断。**不要用查 CVE 库或"对比补丁版本"替代实际触发。** 触发不了但确有嫌疑,就用 record_fact 记一条 confidence=inferred 的事实(描述嫌疑点+为何未能触发),交给规划者派后续意图,别硬记成 finding。
- list_assets(查询资产,非探索节点) / asset_neighbors / list_facts(探索事实,分页最新在前;默认 20 条,可传 q 关键词过滤、before 翻页) / list_findings(漏洞) / node_detail(探索节点 id,非资产 id):按需查上下文。 - list_assets(查询资产,非探索节点) / asset_neighbors / list_facts(探索事实,分页最新在前;默认 20 条,可传 q 关键词过滤、before 翻页) / list_findings(漏洞) / node_detail(探索节点 id,非资产 id):按需查上下文。
+21 -7
View File
@@ -144,6 +144,13 @@ type LLMRecord struct {
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
RequestBody string `json:"request_body,omitempty"` RequestBody string `json:"request_body,omitempty"`
ResponseBody string `json:"response_body,omitempty"` ResponseBody string `json:"response_body,omitempty"`
// RawRequest / RawResponse are the untouched HTTP bodies exchanged with the
// provider — the request as buildBody() sent it (full tool schemas included)
// and the raw SSE frames. RequestBody/ResponseBody above are the normalized
// view, which drops tool schemas and tool_use blocks entirely. Empty for
// records written before this was added, or when the call never reached HTTP.
RawRequest string `json:"raw_request,omitempty"`
RawResponse string `json:"raw_response,omitempty"`
} }
const llmRecordsSchema = ` const llmRecordsSchema = `
@@ -163,7 +170,9 @@ CREATE TABLE IF NOT EXISTS llm_records (
status TEXT, status TEXT,
error TEXT, error TEXT,
request_body TEXT, request_body TEXT,
response_body TEXT response_body TEXT,
raw_request TEXT,
raw_response TEXT
); );
CREATE INDEX IF NOT EXISTS idx_llm_records_ts ON llm_records(ts); CREATE INDEX IF NOT EXISTS idx_llm_records_ts ON llm_records(ts);
CREATE INDEX IF NOT EXISTS idx_llm_records_session ON llm_records(session_id); CREATE INDEX IF NOT EXISTS idx_llm_records_session ON llm_records(session_id);
@@ -174,6 +183,8 @@ const llmRecordsMigrate = `
ALTER TABLE llm_records ADD COLUMN IF NOT EXISTS task_id TEXT; ALTER TABLE llm_records ADD COLUMN IF NOT EXISTS task_id TEXT;
ALTER TABLE llm_records ADD COLUMN IF NOT EXISTS worker TEXT; ALTER TABLE llm_records ADD COLUMN IF NOT EXISTS worker TEXT;
ALTER TABLE llm_records ADD COLUMN IF NOT EXISTS profile_name TEXT; ALTER TABLE llm_records ADD COLUMN IF NOT EXISTS profile_name TEXT;
ALTER TABLE llm_records ADD COLUMN IF NOT EXISTS raw_request TEXT;
ALTER TABLE llm_records ADD COLUMN IF NOT EXISTS raw_response TEXT;
` `
// EnsureLLMRecordsTable creates the llm_records table if it does not exist. // EnsureLLMRecordsTable creates the llm_records table if it does not exist.
@@ -188,11 +199,12 @@ func (d *DB) EnsureLLMRecordsTable() error {
// InsertLLMRecord stores one LLM call record. // InsertLLMRecord stores one LLM call record.
func (d *DB) InsertLLMRecord(r *LLMRecord) error { func (d *DB) InsertLLMRecord(r *LLMRecord) error {
_, err := d.Exec(` _, err := d.Exec(`
INSERT INTO llm_records(model, profile_name, session_id, task_id, worker, latency_ms, input_tokens, output_tokens, cache_read, cache_write, status, error, request_body, response_body) INSERT INTO llm_records(model, profile_name, session_id, task_id, worker, latency_ms, input_tokens, output_tokens, cache_read, cache_write, status, error, request_body, response_body, raw_request, raw_response)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`, VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)`,
r.Model, nullIfEmpty(r.ProfileName), r.SessionID, nullIfEmpty(r.TaskID), nullIfEmpty(r.Worker), r.Model, nullIfEmpty(r.ProfileName), r.SessionID, nullIfEmpty(r.TaskID), nullIfEmpty(r.Worker),
r.LatencyMs, r.InputTokens, r.OutputTokens, r.CacheRead, r.CacheWrite, r.LatencyMs, r.InputTokens, r.OutputTokens, r.CacheRead, r.CacheWrite,
r.Status, nullIfEmpty(r.Error), nullIfEmpty(r.RequestBody), nullIfEmpty(r.ResponseBody)) r.Status, nullIfEmpty(r.Error), nullIfEmpty(r.RequestBody), nullIfEmpty(r.ResponseBody),
nullIfEmpty(r.RawRequest), nullIfEmpty(r.RawResponse))
return err return err
} }
@@ -305,19 +317,21 @@ func (d *DB) DeleteLLMRecords(task string) (int64, error) {
// GetLLMRecord returns a single LLM record with full request/response bodies. // GetLLMRecord returns a single LLM record with full request/response bodies.
func (d *DB) GetLLMRecord(id int64) (*LLMRecord, error) { func (d *DB) GetLLMRecord(id int64) (*LLMRecord, error) {
var r LLMRecord var r LLMRecord
var reqBody, respBody sql.NullString var reqBody, respBody, rawReq, rawResp sql.NullString
err := d.QueryRow(`SELECT id, ts, COALESCE(model,''), COALESCE(profile_name,''), COALESCE(session_id,''), COALESCE(task_id,''), COALESCE(worker,''), err := d.QueryRow(`SELECT id, ts, COALESCE(model,''), COALESCE(profile_name,''), COALESCE(session_id,''), COALESCE(task_id,''), COALESCE(worker,''),
COALESCE(latency_ms,0), COALESCE(input_tokens,0), COALESCE(output_tokens,0), COALESCE(cache_read,0), COALESCE(cache_write,0), COALESCE(latency_ms,0), COALESCE(input_tokens,0), COALESCE(output_tokens,0), COALESCE(cache_read,0), COALESCE(cache_write,0),
COALESCE(status,''), COALESCE(error,''), request_body, response_body COALESCE(status,''), COALESCE(error,''), request_body, response_body, raw_request, raw_response
FROM llm_records WHERE id=$1`, id). FROM llm_records WHERE id=$1`, id).
Scan(&r.ID, &r.Ts, &r.Model, &r.ProfileName, &r.SessionID, &r.TaskID, &r.Worker, &r.LatencyMs, Scan(&r.ID, &r.Ts, &r.Model, &r.ProfileName, &r.SessionID, &r.TaskID, &r.Worker, &r.LatencyMs,
&r.InputTokens, &r.OutputTokens, &r.CacheRead, &r.CacheWrite, &r.Status, &r.Error, &r.InputTokens, &r.OutputTokens, &r.CacheRead, &r.CacheWrite, &r.Status, &r.Error,
&reqBody, &respBody) &reqBody, &respBody, &rawReq, &rawResp)
if err != nil { if err != nil {
return nil, err return nil, err
} }
r.RequestBody = reqBody.String r.RequestBody = reqBody.String
r.ResponseBody = respBody.String r.ResponseBody = respBody.String
r.RawRequest = rawReq.String
r.RawResponse = rawResp.String
return &r, nil return &r, nil
} }
+62
View File
@@ -0,0 +1,62 @@
package db
import (
"testing"
)
// The raw-body columns were added after release, so existing installs only get
// them through llmRecordsMigrate. This runs the real migration against the dev
// PG on a table stripped back to its pre-upgrade shape, inside a transaction
// that is always rolled back — PG does transactional DDL, so nothing persists.
func TestLLMRecordsMigrateAddsRawColumnsToOldTable(t *testing.T) {
d, err := Open(testDSN(t))
if err != nil {
t.Skipf("postgres unavailable (%v) — skipping", err)
}
defer d.Close()
if err := d.EnsureLLMRecordsTable(); err != nil {
t.Fatalf("ensure: %v", err)
}
tx, err := d.Begin()
if err != nil {
t.Fatalf("begin: %v", err)
}
defer tx.Rollback() //nolint:errcheck // the test never commits
// Roll the table back to how a pre-upgrade install looks.
if _, err := tx.Exec(`ALTER TABLE llm_records DROP COLUMN IF EXISTS raw_request, DROP COLUMN IF EXISTS raw_response`); err != nil {
t.Fatalf("simulate old table: %v", err)
}
if _, err := tx.Exec(llmRecordsMigrate); err != nil {
t.Fatalf("migrate: %v", err)
}
for _, col := range []string{"raw_request", "raw_response"} {
var n int
if err := tx.QueryRow(
`SELECT count(*) FROM information_schema.columns
WHERE table_name='llm_records' AND column_name=$1`, col).Scan(&n); err != nil {
t.Fatalf("inspect %s: %v", col, err)
}
if n != 1 {
t.Errorf("column %s missing after migrate", col)
}
}
// Re-running must stay a no-op (the migration runs on every startup).
if _, err := tx.Exec(llmRecordsMigrate); err != nil {
t.Fatalf("migrate is not idempotent: %v", err)
}
// An insert carrying raw bodies must round-trip through the migrated table.
var got string
if err := tx.QueryRow(
`INSERT INTO llm_records(model, raw_request, raw_response) VALUES ('m','{"a":1}','data: x')
RETURNING raw_response`).Scan(&got); err != nil {
t.Fatalf("insert into migrated table: %v", err)
}
if got != "data: x" {
t.Errorf("raw_response=%q want %q", got, "data: x")
}
}
+1 -1
View File
@@ -291,7 +291,7 @@ CREATE TABLE IF NOT EXISTS settings (
CREATE TABLE IF NOT EXISTS llm_profiles ( CREATE TABLE IF NOT EXISTS llm_profiles (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE, name TEXT NOT NULL UNIQUE,
format TEXT NOT NULL CHECK (format IN ('openai','anthropic')), format TEXT NOT NULL CHECK (format IN ('openai','anthropic','openai-responses')),
base_url TEXT, base_url TEXT,
proxy TEXT, proxy TEXT,
model TEXT NOT NULL, model TEXT NOT NULL,
+140
View File
@@ -0,0 +1,140 @@
package llmrec
import (
"context"
"fmt"
"io"
"strings"
"sync"
)
type captureContextKey struct{}
// Capture collects the untouched wire bodies of one logical LLM call. The
// Recorder creates it and puts it on the context; the HTTP transport that norma
// dials through (agent.quotaAwareTransport) finds it there and fills it in.
//
// This exists because everything the Recorder itself sees is already normalized:
// llm.CompletionRequest is re-serialized rather than the body buildBody() sent,
// and the response arrives as decoded StreamEvents, not the SSE frames. For
// debugging a live provider, the bytes on the wire are the only ground truth.
//
// norma's doStream retries the request-establishment phase, so one Stream can
// issue several HTTP attempts. Each attempt is kept: the discarded ones (see
// norma/llm/retry.go, which closes non-final bodies unread) are exactly what
// makes rate-limit and gateway failures diagnosable.
//
// The transport writes from norma's stream-reading goroutine while the Recorder
// snapshots at stream end, so all state is mutex-guarded.
type Capture struct {
mu sync.Mutex
request string
attempts []*attempt
}
type attempt struct {
status int
body strings.Builder
}
// NewCapture returns a context carrying a fresh Capture, plus the Capture itself.
func NewCapture(ctx context.Context) (context.Context, *Capture) {
c := &Capture{}
return context.WithValue(ctx, captureContextKey{}, c), c
}
// CaptureFrom returns the Capture attached to ctx, or nil when raw capture is
// off. Callers must tolerate nil — recording is a toggle, and non-recorded
// providers dial through the same transport.
func CaptureFrom(ctx context.Context) *Capture {
if ctx == nil {
return nil
}
c, _ := ctx.Value(captureContextKey{}).(*Capture)
return c
}
// SetRequest stores the outgoing request body. Retries re-send identical bytes,
// so only the first attempt's body is kept.
func (c *Capture) SetRequest(body string) {
if c == nil || body == "" {
return
}
c.mu.Lock()
defer c.mu.Unlock()
if c.request == "" {
c.request = body
}
}
// TeeResponse opens a new attempt and wraps rc so everything read from it is
// mirrored into that attempt. It tees rather than reads because a successful
// response is an SSE stream that must keep streaming to the caller.
func (c *Capture) TeeResponse(status int, rc io.ReadCloser) io.ReadCloser {
if c == nil || rc == nil {
return rc
}
a := &attempt{status: status}
c.mu.Lock()
c.attempts = append(c.attempts, a)
c.mu.Unlock()
return &teeBody{rc: rc, c: c, a: a}
}
// RawRequest returns the request body as sent, or "" if nothing was captured.
func (c *Capture) RawRequest() string {
if c == nil {
return ""
}
c.mu.Lock()
defer c.mu.Unlock()
return c.request
}
// RawResponse returns the response bytes as received. A single attempt yields
// the untouched original (copy-pasteable straight into a replay); multiple
// attempts are concatenated behind per-attempt header lines so a retry sequence
// stays readable.
func (c *Capture) RawResponse() string {
if c == nil {
return ""
}
c.mu.Lock()
defer c.mu.Unlock()
switch len(c.attempts) {
case 0:
return ""
case 1:
return c.attempts[0].body.String()
}
var b strings.Builder
for i, a := range c.attempts {
fmt.Fprintf(&b, "===== attempt %d/%d — HTTP %d =====\n", i+1, len(c.attempts), a.status)
body := a.body.String()
b.WriteString(body)
if !strings.HasSuffix(body, "\n") {
b.WriteByte('\n')
}
}
return b.String()
}
// teeBody mirrors reads into a Capture attempt, guarded by the Capture's mutex
// so a snapshot taken mid-stream never races the writer.
type teeBody struct {
rc io.ReadCloser
c *Capture
a *attempt
}
func (t *teeBody) Read(p []byte) (int, error) {
n, err := t.rc.Read(p)
if n > 0 {
t.c.mu.Lock()
t.a.body.Write(p[:n])
t.c.mu.Unlock()
}
return n, err
}
func (t *teeBody) Close() error { return t.rc.Close() }
+103
View File
@@ -0,0 +1,103 @@
package llmrec
import (
"context"
"io"
"strings"
"testing"
)
func TestCaptureFromNilAndMissing(t *testing.T) {
var nilCtx context.Context // the transport reaches here on any unrecorded call
if CaptureFrom(nilCtx) != nil {
t.Fatal("nil context should yield no capture")
}
if CaptureFrom(context.Background()) != nil {
t.Fatal("bare context should yield no capture")
}
}
// A nil *Capture is the "recording off" path: the transport calls these on every
// request, so they must all be no-ops rather than panics.
func TestNilCaptureIsInert(t *testing.T) {
var c *Capture
c.SetRequest("body")
if got := c.RawRequest(); got != "" {
t.Fatalf("RawRequest()=%q want empty", got)
}
if got := c.RawResponse(); got != "" {
t.Fatalf("RawResponse()=%q want empty", got)
}
rc := io.NopCloser(strings.NewReader("x"))
if c.TeeResponse(200, rc) != rc {
t.Fatal("nil capture must pass the body through untouched")
}
}
func TestCaptureSingleAttemptKeepsBytesVerbatim(t *testing.T) {
ctx, c := NewCapture(context.Background())
if CaptureFrom(ctx) != c {
t.Fatal("capture not retrievable from its own context")
}
c.SetRequest(`{"model":"x"}`)
// Retries re-send identical bytes; only the first is kept.
c.SetRequest(`{"model":"ignored"}`)
const sse = "event: message_start\ndata: {\"type\":\"message_start\"}\n\nevent: message_stop\ndata: {}\n\n"
body := c.TeeResponse(200, io.NopCloser(strings.NewReader(sse)))
got, err := io.ReadAll(body)
if err != nil {
t.Fatalf("read: %v", err)
}
if string(got) != sse {
t.Fatal("tee altered the stream delivered to the caller")
}
if err := body.Close(); err != nil {
t.Fatalf("close: %v", err)
}
if c.RawRequest() != `{"model":"x"}` {
t.Fatalf("RawRequest()=%q", c.RawRequest())
}
// A lone attempt must stay byte-identical — no headers, no framing added, so
// it can be replayed as-is.
if c.RawResponse() != sse {
t.Fatalf("RawResponse()=%q want verbatim SSE", c.RawResponse())
}
}
// norma's doStream discards the bodies of retried attempts (retry.go closes them
// unread), so the capture is the only place a 429 body survives.
func TestCaptureRetriedAttemptsAreAllKept(t *testing.T) {
_, c := NewCapture(context.Background())
first := c.TeeResponse(429, io.NopCloser(strings.NewReader(`{"error":"rate_limit"}`)))
if _, err := io.ReadAll(first); err != nil {
t.Fatalf("read first: %v", err)
}
second := c.TeeResponse(200, io.NopCloser(strings.NewReader("data: ok\n")))
if _, err := io.ReadAll(second); err != nil {
t.Fatalf("read second: %v", err)
}
raw := c.RawResponse()
if !strings.Contains(raw, `{"error":"rate_limit"}`) {
t.Fatalf("dropped the retried attempt body: %q", raw)
}
if !strings.Contains(raw, "data: ok") {
t.Fatalf("dropped the final attempt body: %q", raw)
}
if !strings.Contains(raw, "attempt 1/2 — HTTP 429") || !strings.Contains(raw, "attempt 2/2 — HTTP 200") {
t.Fatalf("attempts not delimited: %q", raw)
}
if strings.Index(raw, "rate_limit") > strings.Index(raw, "data: ok") {
t.Fatal("attempts stored out of order")
}
}
func TestCaptureEmptyWhenNoHTTPHappened(t *testing.T) {
_, c := NewCapture(context.Background())
if c.RawRequest() != "" || c.RawResponse() != "" {
t.Fatal("unused capture should be empty")
}
}
+27 -13
View File
@@ -110,9 +110,15 @@ func (r *Recorder) Stream(ctx context.Context, req llm.CompletionRequest) iter.S
} }
// Serialize the request only when storing bodies (this is the expensive part). // Serialize the request only when storing bodies (this is the expensive part).
// Alongside it, attach a Capture so the HTTP transport can hand back the
// untouched wire bodies — the normalized view below cannot reconstruct them
// (tool schemas are dropped, tool_use blocks never reach this layer, and the
// SSE framing is already decoded). See capture.go.
reqBody := "" reqBody := ""
var capt *Capture
if recordBodies { if recordBodies {
reqBody = r.serializeRequest(req) reqBody = r.serializeRequest(req)
ctx, capt = NewCapture(ctx)
} }
return func(yield func(llm.StreamEvent, error) bool) { return func(yield func(llm.StreamEvent, error) bool) {
@@ -133,7 +139,7 @@ func (r *Recorder) Stream(ctx context.Context, req llm.CompletionRequest) iter.S
r.recordUsage(taskID, expID, worker, usage, int(time.Since(start).Milliseconds()), status) r.recordUsage(taskID, expID, worker, usage, int(time.Since(start).Milliseconds()), status)
// Heavy trace row — only when body recording is on. // Heavy trace row — only when body recording is on.
if recordBodies { if recordBodies {
r.record(req, session, taskID, worker, reqBody, start, textBuf.String(), thinkingBuf.String(), usage, stopReason, err) r.record(req, session, taskID, worker, reqBody, capt, start, textBuf.String(), thinkingBuf.String(), usage, stopReason, err)
} }
} }
@@ -180,7 +186,7 @@ func (r *Recorder) Stream(ctx context.Context, req llm.CompletionRequest) iter.S
} }
// Complete implements the atomic completion path while preserving the same // Complete implements the atomic completion path while preserving the same
// usage metering and optional body recording semantics as Stream. // usage metering, normalized body recording and raw wire capture as Stream.
func (r *Recorder) Complete(ctx context.Context, req llm.CompletionRequest) (llm.Message, string, llm.Usage, error) { func (r *Recorder) Complete(ctx context.Context, req llm.CompletionRequest) (llm.Message, string, llm.Usage, error) {
recordBodies := r.enabled == nil || r.enabled() recordBodies := r.enabled == nil || r.enabled()
start := time.Now() start := time.Now()
@@ -193,8 +199,10 @@ func (r *Recorder) Complete(ctx context.Context, req llm.CompletionRequest) (llm
} }
reqBody := "" reqBody := ""
var capt *Capture
if recordBodies { if recordBodies {
reqBody = r.serializeRequest(req) reqBody = r.serializeRequest(req)
ctx, capt = NewCapture(ctx)
} }
msg, stopReason, usage, err := r.inner.Complete(ctx, req) msg, stopReason, usage, err := r.inner.Complete(ctx, req)
@@ -204,20 +212,21 @@ func (r *Recorder) Complete(ctx context.Context, req llm.CompletionRequest) (llm
} }
r.recordUsage(taskID, expID, worker, usage, int(time.Since(start).Milliseconds()), status) r.recordUsage(taskID, expID, worker, usage, int(time.Since(start).Milliseconds()), status)
if recordBodies { if recordBodies {
var textBuf, thinkingBuf strings.Builder r.record(req, session, taskID, worker, reqBody, capt, start, msg.Text(), thinkingText(msg), usage, stopReason, err)
for _, block := range msg.Content {
switch block.Type {
case llm.BlockText:
textBuf.WriteString(block.Text)
case llm.BlockThinking:
thinkingBuf.WriteString(block.Thinking)
}
}
r.record(req, session, taskID, worker, reqBody, start, textBuf.String(), thinkingBuf.String(), usage, stopReason, err)
} }
return msg, stopReason, usage, err return msg, stopReason, usage, err
} }
func thinkingText(msg llm.Message) string {
var b strings.Builder
for _, block := range msg.Content {
if block.Type == llm.BlockThinking && block.Thinking != "" {
b.WriteString(block.Thinking)
}
}
return b.String()
}
// recordUsage appends one lightweight metering row to llm_usage (no bodies). Skips // recordUsage appends one lightweight metering row to llm_usage (no bodies). Skips
// zero-token calls with no model, which carry nothing worth metering. // zero-token calls with no model, which carry nothing worth metering.
func (r *Recorder) recordUsage(taskID string, expID int64, worker string, usage llm.Usage, latencyMs int, status string) { func (r *Recorder) recordUsage(taskID string, expID int64, worker string, usage llm.Usage, latencyMs int, status string) {
@@ -249,7 +258,7 @@ func (r *Recorder) recordUsage(taskID string, expID int64, worker string, usage
// record persists one LLM call to PostgreSQL before the provider stream returns. // record persists one LLM call to PostgreSQL before the provider stream returns.
// Keeping the write inside the owning task operation means task deletion can // Keeping the write inside the owning task operation means task deletion can
// drain calls and then remove records without a late async insert recreating one. // drain calls and then remove records without a late async insert recreating one.
func (r *Recorder) record(req llm.CompletionRequest, session, taskID, worker, reqBody string, start time.Time, text, thinking string, usage llm.Usage, stopReason string, streamErr error) { func (r *Recorder) record(req llm.CompletionRequest, session, taskID, worker, reqBody string, capt *Capture, start time.Time, text, thinking string, usage llm.Usage, stopReason string, streamErr error) {
latency := int(time.Since(start).Milliseconds()) latency := int(time.Since(start).Milliseconds())
status := "ok" status := "ok"
errMsg := "" errMsg := ""
@@ -295,6 +304,11 @@ func (r *Recorder) record(req llm.CompletionRequest, session, taskID, worker, re
Error: errMsg, Error: errMsg,
RequestBody: reqBody, RequestBody: reqBody,
ResponseBody: string(respBody), ResponseBody: string(respBody),
// Raw wire bodies; empty when the transport did not fill the Capture
// (e.g. a provider dialing through a client without the capture hook, or
// a call that failed before any HTTP request went out).
RawRequest: capt.RawRequest(),
RawResponse: capt.RawResponse(),
} }
if err := r.pg.InsertLLMRecord(rec); err != nil { if err := r.pg.InsertLLMRecord(rec); err != nil {
+235
View File
@@ -0,0 +1,235 @@
#!/usr/bin/env bash
# =============================================================================
# ARTEX 管理员密码重置脚本
#
# 登录用户名固定为 ARTEX;密码以 bcrypt 哈希存放在数据库 settings 表的
# auth.password_hash 键。本脚本连上数据库后,用 pgcrypto 在库内生成 bcrypt 哈希
# 并写回该键——与后端登录校验(golang.org/x/crypto/bcrypt)完全兼容。
#
# 两种部署:
# local (默认)—— 宿主机直接用 psql 连数据库。连接信息按以下优先级获取:
# 命令行参数 > --dsn/$ARTEX_PG_DSN > config.json 的 database.*
# docker —— 通过 `docker compose exec`(或 `docker exec`)在 postgres
# 容器内执行 psql(compose 默认不对宿主暴露 5432,故走容器内)。
#
# 用法示例:
# ./reset-password.sh # 本地,自动读 config.json/环境,交互输入新密码
# ./reset-password.sh -p 'NewPass!' # 本地,直接给定新密码
# ./reset-password.sh --dsn postgres://u:p@h:5432/artex
# ./reset-password.sh -H 127.0.0.1 -P 5433 -U autopentest -W pass -d artex
# ./reset-password.sh -m docker # docker 部署(读 .env 的 POSTGRES_*)
# ./reset-password.sh -m docker -c pg容器名 --exec docker
#
# 安全:新密码经环境变量 + psql \getenv 传入(不进入进程 argv),并用 :'var'
# 自动转义(防 SQL 注入);数据库密码经 PGPASSWORD 传递,同样不进 argv。
# =============================================================================
set -euo pipefail
PASS_KEY="auth.password_hash"
BCRYPT_COST=10
MODE="" # local | docker(空=自动判定)
DSN=""
HOST="" PORT="" USER="" DBPASS="" DBNAME="" SSLMODE=""
CONFIG=""
CONTAINER="" # docker 模式的 postgres 服务/容器名(默认 postgres)
EXEC_KIND="" # compose | docker(docker 模式下用哪种 exec;空=自动)
NEWPASS=""
ASSUME_YES=0
die() { echo "错误:$*" >&2; exit 1; }
info() { echo "· $*" >&2; }
usage() { sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//'; exit 0; }
# ---- 参数解析 -------------------------------------------------------------
while [[ $# -gt 0 ]]; do
case "$1" in
-m|--mode) MODE="${2:-}"; shift 2 ;;
--dsn) DSN="${2:-}"; shift 2 ;;
-H|--host) HOST="${2:-}"; shift 2 ;;
-P|--port) PORT="${2:-}"; shift 2 ;;
-U|--user) USER="${2:-}"; shift 2 ;;
-W|--db-password) DBPASS="${2:-}"; shift 2 ;;
-d|--dbname) DBNAME="${2:-}"; shift 2 ;;
--sslmode) SSLMODE="${2:-}"; shift 2 ;;
--config) CONFIG="${2:-}"; shift 2 ;;
-c|--container) CONTAINER="${2:-}"; shift 2 ;;
--exec) EXEC_KIND="${2:-}"; shift 2 ;;
-p|--new-password) NEWPASS="${2:-}"; shift 2 ;;
-y|--yes) ASSUME_YES=1; shift ;;
-h|--help) usage ;;
*) die "未知参数:$1(-h 查看用法)" ;;
esac
done
# ---- 从 config.json 读取 database.*(仅 local 模式、且未显式给出连接时)-----
# 优先用 python3 解析(健壮);缺 python3 时退回 grep(config.json 为规整分字段)。
read_config_json() {
local path="$1"
[[ -f "$path" ]] || return 1
if command -v python3 >/dev/null 2>&1; then
python3 - "$path" <<'PY'
import json, sys
try:
d = json.load(open(sys.argv[1])).get("database", {})
except Exception:
sys.exit(1)
# 支持直接给 dsn,或分字段
if d.get("dsn"):
print("DSN\t" + d["dsn"]); sys.exit(0)
for k in ("host","port","user","password","dbname","sslmode"):
if d.get(k) is not None:
print(k.upper() + "\t" + str(d[k]))
PY
else
# 极简后备:逐键 grep(值为字符串或数字)
local k
for k in host port user password dbname sslmode; do
local v
v=$(grep -oE "\"$k\"[[:space:]]*:[[:space:]]*(\"[^\"]*\"|[0-9]+)" "$path" 2>/dev/null \
| head -1 | sed -E "s/.*:[[:space:]]*//; s/^\"//; s/\"$//") || true
[[ -n "$v" ]] && echo -e "${k^^}\t$v"
done
fi
}
apply_config_fields() {
local line key val
while IFS=$'\t' read -r key val; do
[[ -z "$key" ]] && continue
case "$key" in
DSN) [[ -z "$DSN" ]] && DSN="$val" ;;
HOST) [[ -z "$HOST" ]] && HOST="$val" ;;
PORT) [[ -z "$PORT" ]] && PORT="$val" ;;
USER) [[ -z "$USER" ]] && USER="$val" ;;
PASSWORD) [[ -z "$DBPASS" ]] && DBPASS="$val" ;;
DBNAME) [[ -z "$DBNAME" ]] && DBNAME="$val" ;;
SSLMODE) [[ -z "$SSLMODE" ]] && SSLMODE="$val" ;;
esac
done
}
# ---- 自动判定模式 ---------------------------------------------------------
if [[ -z "$MODE" ]]; then
if [[ -n "$DSN$HOST$USER$DBNAME" || -n "${ARTEX_PG_DSN:-}" || -f "${CONFIG:-config.json}" ]]; then
MODE="local"
elif command -v docker >/dev/null 2>&1 && [[ -f docker-compose.yml ]]; then
MODE="docker"
else
MODE="local"
fi
fi
info "部署模式:$MODE"
# ---- 采集新密码 -----------------------------------------------------------
if [[ -z "$NEWPASS" ]]; then
read -r -s -p "输入新密码(用户名固定为 ARTEX):" NEWPASS; echo >&2
[[ -n "$NEWPASS" ]] || die "密码不能为空"
read -r -s -p "再次输入以确认:" NEWPASS2; echo >&2
[[ "$NEWPASS" == "$NEWPASS2" ]] || die "两次输入不一致"
fi
[[ -n "$NEWPASS" ]] || die "密码不能为空"
# 通过环境变量把密码交给 psql(\getenv 读取,不进入 argv/ps)
export ARTEX_RESET_NEWPASS="$NEWPASS"
# 库内生成 bcrypt 并 upsert;密码用 :'newpw' 自动转义。CREATE EXTENSION 幂等,
# 若数据库角色无建扩展权限会在此报错(提示见下方 run 的失败分支)。
SQL=$(cat <<SQL
\\set ON_ERROR_STOP on
\\getenv newpw ARTEX_RESET_NEWPASS
CREATE EXTENSION IF NOT EXISTS pgcrypto;
INSERT INTO settings(key, value)
VALUES ('$PASS_KEY', crypt(:'newpw', gen_salt('bf', $BCRYPT_COST)))
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now();
SQL
)
# ---- 执行 -----------------------------------------------------------------
if [[ "$MODE" == "local" ]]; then
# 连接信息优先级:命令行 > --dsn/$ARTEX_PG_DSN > config.json
if [[ -z "$DSN" && -z "$HOST$USER$DBNAME" ]]; then
[[ -n "${ARTEX_PG_DSN:-}" ]] && DSN="$ARTEX_PG_DSN"
fi
if [[ -z "$DSN" && -z "$HOST$USER$DBNAME" ]]; then
cfg="${CONFIG:-config.json}"
if [[ -f "$cfg" ]]; then
info "从 $cfg 读取数据库配置"
apply_config_fields < <(read_config_json "$cfg")
fi
fi
command -v psql >/dev/null 2>&1 || die "本机未找到 psql(请安装 postgresql-client,或改用 -m docker)"
declare -a PSQL_ARGS=()
if [[ -n "$DSN" ]]; then
PSQL_ARGS=("$DSN")
target="$DSN"
else
[[ -n "$USER" ]] || die "缺少数据库用户(-U)或有效的 config.json/DSN"
[[ -n "$DBNAME" ]] || die "缺少数据库名(-d)或有效的 config.json/DSN"
HOST="${HOST:-127.0.0.1}"; PORT="${PORT:-5432}"; SSLMODE="${SSLMODE:-disable}"
PSQL_ARGS=(-h "$HOST" -p "$PORT" -U "$USER" -d "$DBNAME")
[[ -n "$SSLMODE" ]] && export PGSSLMODE="$SSLMODE"
[[ -n "$DBPASS" ]] && export PGPASSWORD="$DBPASS"
target="$USER@$HOST:$PORT/$DBNAME"
fi
info "目标数据库:$target"
if [[ "$ASSUME_YES" -ne 1 ]]; then
read -r -p "确认在该库重置 ARTEX 密码?[y/N] " ans
[[ "$ans" == "y" || "$ans" == "Y" ]] || die "已取消"
fi
if ! printf '%s\n' "$SQL" | psql "${PSQL_ARGS[@]}" -v ON_ERROR_STOP=1 -q >/dev/null; then
die "写入失败。若报 pgcrypto 权限/缺失,请用具备建扩展权限的角色,或先手动执行 CREATE EXTENSION pgcrypto。"
fi
else
# ---- docker ----
command -v docker >/dev/null 2>&1 || die "未找到 docker"
CONTAINER="${CONTAINER:-postgres}"
# 选择 exec 方式:优先 docker compose exec(服务名),否则 docker exec(容器名)
if [[ -z "$EXEC_KIND" ]]; then
if docker compose version >/dev/null 2>&1 && [[ -f docker-compose.yml ]]; then
EXEC_KIND="compose"
else
EXEC_KIND="docker"
fi
fi
# 容器内的 psql 凭据:优先命令行,其次 .env 的 POSTGRES_*,再退回 compose 默认(artex)
if [[ -f .env ]]; then
# shellcheck disable=SC1091
set -a; . ./.env; set +a
fi
DUSER="${USER:-${POSTGRES_USER:-artex}}"
DNAME="${DBNAME:-${POSTGRES_DB:-artex}}"
[[ -n "$DBPASS" ]] && export PGPASSWORD="$DBPASS"
[[ -z "${PGPASSWORD:-}" && -n "${POSTGRES_PASSWORD:-}" ]] && export PGPASSWORD="$POSTGRES_PASSWORD"
info "目标:容器 $CONTAINER 内 psql -U $DUSER -d $DNAME(exec=$EXEC_KIND)"
if [[ "$ASSUME_YES" -ne 1 ]]; then
read -r -p "确认在该容器数据库重置 ARTEX 密码?[y/N] " ans
[[ "$ans" == "y" || "$ans" == "Y" ]] || die "已取消"
fi
# -e 只带名字不带值 → 从当前环境继承,密码不出现在 docker 命令 argv 里。
declare -a EXEC_CMD
if [[ "$EXEC_KIND" == "compose" ]]; then
EXEC_CMD=(docker compose exec -T -e ARTEX_RESET_NEWPASS -e PGPASSWORD "$CONTAINER"
psql -U "$DUSER" -d "$DNAME" -v ON_ERROR_STOP=1 -q)
else
EXEC_CMD=(docker exec -i -e ARTEX_RESET_NEWPASS -e PGPASSWORD "$CONTAINER"
psql -U "$DUSER" -d "$DNAME" -v ON_ERROR_STOP=1 -q)
fi
if ! printf '%s\n' "$SQL" | "${EXEC_CMD[@]}" >/dev/null; then
die "写入失败。请确认容器名(-c)、数据库账号(.env 的 POSTGRES_*),以及角色有 pgcrypto 权限。"
fi
fi
unset ARTEX_RESET_NEWPASS
echo "✓ 已重置 ARTEX 管理员密码。请用用户名 ARTEX + 新密码登录(无需重启服务)。"
+133
View File
@@ -0,0 +1,133 @@
package server
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Autumn-27/artex/agent"
"github.com/Autumn-27/artex/llmrec"
"github.com/Autumn-27/norma/llm"
"github.com/Autumn-27/norma/transcript"
)
// Full chain against a real PG: Recorder → norma provider → capturing transport
// → llm_records. The unit tests cover each hop; this proves the raw bodies
// actually survive all the way into the row an operator opens when debugging.
func TestRecorderPersistsRawWireBodies(t *testing.T) {
m, err := NewManager(t.TempDir(), "")
if err != nil {
t.Skipf("postgres unavailable (%v) — skipping", err)
}
defer m.Close()
pg := m.PG()
if err := pg.EnsureLLMRecordsTable(); err != nil {
t.Fatalf("ensure table: %v", err)
}
sse := strings.Join([]string{
`event: message_start`,
`data: {"type":"message_start","message":{"id":"msg_raw","usage":{"input_tokens":5,"output_tokens":1}}}`,
``,
`event: content_block_delta`,
`data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"probing"}}`,
``,
// A tool_use block: the normalized response body drops these entirely, so
// its presence in raw_response is the whole point of the feature.
`event: content_block_start`,
`data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_raw","name":"bash"}}`,
``,
`event: content_block_delta`,
`data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"command\":\"id\"}"}}`,
``,
`event: message_delta`,
`data: {"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":9}}`,
``,
`event: message_stop`,
`data: {"type":"message_stop"}`,
``,
}, "\n")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("content-type", "text/event-stream")
_, _ = io.WriteString(w, sse)
}))
defer srv.Close()
cfg := agent.Config{Format: llm.FormatAnthropic, BaseURL: srv.URL, APIKey: "k", Model: "claude-raw-test"}
inner, err := cfg.NewProvider()
if err != nil {
t.Fatalf("NewProvider: %v", err)
}
const session = "exp999999-rawtest"
rec := llmrec.Wrap(inner, pg, cfg.Model, "raw-test-profile", "", "", func() bool { return true })
// Clean up whatever this test writes, whether or not it passes. Must be a
// defer registered after `defer m.Close()` (LIFO puts it first) — a
// t.Cleanup would run after the manager already closed the pool.
defer func() {
if _, err := pg.Exec(`DELETE FROM llm_records WHERE session_id = $1`, session); err != nil {
t.Logf("cleanup: %v", err)
}
}()
ctx := transcript.WithSessionID(context.Background(), session)
for _, err := range rec.Stream(ctx, llm.CompletionRequest{
System: []string{"system prompt"},
Messages: []llm.Message{{Role: "user", Content: []llm.ContentBlock{{Type: "text", Text: "go"}}}},
Tools: []llm.ToolSchema{{
Name: "bash",
Description: "run a shell command",
InputSchema: map[string]any{"type": "object"},
}},
MaxTokens: 512,
}) {
if err != nil {
t.Fatalf("stream: %v", err)
}
}
var id int64
if err := pg.QueryRow(`SELECT id FROM llm_records WHERE session_id=$1 ORDER BY id DESC LIMIT 1`, session).Scan(&id); err != nil {
t.Fatalf("no record written: %v", err)
}
got, err := pg.GetLLMRecord(id)
if err != nil {
t.Fatalf("GetLLMRecord: %v", err)
}
if got.RawResponse != sse {
t.Errorf("raw_response is not the wire bytes:\n got: %q\nwant: %q", got.RawResponse, sse)
}
// The tool call is absent from the normalized body but present in the raw one
// — the specific gap this feature closes.
if strings.Contains(got.ResponseBody, "toolu_raw") {
t.Error("normalized body unexpectedly contains the tool_use block; test no longer proves the gap")
}
if !strings.Contains(got.RawResponse, "toolu_raw") {
t.Error("raw_response lost the tool_use block")
}
var body map[string]any
if err := json.Unmarshal([]byte(got.RawRequest), &body); err != nil {
t.Fatalf("raw_request is not valid JSON: %v\n%s", err, got.RawRequest)
}
if body["model"] != "claude-raw-test" {
t.Errorf("raw_request model=%v", body["model"])
}
tools, _ := body["tools"].([]any)
if len(tools) != 1 {
t.Fatalf("raw_request tools=%v", body["tools"])
}
if tool, _ := tools[0].(map[string]any); tool["input_schema"] == nil {
t.Errorf("raw_request lost the tool schema: %v", tool)
}
// The normalized request keeps tool names only, so the schema is unique to raw.
if strings.Contains(got.RequestBody, "input_schema") {
t.Error("normalized request unexpectedly carries schemas; test no longer proves the gap")
}
}
+60
View File
@@ -573,6 +573,66 @@ func (s *Server) reseedMainAgentPrompt() {
log.Printf("[prompts] mainagent 提示词已追加新默认版本(加入目标达成后反问建目标,一次性)") log.Printf("[prompts] mainagent 提示词已追加新默认版本(加入目标达成后反问建目标,一次性)")
} }
// reseedPlannerPrompt 把 planner 提示词刷成【当前代码默认】——默认正文重写了「0 意图」的正当理由
// (从笼统"最常见"改为"仅在已覆盖/等待在跑 work 依赖时才 0 意图")、并新增「量化验收核对」(覆盖度等
// 可量化目标未达标禁止 prove_goal)。SeedPromptIfEmpty 首插入only,旧库已有版本收不到,故用版本管理
// 【追加一个新版本】并切过去(ResetPromptToDefault),旧版本仍保留在历史里,用户若自定义过可从版本记录
// 找回。settings flag 守卫 → 只做一次。全新库无需处理(SeedPromptIfEmpty 已 seed 最新默认)。与
// reseedGoalsPrompt 完全同构。
func (s *Server) reseedPlannerPrompt() {
const flag = "planner_prompt_zerointent_acceptance_v1"
if v, _, _ := s.m.pg.GetSetting(flag); v == "true" {
return
}
defer func() { _ = s.m.pg.SetSetting(flag, "true") }() // 无论成功与否只尝试一次
a, err := s.m.pg.GetAgentByKey("planner")
if err != nil || a == nil {
return // 全新库尚未建 agent 行时,seedPrompts 会直接 seed 最新默认,无需此迁移
}
tmpl := agent.BuiltinPromptSeeds()["planner"]
if tmpl == "" {
return
}
// 全新库 seedPrompts 已 seed 最新默认 → 当前版本已等于代码默认,不必再追加重复版本。
if cur, err := s.m.pg.CurrentPrompt(a.ID); err == nil && cur == tmpl {
return
}
if _, err := s.m.pg.ResetPromptToDefault(a.ID, tmpl); err != nil {
log.Printf("[prompts] planner 提示词重刷为新默认失败: %v", err)
return
}
log.Printf("[prompts] planner 提示词已追加新默认版本(重写0意图理由+加量化验收核对,一次性)")
}
// reseedWorkerPrompt 把 worker 提示词刷成【当前代码默认】——默认正文强化了「否定结论的证据门槛」
// (未穷尽手段/证据弱一律标 inferred,别用轻率 observed 否定焊死路线)。SeedPromptIfEmpty 首插入only,
// 旧库已有版本收不到,故用版本管理【追加一个新版本】并切过去,旧版本仍保留在历史里可找回。settings flag
// 守卫 → 只做一次。全新库无需处理。与 reseedGoalsPrompt 完全同构。
func (s *Server) reseedWorkerPrompt() {
const flag = "worker_prompt_negfact_threshold_v1"
if v, _, _ := s.m.pg.GetSetting(flag); v == "true" {
return
}
defer func() { _ = s.m.pg.SetSetting(flag, "true") }() // 无论成功与否只尝试一次
a, err := s.m.pg.GetAgentByKey("worker")
if err != nil || a == nil {
return // 全新库尚未建 agent 行时,seedPrompts 会直接 seed 最新默认,无需此迁移
}
tmpl := agent.BuiltinPromptSeeds()["worker"]
if tmpl == "" {
return
}
// 全新库 seedPrompts 已 seed 最新默认 → 当前版本已等于代码默认,不必再追加重复版本。
if cur, err := s.m.pg.CurrentPrompt(a.ID); err == nil && cur == tmpl {
return
}
if _, err := s.m.pg.ResetPromptToDefault(a.ID, tmpl); err != nil {
log.Printf("[prompts] worker 提示词重刷为新默认失败: %v", err)
return
}
log.Printf("[prompts] worker 提示词已追加新默认版本(加否定结论证据门槛,一次性)")
}
// seedReporterAgent 预置一个「报告撰写」自定义 agent(builtin=false,可在 UI 编辑/删除): // seedReporterAgent 预置一个「报告撰写」自定义 agent(builtin=false,可在 UI 编辑/删除):
// 绑定 update_finding_report + 任务查询工具,并挂一个「report_finding 被调用即触发」的 // 绑定 update_finding_report + 任务查询工具,并挂一个「report_finding 被调用即触发」的
// 触发器 —— 每登记一个漏洞就唤起它写详细报告。一次性(settings flag 守卫):用户删掉后不再重建。 // 触发器 —— 每登记一个漏洞就唤起它写详细报告。一次性(settings flag 守卫):用户删掉后不再重建。
+2 -3
View File
@@ -323,10 +323,9 @@ func (s *Server) loadLLMConfig() (agent.Config, bool) {
// saveLLMConfig persists the LLM config as the active "default" profile in PG. // saveLLMConfig persists the LLM config as the active "default" profile in PG.
func (s *Server) saveLLMConfig(cfg agent.Config) error { func (s *Server) saveLLMConfig(cfg agent.Config) error {
// cfg.Provider() already returns one of the three valid format strings
// (anthropic / openai / openai-responses), matching the DB CHECK constraint.
format := cfg.Provider() format := cfg.Provider()
if format != "anthropic" {
format = "openai"
}
var id int64 var id int64
// agent.Config carries no failover fields, so carry the stored ones forward — // agent.Config carries no failover fields, so carry the stored ones forward —
// otherwise this legacy endpoint would silently reset the profile's priority, // otherwise this legacy endpoint would silently reset the profile's priority,
+4 -2
View File
@@ -1926,11 +1926,13 @@ func (s *Server) pgListModels(w http.ResponseWriter, r *http.Request) {
var candidates []candidate var candidates []candidate
switch provider { switch provider {
case "openai": case "openai", "openai-responses":
// Responses API shares the OpenAI model list at /v1/models; tolerate a full
// endpoint URL of either format.
if baseURL == "" { if baseURL == "" {
baseURL = "https://api.openai.com/v1" baseURL = "https://api.openai.com/v1"
} }
b := strings.TrimRight(strings.TrimSuffix(baseURL, "/chat/completions"), "/") b := strings.TrimRight(strings.TrimSuffix(strings.TrimSuffix(baseURL, "/chat/completions"), "/responses"), "/")
candidates = append(candidates, candidate{b + "/models", bearerHdr()}) candidates = append(candidates, candidate{b + "/models", bearerHdr()})
default: // anthropic default: // anthropic
if baseURL == "" { if baseURL == "" {
+62 -25
View File
@@ -6,6 +6,7 @@ import {
ArrowUpIcon, ArrowUpIcon,
Bot, Bot,
ChevronDownIcon, ChevronDownIcon,
ListChecksIcon,
Loader2Icon, Loader2Icon,
MoreHorizontalIcon, MoreHorizontalIcon,
PaperclipIcon, PaperclipIcon,
@@ -763,6 +764,7 @@ const ConversationItem = React.memo(function ConversationItem({
onCancelRename, onCancelRename,
onTogglePinned, onTogglePinned,
onDelete, onDelete,
selectionMode,
selectedForDelete, selectedForDelete,
onSelectedForDeleteChange, onSelectedForDeleteChange,
}: { }: {
@@ -778,6 +780,7 @@ const ConversationItem = React.memo(function ConversationItem({
onCancelRename: () => void; onCancelRename: () => void;
onTogglePinned: (conversation: Conversation) => void; onTogglePinned: (conversation: Conversation) => void;
onDelete: (id: number) => void; onDelete: (id: number) => void;
selectionMode: boolean;
selectedForDelete: boolean; selectedForDelete: boolean;
onSelectedForDeleteChange: (id: number, checked: boolean) => void; onSelectedForDeleteChange: (id: number, checked: boolean) => void;
}) { }) {
@@ -800,12 +803,14 @@ const ConversationItem = React.memo(function ConversationItem({
active ? "bg-accent text-accent-foreground" : "hover:bg-accent/50", active ? "bg-accent text-accent-foreground" : "hover:bg-accent/50",
)} )}
> >
<Checkbox {selectionMode && (
checked={selectedForDelete} <Checkbox
onCheckedChange={(checked) => onSelectedForDeleteChange(conv.id, checked === true)} checked={selectedForDelete}
aria-label={`选择对话「${conv.title || "新对话"}」`} onCheckedChange={(checked) => onSelectedForDeleteChange(conv.id, checked === true)}
className="ml-1 shrink-0" aria-label={`选择对话「${conv.title || "新对话"}」`}
/> className="ml-1 shrink-0"
/>
)}
{renaming ? ( {renaming ? (
<input <input
ref={renameInputRef} ref={renameInputRef}
@@ -907,6 +912,10 @@ export default function ChatPage() {
const [renamingId, setRenamingId] = React.useState<number | null>(null); const [renamingId, setRenamingId] = React.useState<number | null>(null);
const [renameText, setRenameText] = React.useState(""); const [renameText, setRenameText] = React.useState("");
const [selectedConversationIds, setSelectedConversationIds] = React.useState<Set<number>>(() => new Set()); const [selectedConversationIds, setSelectedConversationIds] = React.useState<Set<number>>(() => new Set());
// selectionMode gates the multi-select UI: off by default (clean list, no
// checkboxes); the header "多选" button turns it on, "完成" turns it off and
// clears the selection.
const [selectionMode, setSelectionMode] = React.useState(false);
const [bulkDeleteOpen, setBulkDeleteOpen] = React.useState(false); const [bulkDeleteOpen, setBulkDeleteOpen] = React.useState(false);
const [bulkDeleting, setBulkDeleting] = React.useState(false); const [bulkDeleting, setBulkDeleting] = React.useState(false);
const [visibleConversationCount, setVisibleConversationCount] = React.useState(CONVERSATION_LIST_PAGE); const [visibleConversationCount, setVisibleConversationCount] = React.useState(CONVERSATION_LIST_PAGE);
@@ -998,6 +1007,11 @@ export default function ChatPage() {
setSelectedConversationIds(checked ? new Set(convs.map((conversation) => conversation.id)) : new Set()); setSelectedConversationIds(checked ? new Set(convs.map((conversation) => conversation.id)) : new Set());
} }
function exitSelectionMode() {
setSelectionMode(false);
setSelectedConversationIds(new Set());
}
const del = React.useCallback( const del = React.useCallback(
async (id: number) => { async (id: number) => {
try { try {
@@ -1046,6 +1060,9 @@ export default function ChatPage() {
toast.error(`${failed.length} 个对话删除失败:${details}${failed.length > 3 ? " 等" : ""}`); toast.error(`${failed.length} 个对话删除失败:${details}${failed.length > 3 ? " 等" : ""}`);
} }
setBulkDeleteOpen(false); setBulkDeleteOpen(false);
// Fully successful → return to the clean list; keep selection mode on if
// some failed so the user can retry the remaining ones.
if (failed.length === 0) setSelectionMode(false);
reloadConvs(); reloadConvs();
} catch (error) { } catch (error) {
toast.error(`批量删除失败:${(error as Error).message}`); toast.error(`批量删除失败:${(error as Error).message}`);
@@ -1100,29 +1117,48 @@ export default function ChatPage() {
<Button size="sm" className="w-full" onClick={() => setSelectedId(null)}> <Button size="sm" className="w-full" onClick={() => setSelectedId(null)}>
<PlusIcon /> 新建对话 <PlusIcon /> 新建对话
</Button> </Button>
{convs.length > 0 && ( {convs.length > 0 &&
<div className="flex items-center gap-2 px-1"> (selectionMode ? (
<Checkbox <div className="flex items-center gap-2 px-1">
checked={conversationHeaderChecked} <Checkbox
onCheckedChange={(checked) => toggleAllConversations(checked === true)} checked={conversationHeaderChecked}
aria-label="选择全部对话" onCheckedChange={(checked) => toggleAllConversations(checked === true)}
/> aria-label="选择全部对话"
<span className="text-muted-foreground min-w-0 flex-1 text-xs tabular-nums"> />
{selectedConversationCount > 0 ? `已选 ${selectedConversationCount} 个` : `共 ${convs.length} 个`} <span className="text-muted-foreground min-w-0 flex-1 text-xs tabular-nums">
</span> {selectedConversationCount > 0 ? `已选 ${selectedConversationCount} 个` : `共 ${convs.length} 个`}
{selectedConversationCount > 0 && ( </span>
{selectedConversationCount > 0 && (
<Button
size="sm"
variant="destructive"
disabled={bulkDeleting}
onClick={() => setBulkDeleteOpen(true)}
>
<Trash2Icon data-icon="inline-start" />
删除
</Button>
)}
<Button size="sm" variant="ghost" onClick={exitSelectionMode}>
完成
</Button>
</div>
) : (
<div className="flex items-center gap-2 px-1">
<span className="text-muted-foreground min-w-0 flex-1 text-xs tabular-nums">
共 {convs.length} 个
</span>
<Button <Button
size="sm" size="sm"
variant="destructive" variant="ghost"
disabled={bulkDeleting} className="text-muted-foreground"
onClick={() => setBulkDeleteOpen(true)} onClick={() => setSelectionMode(true)}
> >
<Trash2Icon data-icon="inline-start" /> <ListChecksIcon data-icon="inline-start" />
删除 多选
</Button> </Button>
)} </div>
</div> ))}
)}
</div> </div>
<ScrollArea type="auto" className="min-h-0 min-w-0 flex-1 [&_[data-slot=scroll-area-viewport]>div]:block!"> <ScrollArea type="auto" className="min-h-0 min-w-0 flex-1 [&_[data-slot=scroll-area-viewport]>div]:block!">
<div className="flex min-w-0 flex-col gap-0.5 p-2"> <div className="flex min-w-0 flex-col gap-0.5 p-2">
@@ -1142,6 +1178,7 @@ export default function ChatPage() {
onCancelRename={cancelRename} onCancelRename={cancelRename}
onTogglePinned={togglePinned} onTogglePinned={togglePinned}
onDelete={del} onDelete={del}
selectionMode={selectionMode}
selectedForDelete={selectedConversationIds.has(c.id)} selectedForDelete={selectedConversationIds.has(c.id)}
onSelectedForDeleteChange={toggleConversationSelected} onSelectedForDeleteChange={toggleConversationSelected}
/> />
@@ -9,6 +9,8 @@ import {
Loader2Icon, Loader2Icon,
XIcon, XIcon,
Trash2Icon, Trash2Icon,
CopyIcon,
CheckIcon,
} from "lucide-react"; } from "lucide-react";
import { import {
@@ -74,6 +76,47 @@ function tryFormatJSON(s: string): string {
} }
} }
// 复制当前框内文本的小按钮。复制成功后短暂显示对勾。text 为空/仅占位符时禁用。
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = React.useState(false);
const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
React.useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []);
const disabled = !text;
const copy = async () => {
if (disabled) return;
try {
await navigator.clipboard.writeText(text);
} catch {
// navigator.clipboard 在非安全上下文(如 http 局域网)不可用,回退到 execCommand。
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
try { document.execCommand("copy"); } catch { /* 忽略:不支持则静默 */ }
document.body.removeChild(ta);
}
setCopied(true);
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => setCopied(false), 1500);
};
return (
<Button
variant="ghost"
size="icon"
className="size-5 shrink-0"
disabled={disabled}
title={copied ? "已复制" : "复制内容"}
onClick={copy}
>
{copied ? <CheckIcon className="size-3 text-emerald-600" /> : <CopyIcon className="size-3" />}
</Button>
);
}
const PAGE_SIZES = [25, 50, 100]; const PAGE_SIZES = [25, 50, 100];
export default function LLMRecordsPage() { export default function LLMRecordsPage() {
@@ -96,6 +139,21 @@ export default function LLMRecordsPage() {
const [selected, setSelected] = React.useState<LLMRecordItem | null>(null); const [selected, setSelected] = React.useState<LLMRecordItem | null>(null);
const [detail, setDetail] = React.useState<LLMRecordDetail | null>(null); const [detail, setDetail] = React.useState<LLMRecordDetail | null>(null);
const [detailLoading, setDetailLoading] = React.useState(false); const [detailLoading, setDetailLoading] = React.useState(false);
// 归一化视图 / HTTP 原文视图。原文是排查 provider 侧问题的唯一依据:归一化视图
// 不含工具 schema,响应里也没有 tool_use 块。
const [rawView, setRawView] = React.useState(false);
const hasRaw = !!(detail?.raw_request || detail?.raw_response);
// 开关保持用户选择,但切到一条无原文的旧记录时自动落回解析视图,而不是显示空白。
const showRaw = rawView && hasRaw;
// 原文请求体是 JSON,pretty-print 只改排版不改语义,便于阅读;原文响应是 SSE
// 帧,tryFormatJSON 解析失败会原样返回,故两边共用一个函数即可。
const reqText = showRaw
? detail?.raw_request && tryFormatJSON(detail.raw_request)
: detail?.request_body && tryFormatJSON(detail.request_body);
const respText = showRaw
? detail?.raw_response
: detail?.response_body && tryFormatJSON(detail.response_body);
// Per-task delete (task picker + confirm dialog) // Per-task delete (task picker + confirm dialog)
const [tasks, setTasks] = React.useState<LLMTask[]>([]); const [tasks, setTasks] = React.useState<LLMTask[]>([]);
@@ -427,10 +485,22 @@ export default function LLMRecordsPage() {
) : ( ) : (
<Badge variant="destructive" className="text-xs">Error</Badge> <Badge variant="destructive" className="text-xs">Error</Badge>
)} )}
{/* 原文视图开关。旧记录没有原文,此时禁用而非静默回退,避免看着像
「原文与解析一致」。 */}
<Button
variant={showRaw ? "secondary" : "ghost"}
size="sm"
className="ml-auto h-7 shrink-0 text-xs"
disabled={!hasRaw}
title={hasRaw ? "查看与 provider 实际收发的 HTTP 原文" : "该记录录制于此功能上线前,无原文"}
onClick={() => setRawView((v) => !v)}
>
原文
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="ml-auto size-7 shrink-0" className="size-7 shrink-0"
onClick={() => setSelected(null)} onClick={() => setSelected(null)}
> >
<XIcon /> <XIcon />
@@ -439,8 +509,9 @@ export default function LLMRecordsPage() {
{/* Request / Response split */} {/* Request / Response split */}
<div className="grid min-h-0 flex-1 grid-cols-2 divide-x"> <div className="grid min-h-0 flex-1 grid-cols-2 divide-x">
<div className="flex min-h-0 min-w-0 flex-col"> <div className="flex min-h-0 min-w-0 flex-col">
<div className="border-b px-3 py-1 text-[11px] font-medium text-muted-foreground"> <div className="flex items-center gap-2 border-b py-0.5 pr-1.5 pl-3 text-[11px] font-medium text-muted-foreground">
Request <span>Request{showRaw && " · 原文"}</span>
<CopyButton text={reqText || ""} />
</div> </div>
<div className="min-h-0 flex-1 overflow-auto"> <div className="min-h-0 flex-1 overflow-auto">
{detailLoading ? ( {detailLoading ? (
@@ -450,14 +521,15 @@ export default function LLMRecordsPage() {
</div> </div>
) : ( ) : (
<pre className="p-3 font-mono text-xs break-all whitespace-pre-wrap"> <pre className="p-3 font-mono text-xs break-all whitespace-pre-wrap">
{detail?.request_body ? tryFormatJSON(detail.request_body) : "(空)"} {reqText || "(空)"}
</pre> </pre>
)} )}
</div> </div>
</div> </div>
<div className="flex min-h-0 min-w-0 flex-col"> <div className="flex min-h-0 min-w-0 flex-col">
<div className="border-b px-3 py-1 text-[11px] font-medium text-muted-foreground"> <div className="flex items-center gap-2 border-b py-0.5 pr-1.5 pl-3 text-[11px] font-medium text-muted-foreground">
Response <span>Response{showRaw && " · 原文(SSE)"}</span>
<CopyButton text={respText || ""} />
</div> </div>
<div className="min-h-0 flex-1 overflow-auto"> <div className="min-h-0 flex-1 overflow-auto">
{detailLoading ? ( {detailLoading ? (
@@ -470,7 +542,7 @@ export default function LLMRecordsPage() {
"p-3 font-mono text-xs break-all whitespace-pre-wrap", "p-3 font-mono text-xs break-all whitespace-pre-wrap",
selected.status !== "ok" && "text-red-600 dark:text-red-400", selected.status !== "ok" && "text-red-600 dark:text-red-400",
)}> )}>
{detail?.response_body ? tryFormatJSON(detail.response_body) : "(空)"} {respText || "(空)"}
</pre> </pre>
)} )}
</div> </div>
+7 -4
View File
@@ -296,7 +296,7 @@ function ProfileSheet({
}) { }) {
const isNew = !profile; const isNew = !profile;
const [name, setName] = React.useState(""); const [name, setName] = React.useState("");
const [format, setFormat] = React.useState<"anthropic" | "openai">("anthropic"); const [format, setFormat] = React.useState<"anthropic" | "openai" | "openai-responses">("anthropic");
const [model, setModel] = React.useState(""); const [model, setModel] = React.useState("");
const [baseUrl, setBaseUrl] = React.useState(""); const [baseUrl, setBaseUrl] = React.useState("");
const [proxy, setProxy] = React.useState(""); const [proxy, setProxy] = React.useState("");
@@ -321,7 +321,9 @@ function ProfileSheet({
React.useEffect(() => { React.useEffect(() => {
if (!open) return; if (!open) return;
setName(profile?.name ?? ""); setName(profile?.name ?? "");
setFormat(profile?.format === "openai" ? "openai" : "anthropic"); setFormat(
profile?.format === "openai" || profile?.format === "openai-responses" ? profile.format : "anthropic",
);
setModel(profile?.model ?? ""); setModel(profile?.model ?? "");
setBaseUrl(profile?.base_url ?? ""); setBaseUrl(profile?.base_url ?? "");
setProxy(profile?.proxy ?? ""); setProxy(profile?.proxy ?? "");
@@ -462,13 +464,14 @@ function ProfileSheet({
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>格式</Label> <Label>格式</Label>
<Select value={format} onValueChange={(v) => setFormat(v as "anthropic" | "openai")}> <Select value={format} onValueChange={(v) => setFormat(v as "anthropic" | "openai" | "openai-responses")}>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="选择格式" /> <SelectValue placeholder="选择格式" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="anthropic">Anthropic</SelectItem> <SelectItem value="anthropic">Anthropic</SelectItem>
<SelectItem value="openai">OpenAI</SelectItem> <SelectItem value="openai">OpenAI (Chat Completions)</SelectItem>
<SelectItem value="openai-responses">OpenAI (Responses API)</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
+36
View File
@@ -3766,5 +3766,41 @@ export function llmRecordDetail(id: number, records = llmRecords) {
null, null,
2, 2,
), ),
// HTTP 原文:请求含被归一化视图丢弃的完整工具 schema,响应为原始 SSE 帧。
raw_request: JSON.stringify({
model: item.model,
max_tokens: 8192,
stream: true,
system: [{ type: "text", text: "你是一个授权渗透测试系统的「执行者」…(省略)", cache_control: { type: "ephemeral" } }],
messages: [{ role: "user", content: "开始执行 system 提示里的这条意图:只做它、只产生事实、做完即停。" }],
tools: [
{
name: "bash",
description: "在目标环境中执行一条 shell 命令并返回其输出。",
input_schema: { type: "object", properties: { command: { type: "string" } }, required: ["command"] },
},
],
thinking: { type: "enabled" },
}),
raw_response: [
`event: message_start`,
`data: {"type":"message_start","message":{"id":"msg_01mock","model":"${item.model}","usage":{"input_tokens":${item.input_tokens},"output_tokens":1}}}`,
``,
`event: content_block_delta`,
`data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"对 search?q= 做注入探测。"}}`,
``,
`event: content_block_start`,
`data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_01mock","name":"bash"}}`,
``,
`event: content_block_delta`,
`data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\\"command\\":\\"curl -s 'https://www.acme.com/search?q=1%27'\\"}"}}`,
``,
`event: message_delta`,
`data: {"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":${item.output_tokens}}}`,
``,
`event: message_stop`,
`data: {"type":"message_stop"}`,
``,
].join("\n"),
}; };
} }
+6 -1
View File
@@ -852,7 +852,7 @@ export interface Settings {
export interface LLMProfile { export interface LLMProfile {
id: string; id: string;
name: string; name: string;
format: "openai" | "anthropic"; format: "openai" | "anthropic" | "openai-responses";
base_url?: string; base_url?: string;
proxy?: string; proxy?: string;
model: string; model: string;
@@ -1163,6 +1163,11 @@ export interface LLMRecordItem {
export interface LLMRecordDetail extends LLMRecordItem { export interface LLMRecordDetail extends LLMRecordItem {
request_body: string; request_body: string;
response_body: string; response_body: string;
// provider 实际收发的 HTTP 原文:请求为 buildBody() 发出的完整 body(含工具
// schema),响应为原始 SSE 帧。上面的 request_body/response_body 是归一化视图,
// 丢弃了工具 schema 与 tool_use 块。旧记录为空。
raw_request?: string;
raw_response?: string;
} }
// One distinct task with its LLM-record count (task picker on the records page). // One distinct task with its LLM-record count (task picker on the records page).