mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-15 04:30:55 +08:00
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30c787d690 | ||
|
|
430950570c | ||
|
|
f8bc149872 | ||
|
|
f3a8e82d29 | ||
|
|
e300f85987 | ||
|
|
dfa0e8a566 | ||
|
|
9b20a46eba | ||
|
|
8c8ae97400 | ||
|
|
149151919f | ||
|
|
5cdb720122 | ||
|
|
ae792126dc | ||
|
|
54ff51177d | ||
|
|
37f2573a53 | ||
|
|
5cb44fb969 | ||
|
|
f3b9f6f688 | ||
|
|
1da0d07222 | ||
|
|
622b7490e0 | ||
|
|
8f3f0c54f8 | ||
|
|
b6e863c746 | ||
|
|
2e43b423ee | ||
|
|
8bb4ca0745 | ||
|
|
939f8d4976 | ||
|
|
b5c0bb9f33 | ||
|
|
33078a7d35 |
@@ -45,8 +45,9 @@ jobs:
|
||||
# keeps the database state deterministic.
|
||||
run: go test -p 1 ./...
|
||||
- name: go test -race
|
||||
# Race detector on the packages that pass under -race today. ./server is
|
||||
# left out until it has been checked with -race against postgres.
|
||||
# Race detector on the packages that pass under -race today. ./server
|
||||
# joined once its suite was checked with -race against postgres and its
|
||||
# test teardown fixed (#227, #228).
|
||||
# Its own database: the plain run above leaves rows behind and the db tests
|
||||
# are not idempotent across runs (duplicate llm_profiles names).
|
||||
env:
|
||||
@@ -54,7 +55,8 @@ jobs:
|
||||
|
||||
run: >-
|
||||
go test -race -p 1 ./agent ./cmd/artex ./config ./db ./evidence ./guard
|
||||
./intercept ./llmpool ./llmrec ./mcphttp ./notify ./sidequestion ./traffic
|
||||
./intercept ./llmpool ./llmrec ./mcphttp ./notify ./server ./sidequestion
|
||||
./traffic
|
||||
|
||||
shell:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- **Untrusted-output boundary is now on by default**: Norma frames tool results for the worker, planner, chat and main agent unless `ARTEX_UNTRUSTED_BOUNDARY` is set to `0`, `false`, `off` or `no`. It was opt-in via `ARTEX_UNTRUSTED_BOUNDARY=1` in 0.6.0 (#166).
|
||||
|
||||
## [0.6.0] - 2026-11-12
|
||||
|
||||
Machine-checked finding verification (S1), intercept decisions traced to their actor, configurable notification limits, prompt hardening and goal guards from the #124 agentic audit, the target gate (S3b phase 1 and step 2.1, off by default and API-only), intent claim leases, and checksum-verified tool downloads in the Docker image.
|
||||
|
||||
+1
-1
@@ -178,7 +178,7 @@ COPY --from=build /out/artex /app/artex
|
||||
COPY start.sh /app/start.sh
|
||||
RUN chmod +x /app/artex /app/start.sh && mkdir -p /app/keys
|
||||
COPY skills/ /app/skills/
|
||||
# persistence points: data/ (SQLite, task files) and keys/ (jwt.key, via ARTEX_JWT_KEY_DIR)
|
||||
# persistence points: data/ (traffic index, task files, transcripts) and keys/ (jwt.key, secret.key, via ARTEX_JWT_KEY_DIR)
|
||||
VOLUME ["/app/data", "/app/keys"]
|
||||
EXPOSE 8787 8788
|
||||
ENTRYPOINT ["/app/start.sh"]
|
||||
|
||||
@@ -161,7 +161,7 @@ CGO_ENABLED=0 go build -tags embedui -o artex ./cmd/artex
|
||||
|
||||
```bash
|
||||
./build.sh --release
|
||||
# output: dist/artex-0.3.3-*.zip
|
||||
# output: dist/artex-<version>-*.zip
|
||||
```
|
||||
|
||||
UPX self-extracting binaries may be incompatible with some Linux kernels, virtualization environments, or security policies, so they are disabled by default. Use `ARTEX_TARGETS` to customize the targets; when you have confirmed the target runtime is compatible, explicitly pass `--upx` to further shrink the binary:
|
||||
@@ -207,7 +207,7 @@ Daily cron example (03:20, operator crontab):
|
||||
|
||||
## Updating and upgrading
|
||||
|
||||
> Upgrading only swaps the program and never touches data: the Postgres volume `pgdata`, `./data` (SQLite, task files, etc.), `./keys` (jwt.key), and `./skills` are all preserved. **No manual database migration is needed**: `artex` idempotently re-runs `schema.sql` on every start (with `ADD COLUMN` / `CREATE INDEX IF NOT EXISTS`), i.e. "restart equals migrate". It is still recommended to back up `./data` and the database before upgrading.
|
||||
> Upgrading only swaps the program and never touches data: the Postgres volume `pgdata`, `./data` (traffic index, task files, transcripts, etc.), `./keys` (jwt.key, secret.key), and `./skills` are all preserved. **No manual database migration is needed**: `artex` idempotently re-runs `schema.sql` on every start (with `ADD COLUMN` / `CREATE INDEX IF NOT EXISTS`), i.e. "restart equals migrate". It is still recommended to back up `./data` and the database before upgrading.
|
||||
|
||||
**Update banner (opt-in, off by default)**: start ARTEX with `ARTEX_UPDATE_CHECK=1` to have the backend ask the public GitHub releases API for the latest ARTEX tag, at most once per 24 hours (cached in memory; a failed check is not retried within that window). When the release is newer than the running version, the web UI shows a small dismissible strip linking to the Releases page. The request sends only the `User-Agent: artex-update-check` header. Unset, ARTEX makes no update request and shows no banner.
|
||||
|
||||
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
ARTEX is an autonomous penetration-testing tool. Security fixes are provided only for the latest official release line. The `main` branch is not considered a stable release and may change at any time.
|
||||
|
||||
Only the latest release line receives security fixes. Older releases do not receive fixes; please upgrade to the latest release.
|
||||
|
||||
Notes:
|
||||
- Please upgrade to the latest release before reporting a vulnerability.
|
||||
- Forks, custom builds from arbitrary commits, and modified versions are not covered by official support.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
**Do not open a public issue for a security vulnerability.**
|
||||
|
||||
Report through **GitHub Security Advisories**: **Security** tab → **Report a vulnerability** in the `BaaRai/ARTEX` repository. This is the only reporting channel.
|
||||
|
||||
Please include:
|
||||
- A description of the vulnerability and its expected impact.
|
||||
- The ARTEX version and/or commit hash.
|
||||
- Runtime environment (OS, Docker/local, PostgreSQL, LLM provider, etc.).
|
||||
- Minimal reproduction steps (PoC).
|
||||
- Logs, screenshots, or sample traffic with **sensitive data removed**.
|
||||
- Suggested remediation, if available.
|
||||
|
||||
**Do not send** real third-party data, credentials, tokens, customer data, or unredacted sensitive content.
|
||||
|
||||
## Scope and Authorized Testing
|
||||
|
||||
ARTEX is an offensive-security tool. Test only against:
|
||||
- Your own ARTEX deployment in a lab or local environment;
|
||||
- Systems you own;
|
||||
- Targets explicitly authorized in writing (bug bounty, pentest contract, etc.).
|
||||
|
||||
Testing, scanning, or exploiting third-party systems without explicit authorization is strictly prohibited. Reports that violate this principle may be closed without action.
|
||||
|
||||
## What to Expect
|
||||
|
||||
This is an open-source project maintained on a best-effort basis. We aim to acknowledge a report within about a week and keep you updated, but we cannot guarantee specific response or fix timeframes.
|
||||
|
||||
## If a Report Is Accepted
|
||||
|
||||
- We will open a private **GitHub Security Advisory** for coordination.
|
||||
- We will identify the release containing the fix when it is published.
|
||||
- We may request a **CVE** if the vulnerability qualifies.
|
||||
- We will credit you or your organization in the advisory if you wish.
|
||||
- Default disclosure timing: **90 days** after a fix is available, or earlier by mutual agreement. If the vulnerability is being exploited in the wild, we may disclose earlier.
|
||||
|
||||
## If a Report Is Declined
|
||||
|
||||
We will explain the reason, for example:
|
||||
- It is not a security vulnerability but intended behavior.
|
||||
- It is outside ARTEX’s scope (e.g., an issue in an LLM provider, PostgreSQL, Docker, or a browser).
|
||||
- There is insufficient information to reproduce it.
|
||||
- The report only states that “the tool can be misused” without identifying a specific technical flaw in ARTEX.
|
||||
|
||||
Where appropriate, we may convert the report into a public hardening issue or feature request.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
The following are generally **not** considered ARTEX security vulnerabilities:
|
||||
- Attacks or scans against systems you do not own or are not authorized to test.
|
||||
- Self-XSS, spam, or brute-force without real impact.
|
||||
- Large-scale DoS/DDoS or resource-exhaustion attacks.
|
||||
- Social engineering targeting maintainers or users.
|
||||
- Issues that exist only in third-party dependencies and do not affect ARTEX.
|
||||
- Reports that “ARTEX can be used to attack systems” when the user has valid authorization — this is the nature of a penetration-testing tool, not a vulnerability.
|
||||
- Missing headers or hardening configuration without a concrete security impact.
|
||||
|
||||
## Safe Harbor
|
||||
|
||||
We welcome ethical security research. If you follow this policy and test only within authorized scope, we will not pursue legal action against you for good-faith research. This does not exempt you from applicable laws in your jurisdiction.
|
||||
|
||||
## Disclosure Policy
|
||||
|
||||
- Do not publicly disclose vulnerability details before a fix is available or before the coordinated disclosure period ends.
|
||||
- Do not publish a public PoC without maintainer consent.
|
||||
- If you do not receive a response within a reasonable time, please ping the contact channel above before disclosing.
|
||||
|
||||
Thank you for helping make ARTEX safer.
|
||||
+30
-7
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"runtime/debug"
|
||||
"sync"
|
||||
|
||||
actool "github.com/Autumn-27/norma/tool"
|
||||
)
|
||||
@@ -23,15 +24,36 @@ type DeferredInfo struct {
|
||||
UnlockSkill func(skillName string)
|
||||
}
|
||||
|
||||
// ToolAugment, if set, returns the EXTRA tools an agent should see beyond its
|
||||
// built-in base set — the agent's visible skills (packed into one Skill meta-tool)
|
||||
// ToolAugmentFunc is the ToolAugment hook. If set, it returns the EXTRA tools
|
||||
// an agent should see beyond its built-in base set — the agent's visible skills (packed into one Skill meta-tool)
|
||||
// and visible MCP servers (expanded to mcp__server__tool). It also returns the
|
||||
// DeferredInfo describing how those MCP tools are deferred/gated. The server wires
|
||||
// it to the PG agent_visibility table. cleanup releases any spawned MCP clients.
|
||||
//
|
||||
// When nil, agents run with only their built-in tools — behavior is unchanged
|
||||
// until a user assigns a skill/MCP to the agent in the UI.
|
||||
var ToolAugment func(ctx context.Context, agentKey string) (extra []actool.CoreTool, def DeferredInfo, cleanup func())
|
||||
type ToolAugmentFunc func(ctx context.Context, agentKey string) (extra []actool.CoreTool, def DeferredInfo, cleanup func())
|
||||
|
||||
// The hook is set by server wiring while restored tasks may already be
|
||||
// assembling tools on other goroutines, so every access goes through the lock.
|
||||
var (
|
||||
toolAugmentMu sync.RWMutex
|
||||
toolAugment ToolAugmentFunc
|
||||
)
|
||||
|
||||
// SetToolAugment installs (or, with nil, removes) the ToolAugment hook.
|
||||
func SetToolAugment(f ToolAugmentFunc) {
|
||||
toolAugmentMu.Lock()
|
||||
toolAugment = f
|
||||
toolAugmentMu.Unlock()
|
||||
}
|
||||
|
||||
// ToolAugment returns the current ToolAugment hook, or nil when none is set.
|
||||
func ToolAugment() ToolAugmentFunc {
|
||||
toolAugmentMu.RLock()
|
||||
defer toolAugmentMu.RUnlock()
|
||||
return toolAugment
|
||||
}
|
||||
|
||||
// AugmentTools returns base plus the agent's visible skill/MCP tools, the
|
||||
// DeferredInfo, and a cleanup func the caller must defer (closes MCP clients).
|
||||
@@ -42,10 +64,11 @@ func AugmentTools(ctx context.Context, agentKey string, base []actool.CoreTool)
|
||||
cleanup = func() {}
|
||||
out = base
|
||||
)
|
||||
if ToolAugment != nil {
|
||||
// Copy each hook once under its lock, then call it outside the lock.
|
||||
if augment := ToolAugment(); augment != nil {
|
||||
var extra []actool.CoreTool
|
||||
var cl func()
|
||||
extra, def, cl = ToolAugment(ctx, agentKey)
|
||||
extra, def, cl = augment(ctx, agentKey)
|
||||
if cl != nil {
|
||||
cleanup = cl
|
||||
}
|
||||
@@ -57,8 +80,8 @@ func AugmentTools(ctx context.Context, agentKey string, base []actool.CoreTool)
|
||||
// agent isn't bound to (or that are disabled) and swap in overridden
|
||||
// descriptions/schemas + default injection. MCP/skill/host tools have no row
|
||||
// and pass through untouched, so deferred/unlock wiring stays consistent.
|
||||
if ToolResolve != nil {
|
||||
out = ToolResolve(ctx, agentKey, out)
|
||||
if resolve := ToolResolve(); resolve != nil {
|
||||
out = resolve(ctx, agentKey, out)
|
||||
}
|
||||
// Tool RAG (off by default): lexical projection of the bound list; only removes.
|
||||
out = applyToolRAG(ctx, agentKey, out, def.Deferred, bm25Score)
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
actool "github.com/Autumn-27/norma/tool"
|
||||
)
|
||||
|
||||
// Server wiring installs the hooks while restored tasks may already be assembling
|
||||
// tools, rendering prompts and building settlement configs on other goroutines.
|
||||
// Readers and writers run concurrently here, so under -race any unsynchronised
|
||||
// hook access is reported regardless of scheduling.
|
||||
func TestAgentHooksConcurrentSwap(t *testing.T) {
|
||||
oldAugment, oldResolve, oldPolicy := ToolAugment(), ToolResolve(), FindingTrafficBindingEnabled()
|
||||
oldPrompt, oldWrapup, oldWrapupTurns := PromptOverride(), WrapupOverride(), WrapupMaxTurnsOverride()
|
||||
oldTimeout, oldTimeoutTurns := WrapupTaskTimeoutOverride(), WrapupTaskTimeoutTurnsOverride()
|
||||
t.Cleanup(func() {
|
||||
SetToolAugment(oldAugment)
|
||||
SetToolResolve(oldResolve)
|
||||
SetFindingTrafficBindingEnabled(oldPolicy)
|
||||
SetPromptOverride(oldPrompt)
|
||||
SetWrapupOverride(oldWrapup)
|
||||
SetWrapupMaxTurnsOverride(oldWrapupTurns)
|
||||
SetWrapupTaskTimeoutOverride(oldTimeout)
|
||||
SetWrapupTaskTimeoutTurnsOverride(oldTimeoutTurns)
|
||||
})
|
||||
augment := func(context.Context, string) ([]actool.CoreTool, DeferredInfo, func()) {
|
||||
return nil, DeferredInfo{}, nil
|
||||
}
|
||||
resolve := func(_ context.Context, _ string, in []actool.CoreTool) []actool.CoreTool { return in }
|
||||
policy := func() bool { return false }
|
||||
text := func(string) (string, bool) { return "", false }
|
||||
turns := func(string) (int, bool) { return 0, false }
|
||||
setAll := func(on bool) {
|
||||
if on {
|
||||
SetToolAugment(augment)
|
||||
SetToolResolve(resolve)
|
||||
SetFindingTrafficBindingEnabled(policy)
|
||||
SetPromptOverride(text)
|
||||
SetWrapupOverride(text)
|
||||
SetWrapupMaxTurnsOverride(turns)
|
||||
SetWrapupTaskTimeoutOverride(text)
|
||||
SetWrapupTaskTimeoutTurnsOverride(turns)
|
||||
return
|
||||
}
|
||||
SetToolAugment(nil)
|
||||
SetToolResolve(nil)
|
||||
SetFindingTrafficBindingEnabled(nil)
|
||||
SetPromptOverride(nil)
|
||||
SetWrapupOverride(nil)
|
||||
SetWrapupMaxTurnsOverride(nil)
|
||||
SetWrapupTaskTimeoutOverride(nil)
|
||||
SetWrapupTaskTimeoutTurnsOverride(nil)
|
||||
}
|
||||
|
||||
const rounds = 200
|
||||
var wg sync.WaitGroup
|
||||
for range 4 {
|
||||
wg.Go(func() {
|
||||
for range rounds {
|
||||
out, _, cleanup := AugmentTools(context.Background(), "worker", nil)
|
||||
cleanup()
|
||||
if len(out) != 0 {
|
||||
t.Errorf("AugmentTools(nil base) = %d tools, want 0", len(out))
|
||||
return
|
||||
}
|
||||
if got := renderSystem("planner", "DEFAULT", PlannerVars{}); got != "DEFAULT" {
|
||||
t.Errorf("renderSystem = %q, want the default", got)
|
||||
return
|
||||
}
|
||||
if st := wrapupSettlementForTask("worker", nil, true); st.Prompt != WrapupDefault("worker") {
|
||||
t.Errorf("settlement prompt = %q, want the default", st.Prompt)
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Go(func() {
|
||||
for range rounds {
|
||||
setAll(true)
|
||||
setAll(false)
|
||||
}
|
||||
})
|
||||
wg.Wait()
|
||||
}
|
||||
@@ -51,9 +51,9 @@ func TestReportFindingOptionalTrafficWithoutCapture(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestReportFindingAtomicContract(t *testing.T) {
|
||||
old := FindingTrafficBindingEnabled
|
||||
FindingTrafficBindingEnabled = func() bool { return true }
|
||||
t.Cleanup(func() { FindingTrafficBindingEnabled = old })
|
||||
old := FindingTrafficBindingEnabled()
|
||||
SetFindingTrafficBindingEnabled(func() bool { return true })
|
||||
t.Cleanup(func() { SetFindingTrafficBindingEnabled(old) })
|
||||
d := testDB(t)
|
||||
defer d.Close()
|
||||
task, err := d.CreateTask("report contract", "fixture", nil, 0, 0)
|
||||
|
||||
@@ -3,6 +3,7 @@ package agent
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
actool "github.com/Autumn-27/norma/tool"
|
||||
@@ -13,10 +14,29 @@ const findingIDGuidance = "\n\n**Finding ID conventions**: finding_id is the sta
|
||||
// The server supplies the persisted setting. A missing setting/host is off.
|
||||
// Consulted at assembly and again on writes so an already-running session
|
||||
// cannot keep binding after the user switches the feature off.
|
||||
var FindingTrafficBindingEnabled func() bool
|
||||
// Guarded by a lock for the same reason as the tool hooks.
|
||||
var (
|
||||
findingTrafficBindingMu sync.RWMutex
|
||||
findingTrafficBindingHook func() bool
|
||||
)
|
||||
|
||||
// SetFindingTrafficBindingEnabled installs (or, with nil, removes) the hook.
|
||||
func SetFindingTrafficBindingEnabled(f func() bool) {
|
||||
findingTrafficBindingMu.Lock()
|
||||
findingTrafficBindingHook = f
|
||||
findingTrafficBindingMu.Unlock()
|
||||
}
|
||||
|
||||
// FindingTrafficBindingEnabled returns the current hook, or nil when none is set.
|
||||
func FindingTrafficBindingEnabled() func() bool {
|
||||
findingTrafficBindingMu.RLock()
|
||||
defer findingTrafficBindingMu.RUnlock()
|
||||
return findingTrafficBindingHook
|
||||
}
|
||||
|
||||
func findingTrafficBindingEnabled() bool {
|
||||
return FindingTrafficBindingEnabled != nil && FindingTrafficBindingEnabled()
|
||||
f := FindingTrafficBindingEnabled()
|
||||
return f != nil && f()
|
||||
}
|
||||
|
||||
// Applied after ToolResolve: user descriptions and prompts remain intact, while
|
||||
|
||||
@@ -10,19 +10,23 @@ import (
|
||||
)
|
||||
|
||||
func TestFindingWorkflowSharedAssemblyAndSwitch(t *testing.T) {
|
||||
oldPolicy, oldResolve, oldAugment := FindingTrafficBindingEnabled, ToolResolve, ToolAugment
|
||||
t.Cleanup(func() { FindingTrafficBindingEnabled, ToolResolve, ToolAugment = oldPolicy, oldResolve, oldAugment })
|
||||
ToolAugment = nil
|
||||
oldPolicy, oldResolve, oldAugment := FindingTrafficBindingEnabled(), ToolResolve(), ToolAugment()
|
||||
t.Cleanup(func() {
|
||||
SetFindingTrafficBindingEnabled(oldPolicy)
|
||||
SetToolResolve(oldResolve)
|
||||
SetToolAugment(oldAugment)
|
||||
})
|
||||
SetToolAugment(nil)
|
||||
on := false
|
||||
FindingTrafficBindingEnabled = func() bool { return on }
|
||||
SetFindingTrafficBindingEnabled(func() bool { return on })
|
||||
ts := NewToolSet(nil, "fixture")
|
||||
ToolResolve = func(_ context.Context, _ string, base []actool.CoreTool) []actool.CoreTool {
|
||||
SetToolResolve(func(_ context.Context, _ string, base []actool.CoreTool) []actool.CoreTool {
|
||||
out := make([]actool.CoreTool, len(base))
|
||||
for i, tool := range base {
|
||||
out[i] = DecorateTool(tool, "CUSTOM DESCRIPTION", tool.InputSchema())
|
||||
}
|
||||
return out
|
||||
}
|
||||
})
|
||||
for _, role := range []string{"worker", "planner", "mainagent", "auto", "pentest", "custom-agent"} {
|
||||
for _, enabled := range []bool{false, true, false} {
|
||||
on = enabled
|
||||
@@ -59,7 +63,7 @@ func TestFindingWorkflowSharedAssemblyAndSwitch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
on = true
|
||||
ToolResolve = func(context.Context, string, []actool.CoreTool) []actool.CoreTool { return nil }
|
||||
SetToolResolve(func(context.Context, string, []actool.CoreTool) []actool.CoreTool { return nil })
|
||||
out, def, cleanup := AugmentTools(t.Context(), "planner", []actool.CoreTool{ts.addFinding()})
|
||||
defer cleanup()
|
||||
if len(out) != 0 || def.FindingGuidance != "" {
|
||||
|
||||
+22
-3
@@ -2,6 +2,7 @@ package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"sync"
|
||||
"text/template"
|
||||
"time"
|
||||
)
|
||||
@@ -10,7 +11,25 @@ import (
|
||||
// key and whether one exists. The server wires it to the PG agent_prompts table.
|
||||
// When nil or no override exists, agents use their built-in default prompt — so
|
||||
// behavior is identical until a user edits a prompt in the UI.
|
||||
var PromptOverride func(agentKey string) (string, bool)
|
||||
// Guarded by a lock: server wiring can run while agents render prompts.
|
||||
var (
|
||||
promptOverrideMu sync.RWMutex
|
||||
promptOverride func(agentKey string) (string, bool)
|
||||
)
|
||||
|
||||
// SetPromptOverride installs (or, with nil, removes) the PromptOverride hook.
|
||||
func SetPromptOverride(f func(agentKey string) (string, bool)) {
|
||||
promptOverrideMu.Lock()
|
||||
promptOverride = f
|
||||
promptOverrideMu.Unlock()
|
||||
}
|
||||
|
||||
// PromptOverride returns the current hook, or nil when none is set.
|
||||
func PromptOverride() func(agentKey string) (string, bool) {
|
||||
promptOverrideMu.RLock()
|
||||
defer promptOverrideMu.RUnlock()
|
||||
return promptOverride
|
||||
}
|
||||
|
||||
// Prompt-variable structs — fields mirror each agent's catalog (docs §5a) so a
|
||||
// user template referencing a catalog variable renders; referencing anything else
|
||||
@@ -38,8 +57,8 @@ func nowStr() string { return time.Now().Format("2006-01-02 15:04:05 MST") }
|
||||
// the DB body.
|
||||
func renderSystem(agentKey, def string, vars any) string {
|
||||
tmpl := def
|
||||
if PromptOverride != nil {
|
||||
if t, ok := PromptOverride(agentKey); ok && t != "" {
|
||||
if override := PromptOverride(); override != nil {
|
||||
if t, ok := override(agentKey); ok && t != "" {
|
||||
tmpl = t
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,9 +18,9 @@ func TestPromptSeedsDoNotCarryInjectionBlock(t *testing.T) {
|
||||
// tail: the #49 block for the roles that read target output, the untrusted-data
|
||||
// rule for the planner and the cold-digest compactor.
|
||||
func TestInjectionTailSurvivesPromptOverride(t *testing.T) {
|
||||
prev := PromptOverride
|
||||
t.Cleanup(func() { PromptOverride = prev })
|
||||
PromptOverride = func(string) (string, bool) { return "BARE BODY", true }
|
||||
prev := PromptOverride()
|
||||
t.Cleanup(func() { SetPromptOverride(prev) })
|
||||
SetPromptOverride(func(string) (string, bool) { return "BARE BODY", true })
|
||||
|
||||
content := map[string]string{
|
||||
"worker": workerSystem("", "", "/data", "/run"),
|
||||
@@ -64,8 +64,8 @@ func TestInjectionTailSurvivesPromptOverride(t *testing.T) {
|
||||
// A body that already carries the block (a DB row seeded before the move) does
|
||||
// not get it twice; the code defaults carry it exactly once.
|
||||
func TestInjectionTailNotDuplicated(t *testing.T) {
|
||||
prev := PromptOverride
|
||||
t.Cleanup(func() { PromptOverride = prev })
|
||||
prev := PromptOverride()
|
||||
t.Cleanup(func() { SetPromptOverride(prev) })
|
||||
|
||||
render := func() map[string]string {
|
||||
return map[string]string{
|
||||
@@ -78,16 +78,16 @@ func TestInjectionTailNotDuplicated(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
PromptOverride = func(string) (string, bool) {
|
||||
SetPromptOverride(func(string) (string, bool) {
|
||||
return "LEGACY BODY\n\n" + untrustedContentRules + "\n\nMore rules.", true
|
||||
}
|
||||
})
|
||||
for role, sys := range render() {
|
||||
if n := strings.Count(sys, untrustedContentRules); n != 1 {
|
||||
t.Errorf("legacy %s: untrusted-content block rendered %d times, want 1", role, n)
|
||||
}
|
||||
}
|
||||
|
||||
PromptOverride = nil
|
||||
SetPromptOverride(nil)
|
||||
for role, sys := range render() {
|
||||
if n := strings.Count(sys, untrustedContentRules); n != 1 {
|
||||
t.Errorf("default %s: untrusted-content block rendered %d times, want 1", role, n)
|
||||
|
||||
+12
-12
@@ -10,15 +10,15 @@ import (
|
||||
// (chat) agent prompt referencing it renders the live server time each turn, rather
|
||||
// than failing template execution and falling back to DefaultAssistantPrompt.
|
||||
func TestChatNowVarRenders(t *testing.T) {
|
||||
prev := PromptOverride
|
||||
defer func() { PromptOverride = prev }()
|
||||
prev := PromptOverride()
|
||||
defer func() { SetPromptOverride(prev) }()
|
||||
|
||||
PromptOverride = func(key string) (string, bool) {
|
||||
SetPromptOverride(func(key string) (string, bool) {
|
||||
if key == "tec_benchmark" {
|
||||
return "Current time: {{.Now}}", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
})
|
||||
|
||||
out := chatSystem("tec_benchmark", "/app/data", "/tmp/x")
|
||||
if !strings.Contains(out, "Current time: ") {
|
||||
@@ -34,12 +34,12 @@ func TestChatNowVarRenders(t *testing.T) {
|
||||
// a custom prompt referencing it renders the server data root (s.m.dir), rather
|
||||
// than failing template execution and falling back to DefaultAssistantPrompt.
|
||||
func TestChatDataDirVarRenders(t *testing.T) {
|
||||
prev := PromptOverride
|
||||
defer func() { PromptOverride = prev }()
|
||||
prev := PromptOverride()
|
||||
defer func() { SetPromptOverride(prev) }()
|
||||
|
||||
PromptOverride = func(key string) (string, bool) {
|
||||
SetPromptOverride(func(key string) (string, bool) {
|
||||
return "Data root: {{.DataDir}}", true
|
||||
}
|
||||
})
|
||||
|
||||
out := chatSystem("tec_benchmark", "/app/data", "/tmp/x")
|
||||
if !strings.Contains(out, "Data root: /app/data") {
|
||||
@@ -50,12 +50,12 @@ func TestChatDataDirVarRenders(t *testing.T) {
|
||||
// TestChatUnknownVarFallsBack verifies an out-of-catalog {{.X}} still degrades
|
||||
// safely to the default assistant prompt (never a half-rendered prompt).
|
||||
func TestChatUnknownVarFallsBack(t *testing.T) {
|
||||
prev := PromptOverride
|
||||
defer func() { PromptOverride = prev }()
|
||||
prev := PromptOverride()
|
||||
defer func() { SetPromptOverride(prev) }()
|
||||
|
||||
PromptOverride = func(key string) (string, bool) {
|
||||
SetPromptOverride(func(key string) (string, bool) {
|
||||
return "References a nonexistent variable: {{.Bogus}}", true
|
||||
}
|
||||
})
|
||||
|
||||
out := chatSystem("whatever", "/app/data", "/tmp/x")
|
||||
if strings.Contains(out, "References a nonexistent variable") {
|
||||
|
||||
@@ -6,27 +6,27 @@ import (
|
||||
)
|
||||
|
||||
func TestRenderSystemOverrideAndFallback(t *testing.T) {
|
||||
t.Cleanup(func() { PromptOverride = nil })
|
||||
t.Cleanup(func() { SetPromptOverride(nil) })
|
||||
|
||||
// no override → built-in default
|
||||
PromptOverride = nil
|
||||
SetPromptOverride(nil)
|
||||
if got := renderSystem("planner", "DEFAULT", PlannerVars{Goal: "g"}); got != "DEFAULT" {
|
||||
t.Fatalf("no override should give default, got %q", got)
|
||||
}
|
||||
|
||||
// override → rendered with vars
|
||||
PromptOverride = func(k string) (string, bool) {
|
||||
SetPromptOverride(func(k string) (string, bool) {
|
||||
if k == "planner" {
|
||||
return "Goal:{{.Goal}} Scope:{{.Scope}}", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
})
|
||||
if got := renderSystem("planner", "DEFAULT", PlannerVars{Goal: "capture X", Scope: "*.x.com"}); got != "Goal:capture X Scope:*.x.com" {
|
||||
t.Fatalf("override render: %q", got)
|
||||
}
|
||||
|
||||
// override referencing a non-catalog var → execution error → fallback to default
|
||||
PromptOverride = func(k string) (string, bool) { return "{{.NotInCatalog}}", true }
|
||||
SetPromptOverride(func(k string) (string, bool) { return "{{.NotInCatalog}}", true })
|
||||
if got := renderSystem("planner", "DEFAULT", PlannerVars{Goal: "x"}); got != "DEFAULT" {
|
||||
t.Fatalf("bad var should fall back to default, got %q", got)
|
||||
}
|
||||
@@ -34,7 +34,7 @@ func TestRenderSystemOverrideAndFallback(t *testing.T) {
|
||||
// full plannerSystem path: DB body [A] is honored, then the code-owned tail
|
||||
// [C] (intermediate artifact output rules) is ALWAYS appended — editing the body
|
||||
// can't drop it.
|
||||
PromptOverride = func(k string) (string, bool) { return "PLANNER {{.Goal}}", true }
|
||||
SetPromptOverride(func(k string) (string, bool) { return "PLANNER {{.Goal}}", true })
|
||||
got := plannerSystem("capture X", "/data", "/data")
|
||||
if !strings.HasPrefix(got, "PLANNER capture X") {
|
||||
t.Fatalf("plannerSystem body not honored: %q", got)
|
||||
@@ -48,9 +48,9 @@ func TestRenderSystemOverrideAndFallback(t *testing.T) {
|
||||
// the traffic_* tools exist), [C] artifact spec always present. The trafficTool
|
||||
// block is gated on the CA (arg 2), NOT on ProxyAddr — a global egress proxy
|
||||
// with capture off routes traffic but records nothing.
|
||||
PromptOverride = func(k string) (string, bool) {
|
||||
SetPromptOverride(func(k string) (string, bool) {
|
||||
return "{{if .ProxyAddr}}via proxy {{.ProxyAddr}}{{else}}manual{{end}}", true
|
||||
}
|
||||
})
|
||||
recording := workerSystem("127.0.0.1:8080", "/ca.pem", "/data", "/data")
|
||||
if !strings.HasPrefix(recording, "via proxy 127.0.0.1:8080") {
|
||||
t.Fatalf("worker proxy branch body: %q", recording)
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
// Package agent wires real LLM-driven planner and work agents (on top of the
|
||||
// agent-core SDK) to the dual SQLite graph. See the ARTEX architecture design doc
|
||||
// agent-core SDK) to the dual PostgreSQL graph. See the ARTEX architecture design doc
|
||||
// §4.3 (planner) and §4.4 (work agent).
|
||||
//
|
||||
// Provider configuration is read from the environment so the system runs with
|
||||
|
||||
+24
-2
@@ -3,6 +3,7 @@ package agent
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
|
||||
actool "github.com/Autumn-27/norma/tool"
|
||||
)
|
||||
@@ -121,13 +122,34 @@ func BuiltinToolSeeds() []ToolSeed {
|
||||
return out
|
||||
}
|
||||
|
||||
// ToolResolve, if set, post-processes an agent's fully-assembled tool list against
|
||||
// ToolResolveFunc is the ToolResolve hook. If set, it post-processes an agent's
|
||||
// fully-assembled tool list against
|
||||
// the DB tools table: it drops tools not bound to this agent (or globally disabled)
|
||||
// and wraps the rest so the model sees the DB-overridden description/schema and
|
||||
// default inputs get injected. Tools with no matching DB row (MCP/skill/host tools
|
||||
// like traffic) pass through untouched. nil = tools unchanged. Wired in
|
||||
// server/assembly.go.
|
||||
var ToolResolve func(ctx context.Context, agentKey string, tools []actool.CoreTool) []actool.CoreTool
|
||||
type ToolResolveFunc func(ctx context.Context, agentKey string, tools []actool.CoreTool) []actool.CoreTool
|
||||
|
||||
// Guarded like toolAugment: wiring can run while tasks assemble tools.
|
||||
var (
|
||||
toolResolveMu sync.RWMutex
|
||||
toolResolve ToolResolveFunc
|
||||
)
|
||||
|
||||
// SetToolResolve installs (or, with nil, removes) the ToolResolve hook.
|
||||
func SetToolResolve(f ToolResolveFunc) {
|
||||
toolResolveMu.Lock()
|
||||
toolResolve = f
|
||||
toolResolveMu.Unlock()
|
||||
}
|
||||
|
||||
// ToolResolve returns the current ToolResolve hook, or nil when none is set.
|
||||
func ToolResolve() ToolResolveFunc {
|
||||
toolResolveMu.RLock()
|
||||
defer toolResolveMu.RUnlock()
|
||||
return toolResolve
|
||||
}
|
||||
|
||||
// DecorateTool wraps t so Description()/InputSchema() report the DB overrides and
|
||||
// Call() injects scalar parameter defaults (from schema's "default" props) whenever
|
||||
|
||||
+10
-5
@@ -82,12 +82,17 @@ func TestToolRAGCoreKeptAndKRespected(t *testing.T) {
|
||||
// The projection runs after ToolResolve, so a tool the binding/enabled filter
|
||||
// dropped is never selected even when it is the best lexical match.
|
||||
func TestToolRAGNeverSelectsUnboundOrDisabled(t *testing.T) {
|
||||
oldResolve, oldAugment, oldPolicy := ToolResolve, ToolAugment, FindingTrafficBindingEnabled
|
||||
t.Cleanup(func() { ToolResolve, ToolAugment, FindingTrafficBindingEnabled = oldResolve, oldAugment, oldPolicy })
|
||||
ToolAugment, FindingTrafficBindingEnabled = nil, nil
|
||||
oldResolve, oldAugment, oldPolicy := ToolResolve(), ToolAugment(), FindingTrafficBindingEnabled()
|
||||
t.Cleanup(func() {
|
||||
SetToolResolve(oldResolve)
|
||||
SetToolAugment(oldAugment)
|
||||
SetFindingTrafficBindingEnabled(oldPolicy)
|
||||
})
|
||||
SetToolAugment(nil)
|
||||
SetFindingTrafficBindingEnabled(nil)
|
||||
t.Setenv(toolRAGEnv, "on")
|
||||
t.Setenv(toolRAGTopKEnv, "2")
|
||||
ToolResolve = func(_ context.Context, _ string, in []actool.CoreTool) []actool.CoreTool {
|
||||
SetToolResolve(func(_ context.Context, _ string, in []actool.CoreTool) []actool.CoreTool {
|
||||
var out []actool.CoreTool
|
||||
for _, tool := range in {
|
||||
if tool.Name() != "sql_injection_exploit" {
|
||||
@@ -95,7 +100,7 @@ func TestToolRAGNeverSelectsUnboundOrDisabled(t *testing.T) {
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
})
|
||||
base := append(ragCatalog(25), ragTool("sql_injection_exploit", "Exploit SQL injection in web login form", "url"))
|
||||
out, _, cleanup := AugmentTools(WithToolQuery(t.Context(), ragQuery), "worker", base)
|
||||
defer cleanup()
|
||||
|
||||
+76
-14
@@ -2,6 +2,7 @@ package agent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/Autumn-27/norma/harness"
|
||||
)
|
||||
@@ -17,15 +18,47 @@ import (
|
||||
// [prompt body] is editable; which tools are disabled and how many turns the
|
||||
// wrap-up itself gets are code-fixed policy.
|
||||
|
||||
// WrapupOverride, if set, returns the stored wrap-up prompt for an agent key and
|
||||
// wrapupHooksMu guards the four wrap-up hooks in this file: server wiring can run
|
||||
// while agents build their settlement config on other goroutines.
|
||||
var wrapupHooksMu sync.RWMutex
|
||||
|
||||
// wrapupOverride, if set, returns the stored wrap-up prompt for an agent key and
|
||||
// whether a non-empty one exists. Wired by the server to the agents table (like
|
||||
// PromptOverride for system prompts). nil / empty → the built-in default is used.
|
||||
var WrapupOverride func(agentKey string) (string, bool)
|
||||
var wrapupOverride func(agentKey string) (string, bool)
|
||||
|
||||
// WrapupMaxTurnsOverride, if set, returns the admin-configured turn budget for the
|
||||
// SetWrapupOverride installs (or, with nil, removes) the WrapupOverride hook.
|
||||
func SetWrapupOverride(f func(agentKey string) (string, bool)) {
|
||||
wrapupHooksMu.Lock()
|
||||
wrapupOverride = f
|
||||
wrapupHooksMu.Unlock()
|
||||
}
|
||||
|
||||
// WrapupOverride returns the current hook, or nil when none is set.
|
||||
func WrapupOverride() func(agentKey string) (string, bool) {
|
||||
wrapupHooksMu.RLock()
|
||||
defer wrapupHooksMu.RUnlock()
|
||||
return wrapupOverride
|
||||
}
|
||||
|
||||
// wrapupMaxTurnsOverride, if set, returns the admin-configured turn budget for the
|
||||
// wrap-up phase of an agent and whether a positive one exists. Wired to the agents
|
||||
// table. nil / ≤0 → the built-in per-agent default (wrapupTurnDefaults) is used.
|
||||
var WrapupMaxTurnsOverride func(agentKey string) (int, bool)
|
||||
var wrapupMaxTurnsOverride func(agentKey string) (int, bool)
|
||||
|
||||
// SetWrapupMaxTurnsOverride installs (or, with nil, removes) the WrapupMaxTurnsOverride hook.
|
||||
func SetWrapupMaxTurnsOverride(f func(agentKey string) (int, bool)) {
|
||||
wrapupHooksMu.Lock()
|
||||
wrapupMaxTurnsOverride = f
|
||||
wrapupHooksMu.Unlock()
|
||||
}
|
||||
|
||||
// WrapupMaxTurnsOverride returns the current hook, or nil when none is set.
|
||||
func WrapupMaxTurnsOverride() func(agentKey string) (int, bool) {
|
||||
wrapupHooksMu.RLock()
|
||||
defer wrapupHooksMu.RUnlock()
|
||||
return wrapupMaxTurnsOverride
|
||||
}
|
||||
|
||||
// Built-in default wrap-up prompts, keyed by agent key. worker reuses the
|
||||
// historically hardcoded settleWrapUpPrompt (defined in worker.go); planner and
|
||||
@@ -75,8 +108,8 @@ func WrapupTurnsDefault(agentKey string) int {
|
||||
// resolveWrapup returns the effective wrap-up prompt: the DB override (if set and
|
||||
// non-empty) over the built-in default.
|
||||
func resolveWrapup(agentKey string) string {
|
||||
if WrapupOverride != nil {
|
||||
if t, ok := WrapupOverride(agentKey); ok && strings.TrimSpace(t) != "" {
|
||||
if override := WrapupOverride(); override != nil {
|
||||
if t, ok := override(agentKey); ok && strings.TrimSpace(t) != "" {
|
||||
return t
|
||||
}
|
||||
}
|
||||
@@ -86,8 +119,8 @@ func resolveWrapup(agentKey string) string {
|
||||
// resolveWrapupTurns returns the effective wrap-up turn budget: a positive DB
|
||||
// override over the built-in per-agent default.
|
||||
func resolveWrapupTurns(agentKey string) int {
|
||||
if WrapupMaxTurnsOverride != nil {
|
||||
if v, ok := WrapupMaxTurnsOverride(agentKey); ok && v > 0 {
|
||||
if override := WrapupMaxTurnsOverride(); override != nil {
|
||||
if v, ok := override(agentKey); ok && v > 0 {
|
||||
return v
|
||||
}
|
||||
}
|
||||
@@ -117,11 +150,40 @@ func wrapupSettlement(agentKey string, disabledTools []string) *harness.Settleme
|
||||
// WrapupTaskTimeoutOverride / …TurnsOverride: DB overrides for the task-timeout
|
||||
// wrap-up prompt and turns (wired to agents.task_timeout_wrapup_prompt /
|
||||
// _max_turns, worker/planner only).
|
||||
// Guarded by wrapupHooksMu, like the per-run hooks above.
|
||||
var (
|
||||
WrapupTaskTimeoutOverride func(agentKey string) (string, bool)
|
||||
WrapupTaskTimeoutTurnsOverride func(agentKey string) (int, bool)
|
||||
wrapupTaskTimeoutOverride func(agentKey string) (string, bool)
|
||||
wrapupTaskTimeoutTurnsOverride func(agentKey string) (int, bool)
|
||||
)
|
||||
|
||||
// SetWrapupTaskTimeoutOverride installs (or, with nil, removes) the hook.
|
||||
func SetWrapupTaskTimeoutOverride(f func(agentKey string) (string, bool)) {
|
||||
wrapupHooksMu.Lock()
|
||||
wrapupTaskTimeoutOverride = f
|
||||
wrapupHooksMu.Unlock()
|
||||
}
|
||||
|
||||
// WrapupTaskTimeoutOverride returns the current hook, or nil when none is set.
|
||||
func WrapupTaskTimeoutOverride() func(agentKey string) (string, bool) {
|
||||
wrapupHooksMu.RLock()
|
||||
defer wrapupHooksMu.RUnlock()
|
||||
return wrapupTaskTimeoutOverride
|
||||
}
|
||||
|
||||
// SetWrapupTaskTimeoutTurnsOverride installs (or, with nil, removes) the hook.
|
||||
func SetWrapupTaskTimeoutTurnsOverride(f func(agentKey string) (int, bool)) {
|
||||
wrapupHooksMu.Lock()
|
||||
wrapupTaskTimeoutTurnsOverride = f
|
||||
wrapupHooksMu.Unlock()
|
||||
}
|
||||
|
||||
// WrapupTaskTimeoutTurnsOverride returns the current hook, or nil when none is set.
|
||||
func WrapupTaskTimeoutTurnsOverride() func(agentKey string) (int, bool) {
|
||||
wrapupHooksMu.RLock()
|
||||
defer wrapupHooksMu.RUnlock()
|
||||
return wrapupTaskTimeoutTurnsOverride
|
||||
}
|
||||
|
||||
var taskTimeoutWrapupDefaults = map[string]string{
|
||||
"worker": workerTaskTimeoutDefault,
|
||||
"planner": plannerTaskTimeoutDefault,
|
||||
@@ -141,8 +203,8 @@ func TaskTimeoutWrapupDefault(agentKey string) string {
|
||||
// string means this agent has no task-timeout prompt (not worker/planner) — the
|
||||
// caller should then fall back to the per-run prompt.
|
||||
func resolveTaskTimeoutWrapup(agentKey string) string {
|
||||
if WrapupTaskTimeoutOverride != nil {
|
||||
if t, ok := WrapupTaskTimeoutOverride(agentKey); ok && strings.TrimSpace(t) != "" {
|
||||
if override := WrapupTaskTimeoutOverride(); override != nil {
|
||||
if t, ok := override(agentKey); ok && strings.TrimSpace(t) != "" {
|
||||
return t
|
||||
}
|
||||
}
|
||||
@@ -150,8 +212,8 @@ func resolveTaskTimeoutWrapup(agentKey string) string {
|
||||
}
|
||||
|
||||
func resolveTaskTimeoutTurns(agentKey string) int {
|
||||
if WrapupTaskTimeoutTurnsOverride != nil {
|
||||
if v, ok := WrapupTaskTimeoutTurnsOverride(agentKey); ok && v > 0 {
|
||||
if override := WrapupTaskTimeoutTurnsOverride(); override != nil {
|
||||
if v, ok := override(agentKey); ok && v > 0 {
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
# ARTEX_TARGET_OS=linux One target OS in single-target mode.
|
||||
# ARTEX_TARGET_ARCH=amd64 One target arch in single-target mode.
|
||||
# ARTEX_TARGETS=linux/amd64,... Comma-separated targets for multi-target mode.
|
||||
# ARTEX_BUILD_VERSION=v0.3.3 Version embedded in the binary and archive name.
|
||||
# ARTEX_BUILD_VERSION=vX.Y.Z Version embedded in the binary and archive name.
|
||||
# ARTEX_OUTPUT=/path/to/artex Explicit binary path in single-target mode.
|
||||
# ARTEX_OUTPUT_DIR=dist Directory for default binary paths.
|
||||
# ARTEX_PACKAGE=1 Create a zip archive for each target.
|
||||
@@ -97,7 +97,7 @@ if [ -z "${ARTEX_BUILD_VERSION:-}" ]; then
|
||||
ARTEX_BUILD_VERSION="dev"
|
||||
fi
|
||||
fi
|
||||
# Release tags are commonly passed as v0.3.3; keep the binary version consistent.
|
||||
# Release tags are commonly passed with a v prefix; keep the binary version consistent.
|
||||
ARTEX_BUILD_VERSION="${ARTEX_BUILD_VERSION#v}"
|
||||
ARTEX_OUTPUT_DIR="${ARTEX_OUTPUT_DIR:-dist}"
|
||||
ARTEX_PACKAGE_DIR="${ARTEX_PACKAGE_DIR:-$ARTEX_OUTPUT_DIR}"
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
// Command artex runs the ARTEX backend: the dual SQLite graph stores,
|
||||
// Command artex runs the ARTEX backend: the dual PostgreSQL graph stores,
|
||||
// the event-driven exploration engine, and the JSON HTTP API consumed by the
|
||||
// shadcn/ui frontend.
|
||||
package main
|
||||
@@ -50,7 +50,7 @@ func main() {
|
||||
func run() int {
|
||||
var (
|
||||
addr = flag.String("addr", "127.0.0.1:8787", "HTTP listen address")
|
||||
dataDir = flag.String("data", filepath.Join(config.BaseDir(), "data"), "data directory for SQLite stores (default: data/ next to the executable)")
|
||||
dataDir = flag.String("data", filepath.Join(config.BaseDir(), "data"), "runtime data directory: traffic index, task files, transcripts (default: data/ next to the executable)")
|
||||
proxy = flag.String("proxy", "127.0.0.1:8788", "traffic recording proxy address (empty to disable)")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
+3
-1
@@ -160,7 +160,8 @@ func calcCSegment(ipStr string) string {
|
||||
return ipnet.String()
|
||||
}
|
||||
|
||||
// normalizeURL lowercases scheme and host, strips trailing slash from bare roots.
|
||||
// normalizeURL lowercases scheme and host, drops userinfo (credentials must
|
||||
// never reach assets.url), and strips trailing slash from bare roots.
|
||||
func normalizeURL(raw string) string {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
@@ -168,6 +169,7 @@ func normalizeURL(raw string) string {
|
||||
}
|
||||
u.Scheme = strings.ToLower(u.Scheme)
|
||||
u.Host = strings.ToLower(u.Host)
|
||||
u.User = nil
|
||||
s := u.String()
|
||||
if strings.HasSuffix(s, "/") && u.Path == "/" && u.RawQuery == "" {
|
||||
s = strings.TrimSuffix(s, "/")
|
||||
|
||||
@@ -893,6 +893,10 @@ func TestNormalizeURL(t *testing.T) {
|
||||
{"HTTPS://Example.COM/path", "https://example.com/path"},
|
||||
{"http://example.com/", "http://example.com"},
|
||||
{"http://example.com/page", "http://example.com/page"},
|
||||
{"https://u:p@Example.com/app?x=1", "https://example.com/app?x=1"},
|
||||
{"https://user@example.com/", "https://example.com"},
|
||||
{"https://u:p@example.com:8443/users/@alice?x=1", "https://example.com:8443/users/@alice?x=1"},
|
||||
{"https://example.com/@me?r=http://a:b@x/", "https://example.com/@me?r=http://a:b@x/"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got := normalizeURL(tc.raw)
|
||||
@@ -902,6 +906,139 @@ func TestNormalizeURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// storedURLRow reads back the url, domain and port of one asset row.
|
||||
func storedURLRow(t *testing.T, d *DB, id int64) (string, string, int) {
|
||||
t.Helper()
|
||||
var u, domain *string
|
||||
var port *int
|
||||
if err := d.QueryRow(`SELECT url, domain, port FROM assets WHERE id = $1`, id).Scan(&u, &domain, &port); err != nil {
|
||||
t.Fatalf("read asset %d: %v", id, err)
|
||||
}
|
||||
var us, ds string
|
||||
var p int
|
||||
if u != nil {
|
||||
us = *u
|
||||
}
|
||||
if domain != nil {
|
||||
ds = *domain
|
||||
}
|
||||
if port != nil {
|
||||
p = *port
|
||||
}
|
||||
return us, ds, p
|
||||
}
|
||||
|
||||
func TestUpsertHTTPServiceStripsUserinfo(t *testing.T) {
|
||||
d, av2, _ := testSetup(t)
|
||||
defer d.Close()
|
||||
cleanup := func() {
|
||||
d.Exec(`DELETE FROM assets WHERE url LIKE '%storeuserinfo-svc.com%' OR domain LIKE '%storeuserinfo-svc.com' OR root_domain = 'storeuserinfo-svc.com'`)
|
||||
}
|
||||
cleanup()
|
||||
defer cleanup()
|
||||
|
||||
id1, err := av2.UpsertHTTPService(UpsertHTTPServiceReq{URL: "https://alice:s3cr3t-one@App.storeuserinfo-svc.com/app?x=1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u, domain, port := storedURLRow(t, d, id1)
|
||||
if u != "https://app.storeuserinfo-svc.com/app?x=1" || domain != "app.storeuserinfo-svc.com" || port != 443 {
|
||||
t.Fatalf("stored url=%q domain=%q port=%d", u, domain, port)
|
||||
}
|
||||
|
||||
// Same URL, different credentials: deduplicated onto the same row.
|
||||
id2, err := av2.UpsertHTTPService(UpsertHTTPServiceReq{URL: "https://bob:s3cr3t-two@app.storeuserinfo-svc.com/app?x=1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id2 != id1 {
|
||||
t.Errorf("dedup across credentials failed: %d vs %d", id2, id1)
|
||||
}
|
||||
var n int
|
||||
d.QueryRow(`SELECT count(*) FROM assets WHERE type = 'service' AND service_type = 'http' AND url LIKE '%storeuserinfo-svc.com/app%'`).Scan(&n)
|
||||
if n != 1 {
|
||||
t.Errorf("want 1 http service row, got %d", n)
|
||||
}
|
||||
d.QueryRow(`SELECT count(*) FROM assets WHERE url LIKE '%s3cr3t%' AND url LIKE '%storeuserinfo-svc.com%'`).Scan(&n)
|
||||
if n != 0 {
|
||||
t.Errorf("credentials stored in %d asset url(s)", n)
|
||||
}
|
||||
|
||||
// @ in the path is kept; only the userinfo goes.
|
||||
id3, err := av2.UpsertHTTPService(UpsertHTTPServiceReq{URL: "https://u:p@app.storeuserinfo-svc.com:8443/users/@alice?x=1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u, domain, port = storedURLRow(t, d, id3)
|
||||
if u != "https://app.storeuserinfo-svc.com:8443/users/@alice?x=1" || domain != "app.storeuserinfo-svc.com" || port != 8443 {
|
||||
t.Errorf("@ path: stored url=%q domain=%q port=%d", u, domain, port)
|
||||
}
|
||||
|
||||
// Regression: a URL without userinfo is stored unchanged.
|
||||
plain := "https://plain.storeuserinfo-svc.com/@me?q=1"
|
||||
id4, err := av2.UpsertHTTPService(UpsertHTTPServiceReq{URL: plain})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if u, _, _ = storedURLRow(t, d, id4); u != plain {
|
||||
t.Errorf("plain url: want %q, got %q", plain, u)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertEndpointStripsUserinfo(t *testing.T) {
|
||||
d, av2, _ := testSetup(t)
|
||||
defer d.Close()
|
||||
cleanup := func() {
|
||||
d.Exec(`DELETE FROM assets WHERE url LIKE '%storeuserinfo-ep.com%' OR domain LIKE '%storeuserinfo-ep.com' OR root_domain = 'storeuserinfo-ep.com'`)
|
||||
}
|
||||
cleanup()
|
||||
defer cleanup()
|
||||
|
||||
id1, err := av2.UpsertEndpoint(UpsertEndpointReq{URL: "https://alice:s3cr3t-one@api.storeuserinfo-ep.com/users?id=1", Method: "GET"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u, domain, port := storedURLRow(t, d, id1)
|
||||
if u != "https://api.storeuserinfo-ep.com/users?id=1" || domain != "api.storeuserinfo-ep.com" || port != 443 {
|
||||
t.Fatalf("stored url=%q domain=%q port=%d", u, domain, port)
|
||||
}
|
||||
|
||||
id2, err := av2.UpsertEndpoint(UpsertEndpointReq{URL: "https://bob:s3cr3t-two@api.storeuserinfo-ep.com/users?id=1", Method: "GET"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id2 != id1 {
|
||||
t.Errorf("endpoint dedup across credentials failed: %d vs %d", id2, id1)
|
||||
}
|
||||
var n int
|
||||
d.QueryRow(`SELECT count(*) FROM assets WHERE type = 'endpoint' AND url LIKE '%storeuserinfo-ep.com/users%'`).Scan(&n)
|
||||
if n != 1 {
|
||||
t.Errorf("want 1 endpoint row, got %d", n)
|
||||
}
|
||||
d.QueryRow(`SELECT count(*) FROM assets WHERE url LIKE '%s3cr3t%' AND url LIKE '%storeuserinfo-ep.com%'`).Scan(&n)
|
||||
if n != 0 {
|
||||
t.Errorf("credentials stored in %d asset url(s)", n)
|
||||
}
|
||||
|
||||
id3, err := av2.UpsertEndpoint(UpsertEndpointReq{URL: "http://u:p@api.storeuserinfo-ep.com/users/@alice?x=1", Method: "POST"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u, _, port = storedURLRow(t, d, id3)
|
||||
if u != "http://api.storeuserinfo-ep.com/users/@alice?x=1" || port != 80 {
|
||||
t.Errorf("@ path: stored url=%q port=%d", u, port)
|
||||
}
|
||||
|
||||
plain := "https://plain.storeuserinfo-ep.com/@me?q=1"
|
||||
id4, err := av2.UpsertEndpoint(UpsertEndpointReq{URL: plain, Method: "GET"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if u, _, _ = storedURLRow(t, d, id4); u != plain {
|
||||
t.Errorf("plain url: want %q, got %q", plain, u)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetPaginationUsesStableIDTieBreaker(t *testing.T) {
|
||||
d, assets, _ := testSetup(t)
|
||||
defer d.Close()
|
||||
|
||||
+6
-50
@@ -153,57 +153,13 @@ type LLMRecord struct {
|
||||
RawResponse string `json:"raw_response,omitempty"`
|
||||
}
|
||||
|
||||
const llmRecordsSchema = `
|
||||
CREATE TABLE IF NOT EXISTS llm_records (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
ts TIMESTAMPTZ DEFAULT now(),
|
||||
model TEXT,
|
||||
profile_name TEXT,
|
||||
session_id TEXT,
|
||||
task_id TEXT,
|
||||
worker TEXT,
|
||||
latency_ms INTEGER,
|
||||
input_tokens INTEGER,
|
||||
output_tokens INTEGER,
|
||||
cache_read INTEGER,
|
||||
cache_write INTEGER,
|
||||
status TEXT,
|
||||
error TEXT,
|
||||
request_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_session ON llm_records(session_id);
|
||||
`
|
||||
|
||||
// llmRecordsMigrate adds new columns to existing tables.
|
||||
const llmRecordsMigrate = `
|
||||
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 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, by
|
||||
// re-running the idempotent migration 0001_llm_records.sql. Open already applies
|
||||
// it through the migration runner; this stays for callers that want the table
|
||||
// guaranteed. It takes the schema lock in its own transaction, so it must not be
|
||||
// called while Open holds that lock.
|
||||
func (d *DB) EnsureLLMRecordsTable() error {
|
||||
tx, err := d.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck
|
||||
if err := coordinateWithSchemaMigration(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(llmRecordsSchema); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(llmRecordsMigrate); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
return d.execEmbeddedMigration(llmRecordsMigration)
|
||||
}
|
||||
|
||||
// InsertLLMRecord stores one LLM call record.
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// largeCoverageFixture is a task scoped by 800 subdomains spread over 200 root
|
||||
// domains and 20 companies, with 1,000 services, 1,200 endpoints and 2,000
|
||||
// anchored facts. Every tenth root has no asset row, so the graph needs both
|
||||
// real and synthetic ("r:<domain>") connector roots.
|
||||
type largeCoverageFixture struct {
|
||||
taskID, expID int64
|
||||
suffix string
|
||||
}
|
||||
|
||||
const (
|
||||
lcRoots = 200
|
||||
lcCompanies = 20
|
||||
lcSubs = 800
|
||||
lcServices = 1000
|
||||
lcEndpoints = 1200
|
||||
lcFacts = 2000
|
||||
)
|
||||
|
||||
func seedLargeCoverageFixture(tb testing.TB, d *DB) largeCoverageFixture {
|
||||
tb.Helper()
|
||||
task, err := d.CreateTask("large coverage graph", "large coverage graph", nil, 0, 0)
|
||||
if err != nil {
|
||||
tb.Fatal(err)
|
||||
}
|
||||
f := largeCoverageFixture{taskID: task.ID, expID: task.ExplorationID, suffix: fmt.Sprintf("lc%d", task.ID)}
|
||||
tb.Cleanup(func() {
|
||||
_, _ = d.Exec(`DELETE FROM explorations WHERE id=$1`, f.expID)
|
||||
_ = d.DeleteTask(f.taskID)
|
||||
_, _ = d.Exec(`DELETE FROM assets WHERE root_domain LIKE '%-'||$1||'.cov.test'`, f.suffix)
|
||||
_, _ = d.Exec(`DELETE FROM companies WHERE nkey LIKE 'lcco-'||$1||'-%'`, f.suffix)
|
||||
})
|
||||
exec := func(q string, args ...any) {
|
||||
tb.Helper()
|
||||
if _, err := d.Exec(q, args...); err != nil {
|
||||
tb.Fatalf("%v\n%s", err, q)
|
||||
}
|
||||
}
|
||||
// root g: r<g>-<suffix>.cov.test, company 1+(g-1)%lcCompanies.
|
||||
// subdomain i: s<i>.<root 1+(i-1)%lcRoots>.
|
||||
exec(`INSERT INTO companies(name,nkey)
|
||||
SELECT 'large co '||g||' '||$1, 'lcco-'||$1||'-'||g FROM generate_series(1,$2) g`, f.suffix, lcCompanies)
|
||||
exec(`INSERT INTO assets(type,domain,root_domain,company_id)
|
||||
SELECT 'root_domain', 'r'||g||'-'||$1||'.cov.test', 'r'||g||'-'||$1||'.cov.test',
|
||||
(SELECT id FROM companies WHERE nkey='lcco-'||$1||'-'||(1+(g-1)%$3))
|
||||
FROM generate_series(1,$2) g WHERE g%10<>0`, f.suffix, lcRoots, lcCompanies)
|
||||
exec(`INSERT INTO assets(type,domain,root_domain,company_id)
|
||||
SELECT 'subdomain', 's'||i||'.r'||r||'-'||$1||'.cov.test', 'r'||r||'-'||$1||'.cov.test',
|
||||
(SELECT id FROM companies WHERE nkey='lcco-'||$1||'-'||(1+(r-1)%$4))
|
||||
FROM generate_series(1,$3) i, LATERAL (SELECT 1+(i-1)%$2 AS r) x`, f.suffix, lcRoots, lcSubs, lcCompanies)
|
||||
// service j hangs off subdomain 1+(j-1)%lcSubs: port 443 for the first
|
||||
// lcSubs services, 8443 for the rest.
|
||||
exec(`INSERT INTO assets(type,service_type,url,domain,root_domain,port,company_id)
|
||||
SELECT 'service','http', 'https://'||s.domain||':'||p||'/', s.domain, s.root_domain, p, s.company_id
|
||||
FROM generate_series(1,$2) j
|
||||
CROSS JOIN LATERAL (SELECT CASE WHEN j<=$3 THEN 443 ELSE 8443 END AS p) port
|
||||
JOIN assets s ON s.type='subdomain' AND s.root_domain LIKE '%-'||$1||'.cov.test'
|
||||
AND s.domain LIKE 's'||(1+(j-1)%$3)||'.%'`, f.suffix, lcServices, lcSubs)
|
||||
exec(`INSERT INTO assets(type,url,method,domain,root_domain,port,company_id)
|
||||
SELECT 'endpoint', 'https://'||s.domain||'/p/'||k, 'GET', s.domain, s.root_domain, 443, s.company_id
|
||||
FROM generate_series(1,$2) k
|
||||
JOIN assets s ON s.type='subdomain' AND s.root_domain LIKE '%-'||$1||'.cov.test'
|
||||
AND s.domain LIKE 's'||(1+(k-1)%$3)||'.%'`, f.suffix, lcEndpoints, lcSubs)
|
||||
exec(`INSERT INTO task_scope(task_id,kind,domain,source)
|
||||
SELECT $1,'subdomain',domain,'manual' FROM assets
|
||||
WHERE type='subdomain' AND root_domain LIKE '%-'||$2||'.cov.test'`, f.taskID, f.suffix)
|
||||
exec(`INSERT INTO exploration_nodes(exploration_id,kind,state,payload)
|
||||
SELECT $1,'fact','confirmed',jsonb_build_object('summary','large coverage fact '||g) FROM generate_series(1,$2) g`, f.expID, lcFacts)
|
||||
exec(`WITH fn AS (
|
||||
SELECT id, row_number() OVER (ORDER BY id) rn FROM exploration_nodes
|
||||
WHERE exploration_id=$1 AND kind='fact' AND payload->>'summary' LIKE 'large coverage fact %'),
|
||||
an AS (
|
||||
SELECT id, row_number() OVER (ORDER BY id) rn FROM assets
|
||||
WHERE type<>'root_domain' AND root_domain LIKE '%-'||$2||'.cov.test')
|
||||
INSERT INTO exploration_anchors(node_id,asset_id) SELECT fn.id, an.id FROM fn JOIN an USING (rn)`, f.expID, f.suffix)
|
||||
exec(`ANALYZE assets, task_scope, exploration_nodes, exploration_anchors, companies`)
|
||||
return f
|
||||
}
|
||||
|
||||
func TestBuildCoverageGraphLargeInvariants(t *testing.T) {
|
||||
d, err := Open(testDSN(t))
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
t.Cleanup(func() { d.Close() })
|
||||
f := seedLargeCoverageFixture(t, d)
|
||||
|
||||
g, err := d.Assets().BuildCoverageGraph(f.taskID, f.expID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
const (
|
||||
syntheticRoots = lcRoots / 10
|
||||
realRoots = lcRoots - syntheticRoots
|
||||
inScope = lcSubs + lcServices + lcEndpoints
|
||||
wantNodes = inScope + lcRoots + lcCompanies
|
||||
// subdomain→root, service→subdomain, endpoint→service (port 443), and
|
||||
// real root→company; a synthetic root has no company.
|
||||
wantEdges = lcSubs + lcServices + lcEndpoints + realRoots
|
||||
)
|
||||
if len(g.Nodes) != wantNodes || len(g.Edges) != wantEdges {
|
||||
t.Fatalf("graph size: nodes=%d edges=%d, want %d/%d", len(g.Nodes), len(g.Edges), wantNodes, wantEdges)
|
||||
}
|
||||
|
||||
byKey := make(map[string]CoverageGraphNode, len(g.Nodes))
|
||||
kinds := map[string]int{}
|
||||
var tested, scoped, synthetic, realConnector int
|
||||
for _, n := range g.Nodes {
|
||||
if _, dup := byKey[n.Key]; dup {
|
||||
t.Fatalf("duplicate node key %q", n.Key)
|
||||
}
|
||||
byKey[n.Key] = n
|
||||
kinds[n.Kind]++
|
||||
if n.Tested {
|
||||
tested++
|
||||
}
|
||||
if n.InScope {
|
||||
scoped++
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(n.Key, "r:"):
|
||||
synthetic++
|
||||
if n.Kind != "root_domain" || n.InScope || n.AssetID != 0 || n.Key != "r:"+n.Domain {
|
||||
t.Fatalf("synthetic root: %+v", n)
|
||||
}
|
||||
case n.Kind == "root_domain":
|
||||
realConnector++
|
||||
if n.InScope || n.AssetID == 0 || n.CompanyID == 0 {
|
||||
t.Fatalf("connector root: %+v", n)
|
||||
}
|
||||
case n.Kind == "company":
|
||||
if n.InScope || n.Label == "" || n.Key != companyKey(n.CompanyID) {
|
||||
t.Fatalf("company node: %+v", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
if kinds["subdomain"] != lcSubs || kinds["service"] != lcServices || kinds["endpoint"] != lcEndpoints ||
|
||||
kinds["root_domain"] != lcRoots || kinds["company"] != lcCompanies {
|
||||
t.Fatalf("node kinds: %v", kinds)
|
||||
}
|
||||
if synthetic != syntheticRoots || realConnector != realRoots {
|
||||
t.Fatalf("connector roots: synthetic=%d real=%d, want %d/%d", synthetic, realConnector, syntheticRoots, realRoots)
|
||||
}
|
||||
if scoped != inScope || tested != lcFacts {
|
||||
t.Fatalf("in_scope=%d tested=%d, want %d/%d", scoped, tested, inScope, lcFacts)
|
||||
}
|
||||
|
||||
seenEdge := map[CoverageGraphEdge]bool{}
|
||||
for _, e := range g.Edges {
|
||||
src, okSrc := byKey[e.Src]
|
||||
dst, okDst := byKey[e.Dst]
|
||||
if !okSrc || !okDst || e.Src == e.Dst {
|
||||
t.Fatalf("edge %+v points to a missing node", e)
|
||||
}
|
||||
if seenEdge[e] {
|
||||
t.Fatalf("duplicate edge %+v", e)
|
||||
}
|
||||
seenEdge[e] = true
|
||||
want := map[string]string{"subdomain": "root_domain", "service": "subdomain", "endpoint": "service", "root_domain": "company"}[src.Kind]
|
||||
if dst.Kind != want {
|
||||
t.Fatalf("edge %s(%s) -> %s(%s), want parent kind %s", e.Src, src.Kind, e.Dst, dst.Kind, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkBuildCoverageGraphLarge locks in the cost of the coverage graph on
|
||||
// the large fixture (statement count and query plan). No time assertion.
|
||||
func BenchmarkBuildCoverageGraphLarge(b *testing.B) {
|
||||
dsn, _, err := DSN()
|
||||
if err != nil {
|
||||
b.Skipf("no database config (%v) - skipping", err)
|
||||
}
|
||||
d, err := Open(dsn)
|
||||
if err != nil {
|
||||
b.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
b.Cleanup(func() { d.Close() })
|
||||
f := seedLargeCoverageFixture(b, d)
|
||||
store := d.Assets()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
g, err := store.BuildCoverageGraph(f.taskID, f.expID)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if len(g.Nodes) == 0 {
|
||||
b.Fatal("empty graph")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,15 @@
|
||||
// Package db is the PostgreSQL data source for ARTEX (replacing the old graph
|
||||
// single-file SQLite). It opens the connection, applies the schema, and seeds
|
||||
// the built-in agents and variable catalog.
|
||||
//
|
||||
// Schema changes:
|
||||
// - schema.sql remains the idempotent base, executed on every startup.
|
||||
// - Any new DDL goes in a numbered migration, migrations/NNNN_slug.sql.
|
||||
// Open applies pending migrations once, in version order, and records
|
||||
// each in schema_migrations with the SHA-256 of its file.
|
||||
// - A published migration is never edited: Open refuses to start when an
|
||||
// applied migration's checksum no longer matches its file. Fix forward
|
||||
// with a new migration instead.
|
||||
package db
|
||||
|
||||
import (
|
||||
@@ -9,6 +18,7 @@ import (
|
||||
_ "embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -153,8 +163,15 @@ func ensureDatabase(dsn string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Open connects, applies the schema (idempotent), and seeds builtin rows.
|
||||
// Open connects, applies the schema (idempotent) and the embedded versioned
|
||||
// migrations, and seeds builtin rows.
|
||||
func Open(dsn string) (*DB, error) {
|
||||
return openWithMigrations(dsn, migrationsFS())
|
||||
}
|
||||
|
||||
// openWithMigrations is Open with the migration set as a parameter, so tests
|
||||
// can pass an fstest.MapFS.
|
||||
func openWithMigrations(dsn string, migrations fs.FS) (*DB, error) {
|
||||
if err := ensureDatabase(dsn); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -187,6 +204,11 @@ func Open(dsn string) (*DB, error) {
|
||||
if err := applySchemaWithRetry(context.Background(), conn, time.Sleep); err != nil {
|
||||
return fmt.Errorf("apply schema: %w", err)
|
||||
}
|
||||
// Runs on conn, the lock holder; it must not call EnsureLLM*Table,
|
||||
// whose own lock request would wait on conn forever.
|
||||
if err := applyMigrations(context.Background(), conn, migrations); err != nil {
|
||||
return fmt.Errorf("apply migrations: %w", err)
|
||||
}
|
||||
if err := d.seedBuiltins(); err != nil {
|
||||
return fmt.Errorf("seed builtins: %w", err)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"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
|
||||
// them through the ALTERs in 0001_llm_records.sql. 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) {
|
||||
@@ -17,6 +18,11 @@ func TestLLMRecordsMigrateAddsRawColumnsToOldTable(t *testing.T) {
|
||||
if err := d.EnsureLLMRecordsTable(); err != nil {
|
||||
t.Fatalf("ensure: %v", err)
|
||||
}
|
||||
content, err := fs.ReadFile(migrationsFS(), llmRecordsMigration)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
llmRecordsMigrateSQL := string(content)
|
||||
|
||||
tx, err := d.Begin()
|
||||
if err != nil {
|
||||
@@ -28,7 +34,7 @@ func TestLLMRecordsMigrateAddsRawColumnsToOldTable(t *testing.T) {
|
||||
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 {
|
||||
if _, err := tx.Exec(llmRecordsMigrateSQL); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
@@ -45,7 +51,7 @@ func TestLLMRecordsMigrateAddsRawColumnsToOldTable(t *testing.T) {
|
||||
}
|
||||
|
||||
// Re-running must stay a no-op (the migration runs on every startup).
|
||||
if _, err := tx.Exec(llmRecordsMigrate); err != nil {
|
||||
if _, err := tx.Exec(llmRecordsMigrateSQL); err != nil {
|
||||
t.Fatalf("migrate is not idempotent: %v", err)
|
||||
}
|
||||
|
||||
|
||||
+4
-34
@@ -26,41 +26,11 @@ type LLMUsage struct {
|
||||
Status string `json:"status"` // ok | error
|
||||
}
|
||||
|
||||
const llmUsageSchema = `
|
||||
CREATE TABLE IF NOT EXISTS llm_usage (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
ts TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
task_id TEXT,
|
||||
exploration_id BIGINT,
|
||||
worker TEXT,
|
||||
model TEXT,
|
||||
profile_name TEXT,
|
||||
latency_ms INTEGER,
|
||||
input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_read INTEGER NOT NULL DEFAULT 0,
|
||||
cache_write INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_llm_usage_task ON llm_usage(task_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_llm_usage_model ON llm_usage(task_id, model);
|
||||
CREATE INDEX IF NOT EXISTS idx_llm_usage_exp ON llm_usage(exploration_id);
|
||||
`
|
||||
|
||||
// EnsureLLMUsageTable creates the llm_usage metering table if it does not exist.
|
||||
// EnsureLLMUsageTable creates the llm_usage metering table if it does not exist,
|
||||
// by re-running the idempotent migration 0002_llm_usage.sql (see
|
||||
// EnsureLLMRecordsTable for the locking rule).
|
||||
func (d *DB) EnsureLLMUsageTable() error {
|
||||
tx, err := d.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck
|
||||
if err := coordinateWithSchemaMigration(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(llmUsageSchema); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
return d.execEmbeddedMigration(llmUsageMigration)
|
||||
}
|
||||
|
||||
// InsertLLMUsage appends one metering row. Best-effort: callers log and continue on
|
||||
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Versioned migrations, applied by Open after schema.sql (rules in the package
|
||||
// comment, db.go).
|
||||
//
|
||||
//go:embed migrations/*.sql
|
||||
var embeddedMigrationFiles embed.FS
|
||||
|
||||
// Embedded migration files that EnsureLLMRecordsTable / EnsureLLMUsageTable
|
||||
// re-run on demand.
|
||||
const (
|
||||
llmRecordsMigration = "0001_llm_records.sql"
|
||||
llmUsageMigration = "0002_llm_usage.sql"
|
||||
)
|
||||
|
||||
// migrationsFS is the embedded migrations directory, rooted so file names are
|
||||
// the bare NNNN_slug.sql names.
|
||||
func migrationsFS() fs.FS {
|
||||
sub, err := fs.Sub(embeddedMigrationFiles, "migrations")
|
||||
if err != nil {
|
||||
panic(err) // the embed pattern guarantees the directory exists
|
||||
}
|
||||
return sub
|
||||
}
|
||||
|
||||
const schemaMigrationsTable = `
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
name TEXT,
|
||||
checksum TEXT,
|
||||
applied_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
`
|
||||
|
||||
var migrationNameRE = regexp.MustCompile(`^([0-9]{4})_[a-z0-9_]+\.sql$`)
|
||||
|
||||
type migration struct {
|
||||
version int
|
||||
name string // file name, e.g. 0001_llm_records.sql
|
||||
sql string
|
||||
checksum string // hex SHA-256 of the file content
|
||||
}
|
||||
|
||||
// loadMigrations reads every *.sql file at the root of fsys, sorted by version.
|
||||
// A .sql file that does not follow NNNN_slug.sql, or a duplicated version, is
|
||||
// an error so a typo cannot silently skip a migration.
|
||||
func loadMigrations(fsys fs.FS) ([]migration, error) {
|
||||
entries, err := fs.ReadDir(fsys, ".")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read migrations: %w", err)
|
||||
}
|
||||
var out []migration
|
||||
seen := map[int]string{}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || path.Ext(e.Name()) != ".sql" {
|
||||
continue
|
||||
}
|
||||
m := migrationNameRE.FindStringSubmatch(e.Name())
|
||||
if m == nil {
|
||||
return nil, fmt.Errorf("migration file %s: name must be NNNN_slug.sql", e.Name())
|
||||
}
|
||||
version, _ := strconv.Atoi(m[1])
|
||||
if version == 0 {
|
||||
return nil, fmt.Errorf("migration file %s: version must be > 0", e.Name())
|
||||
}
|
||||
if prev, dup := seen[version]; dup {
|
||||
return nil, fmt.Errorf("migration version %04d is used by both %s and %s", version, prev, e.Name())
|
||||
}
|
||||
seen[version] = e.Name()
|
||||
content, err := fs.ReadFile(fsys, e.Name())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read migration %s: %w", e.Name(), err)
|
||||
}
|
||||
sum := sha256.Sum256(content)
|
||||
out = append(out, migration{version: version, name: e.Name(), sql: string(content), checksum: hex.EncodeToString(sum[:])})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].version < out[j].version })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// applyMigrations brings the database up to the migrations in fsys. conn must
|
||||
// be the connection that holds the schema advisory lock (see Open); every
|
||||
// statement runs on it, so nothing here waits on that lock again.
|
||||
//
|
||||
// It fails closed before applying anything when the database records a version
|
||||
// this binary does not ship, or an applied migration whose name or checksum
|
||||
// differs from the file, or a pending migration is older than the newest
|
||||
// applied one. Each pending migration then runs in its own
|
||||
// transaction together with its schema_migrations row; a failure rolls that one
|
||||
// back and stops, leaving earlier migrations applied.
|
||||
func applyMigrations(ctx context.Context, conn *sql.Conn, fsys fs.FS) error {
|
||||
migrations, err := loadMigrations(fsys)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := conn.ExecContext(ctx, schemaMigrationsTable); err != nil {
|
||||
return fmt.Errorf("create schema_migrations: %w", err)
|
||||
}
|
||||
|
||||
type appliedRow struct{ name, checksum string }
|
||||
applied := map[int]appliedRow{}
|
||||
rows, err := conn.QueryContext(ctx, `SELECT version, COALESCE(name,''), COALESCE(checksum,'') FROM schema_migrations ORDER BY version`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read schema_migrations: %w", err)
|
||||
}
|
||||
var appliedVersions []int
|
||||
for rows.Next() {
|
||||
var v int
|
||||
var r appliedRow
|
||||
if err := rows.Scan(&v, &r.name, &r.checksum); err != nil {
|
||||
rows.Close()
|
||||
return fmt.Errorf("read schema_migrations: %w", err)
|
||||
}
|
||||
applied[v] = r
|
||||
appliedVersions = append(appliedVersions, v)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return fmt.Errorf("read schema_migrations: %w", err)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return fmt.Errorf("read schema_migrations: %w", err)
|
||||
}
|
||||
|
||||
byVersion := map[int]migration{}
|
||||
for _, m := range migrations {
|
||||
byVersion[m.version] = m
|
||||
}
|
||||
for _, v := range appliedVersions {
|
||||
r := applied[v]
|
||||
m, ok := byVersion[v]
|
||||
if !ok {
|
||||
return fmt.Errorf("migration %04d (%s) is applied in the database but missing from this binary", v, r.name)
|
||||
}
|
||||
if r.name != m.name {
|
||||
return fmt.Errorf("migration %04d: database records file %s but this binary has %s", v, r.name, m.name)
|
||||
}
|
||||
if r.checksum != m.checksum {
|
||||
return fmt.Errorf("migration %04d (%s): checksum changed since it was applied (database %s, file %s); published migrations must not be edited", v, m.name, r.checksum, m.checksum)
|
||||
}
|
||||
}
|
||||
|
||||
// A pending version below the newest applied one would run out of order on
|
||||
// this database: refuse it before writing anything.
|
||||
if len(appliedVersions) > 0 {
|
||||
maxVersion := appliedVersions[len(appliedVersions)-1]
|
||||
for _, m := range migrations {
|
||||
if _, done := applied[m.version]; !done && m.version < maxVersion {
|
||||
return fmt.Errorf("migration %s (%04d) is older than the applied migration %s (%04d); migrations must be appended, never inserted",
|
||||
m.name, m.version, applied[maxVersion].name, maxVersion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, m := range migrations {
|
||||
if _, done := applied[m.version]; done {
|
||||
continue
|
||||
}
|
||||
if err := applyMigration(ctx, conn, m); err != nil {
|
||||
return fmt.Errorf("migration %04d (%s): %w", m.version, m.name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyMigration(ctx context.Context, conn *sql.Conn, m migration) error {
|
||||
tx, err := conn.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck // no-op after Commit
|
||||
// No args: pgx sends it over the simple protocol, which allows several statements.
|
||||
if _, err := tx.ExecContext(ctx, m.sql); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(version, name, checksum) VALUES ($1, $2, $3)`, m.version, m.name, m.checksum); err != nil {
|
||||
return fmt.Errorf("record: %w", err)
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// execEmbeddedMigration re-runs one embedded (idempotent) migration file in its
|
||||
// own transaction under the schema lock. It does not touch schema_migrations.
|
||||
// The lock is taken on a pooled connection, so this must never be called from
|
||||
// inside Open's withSchemaMigrationLock callback: it would wait on the lock
|
||||
// that callback's connection holds.
|
||||
func (d *DB) execEmbeddedMigration(name string) error {
|
||||
content, err := fs.ReadFile(migrationsFS(), name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tx, err := d.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck
|
||||
if err := coordinateWithSchemaMigration(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(string(content)); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"io/fs"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoadMigrationsValidation(t *testing.T) {
|
||||
ms, err := loadMigrations(fstest.MapFS{
|
||||
"0010_b.sql": {Data: []byte("SELECT 10;")},
|
||||
"0002_a.sql": {Data: []byte("SELECT 2;")},
|
||||
"README.md": {Data: []byte("not a migration")},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ms) != 2 || ms[0].version != 2 || ms[1].version != 10 {
|
||||
t.Fatalf("want versions [2 10], got %+v", ms)
|
||||
}
|
||||
sum := sha256.Sum256([]byte("SELECT 2;"))
|
||||
if ms[0].checksum != hex.EncodeToString(sum[:]) || ms[0].name != "0002_a.sql" {
|
||||
t.Fatalf("migration 0002 = %+v", ms[0])
|
||||
}
|
||||
|
||||
for name, fsys := range map[string]fstest.MapFS{
|
||||
"bad name": {"2_a.sql": {Data: []byte("SELECT 1;")}},
|
||||
"duplicate version": {"0001_a.sql": {Data: []byte("SELECT 1;")}, "0001_b.sql": {Data: []byte("SELECT 1;")}},
|
||||
} {
|
||||
if _, err := loadMigrations(fsys); err == nil {
|
||||
t.Errorf("%s: want error", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// scratchDSN returns the DSN of a new, empty database named after the test
|
||||
// database plus suffix, dropped again when the test ends.
|
||||
func scratchDSN(t *testing.T, suffix string) string {
|
||||
t.Helper()
|
||||
u, err := url.Parse(testDSN(t))
|
||||
if err != nil || strings.Trim(u.Path, "/") == "" {
|
||||
t.Skipf("DSN has no database name — skipping")
|
||||
}
|
||||
name := strings.TrimPrefix(u.Path, "/") + "_mig_" + suffix
|
||||
admin := *u
|
||||
admin.Path = "/postgres"
|
||||
conn, err := sql.Open("pgx", admin.String())
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||
}
|
||||
if err := conn.Ping(); err != nil {
|
||||
conn.Close()
|
||||
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||
}
|
||||
drop := func() error {
|
||||
_, err := conn.Exec(`DROP DATABASE IF EXISTS "` + name + `" WITH (FORCE)`)
|
||||
return err
|
||||
}
|
||||
if err := drop(); err != nil {
|
||||
t.Fatalf("drop leftover %s: %v", name, err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := drop(); err != nil {
|
||||
t.Errorf("drop %s: %v", name, err)
|
||||
}
|
||||
conn.Close()
|
||||
})
|
||||
u.Path = "/" + name
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// withEmbedded returns the embedded migrations plus extra files.
|
||||
func withEmbedded(t *testing.T, extra fstest.MapFS) fstest.MapFS {
|
||||
t.Helper()
|
||||
out := fstest.MapFS{}
|
||||
for _, name := range []string{llmRecordsMigration, llmUsageMigration} {
|
||||
content, err := fs.ReadFile(migrationsFS(), name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out[name] = &fstest.MapFile{Data: content}
|
||||
}
|
||||
for name, f := range extra {
|
||||
out[name] = f
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type appliedMigration struct {
|
||||
version int
|
||||
name, checksum string
|
||||
appliedAt time.Time
|
||||
}
|
||||
|
||||
func readApplied(t *testing.T, d *DB) []appliedMigration {
|
||||
t.Helper()
|
||||
rows, err := d.Query(`SELECT version, name, checksum, applied_at FROM schema_migrations ORDER BY version`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []appliedMigration
|
||||
for rows.Next() {
|
||||
var m appliedMigration
|
||||
if err := rows.Scan(&m.version, &m.name, &m.checksum, &m.appliedAt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// assertEmbeddedApplied checks that both LLM tables exist and that every
|
||||
// embedded migration is recorded with the SHA-256 of its file.
|
||||
func assertEmbeddedApplied(t *testing.T, d *DB) []appliedMigration {
|
||||
t.Helper()
|
||||
for _, table := range []string{"llm_records", "llm_usage"} {
|
||||
var exists bool
|
||||
if err := d.QueryRow(`SELECT to_regclass($1) IS NOT NULL`, table).Scan(&exists); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !exists {
|
||||
t.Errorf("table %s missing after Open", table)
|
||||
}
|
||||
}
|
||||
applied := readApplied(t, d)
|
||||
want := []struct {
|
||||
version int
|
||||
name string
|
||||
}{{1, llmRecordsMigration}, {2, llmUsageMigration}, {3, "0003_task_scope_icp_index.sql"}}
|
||||
if len(applied) != len(want) {
|
||||
t.Fatalf("schema_migrations = %+v, want versions 1 to 3", applied)
|
||||
}
|
||||
for i, w := range want {
|
||||
content, err := fs.ReadFile(migrationsFS(), w.name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sum := sha256.Sum256(content)
|
||||
got := applied[i]
|
||||
if got.version != w.version || got.name != w.name || got.checksum != hex.EncodeToString(sum[:]) {
|
||||
t.Errorf("schema_migrations row %d = %+v, want version %d name %s checksum %x", i, got, w.version, w.name, sum)
|
||||
}
|
||||
}
|
||||
return applied
|
||||
}
|
||||
|
||||
// assertReopenIsNoop opens dsn again and checks that no migration row was added
|
||||
// or re-applied, then that both Ensure functions stay idempotent.
|
||||
func assertReopenIsNoop(t *testing.T, dsn string, before []appliedMigration) {
|
||||
t.Helper()
|
||||
d, err := Open(dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("second Open: %v", err)
|
||||
}
|
||||
defer d.Close()
|
||||
after := assertEmbeddedApplied(t, d)
|
||||
for i := range before {
|
||||
if !after[i].appliedAt.Equal(before[i].appliedAt) {
|
||||
t.Errorf("migration %04d applied_at changed: %v -> %v", before[i].version, before[i].appliedAt, after[i].appliedAt)
|
||||
}
|
||||
}
|
||||
for range 2 {
|
||||
if err := d.EnsureLLMRecordsTable(); err != nil {
|
||||
t.Fatalf("EnsureLLMRecordsTable after Open: %v", err)
|
||||
}
|
||||
if err := d.EnsureLLMUsageTable(); err != nil {
|
||||
t.Fatalf("EnsureLLMUsageTable after Open: %v", err)
|
||||
}
|
||||
}
|
||||
if got := readApplied(t, d); len(got) != len(before) {
|
||||
t.Errorf("Ensure functions changed schema_migrations: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenRecordsEmbeddedMigrations runs against the package test database:
|
||||
// fresh on a first run, already migrated on a second run.
|
||||
func TestOpenRecordsEmbeddedMigrations(t *testing.T) {
|
||||
dsn := testDSN(t)
|
||||
d, err := Open(dsn)
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||
}
|
||||
applied := assertEmbeddedApplied(t, d)
|
||||
d.Close()
|
||||
assertReopenIsNoop(t, dsn, applied)
|
||||
}
|
||||
|
||||
func TestOpenMigratesFreshDatabase(t *testing.T) {
|
||||
dsn := scratchDSN(t, "fresh")
|
||||
d, err := Open(dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
applied := assertEmbeddedApplied(t, d)
|
||||
d.Close()
|
||||
assertReopenIsNoop(t, dsn, applied)
|
||||
}
|
||||
|
||||
// An install that predates schema_migrations already has both tables (created
|
||||
// by the Ensure functions, possibly without the later llm_records columns).
|
||||
// Open must adopt them: run 0001/0002 over them and record both.
|
||||
func TestOpenAdoptsExistingTablesWithoutSchemaMigrations(t *testing.T) {
|
||||
dsn := scratchDSN(t, "legacy")
|
||||
d, err := openWithMigrations(dsn, fstest.MapFS{})
|
||||
if err != nil {
|
||||
t.Fatalf("Open without migrations: %v", err)
|
||||
}
|
||||
if err := d.EnsureLLMRecordsTable(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.EnsureLLMUsageTable(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, q := range []string{
|
||||
`ALTER TABLE llm_records DROP COLUMN raw_request, DROP COLUMN raw_response`,
|
||||
`INSERT INTO llm_records(model) VALUES ('legacy')`,
|
||||
`INSERT INTO llm_usage(model, status) VALUES ('legacy', 'ok')`,
|
||||
`DROP TABLE schema_migrations`,
|
||||
} {
|
||||
if _, err := d.Exec(q); err != nil {
|
||||
t.Fatalf("%s: %v", q, err)
|
||||
}
|
||||
}
|
||||
d.Close()
|
||||
|
||||
d, err = Open(dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("Open on existing tables: %v", err)
|
||||
}
|
||||
applied := assertEmbeddedApplied(t, d)
|
||||
var records, usage, rawCols int
|
||||
if err := d.QueryRow(`SELECT count(*) FROM llm_records WHERE model='legacy'`).Scan(&records); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.QueryRow(`SELECT count(*) FROM llm_usage WHERE model='legacy'`).Scan(&usage); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.QueryRow(`SELECT count(*) FROM information_schema.columns
|
||||
WHERE table_name='llm_records' AND column_name IN ('raw_request','raw_response')`).Scan(&rawCols); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if records != 1 || usage != 1 || rawCols != 2 {
|
||||
t.Errorf("after adopt: llm_records legacy rows=%d llm_usage legacy rows=%d raw columns=%d, want 1 1 2", records, usage, rawCols)
|
||||
}
|
||||
d.Close()
|
||||
assertReopenIsNoop(t, dsn, applied)
|
||||
}
|
||||
|
||||
// probeMigration records how many migrations were already applied when it
|
||||
// ran, and fails if it ever runs twice (CREATE TABLE without IF NOT EXISTS).
|
||||
var probeMigration = &fstest.MapFile{Data: []byte(`
|
||||
CREATE TABLE migration_probe (applied_before INTEGER);
|
||||
INSERT INTO migration_probe SELECT count(*) FROM schema_migrations;
|
||||
`)}
|
||||
|
||||
func TestMigrationsApplyNewVersionOnceInOrder(t *testing.T) {
|
||||
dsn := scratchDSN(t, "order")
|
||||
fsys := withEmbedded(t, fstest.MapFS{"0003_probe.sql": probeMigration})
|
||||
for pass := 1; pass <= 2; pass++ {
|
||||
d, err := openWithMigrations(dsn, fsys)
|
||||
if err != nil {
|
||||
t.Fatalf("pass %d: %v", pass, err)
|
||||
}
|
||||
applied := readApplied(t, d)
|
||||
if len(applied) != 3 || applied[2].version != 3 || applied[2].name != "0003_probe.sql" {
|
||||
t.Fatalf("pass %d: schema_migrations = %+v", pass, applied)
|
||||
}
|
||||
var runs, before int
|
||||
if err := d.QueryRow(`SELECT count(*), max(applied_before) FROM migration_probe`).Scan(&runs, &before); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if runs != 1 || before != 2 {
|
||||
t.Fatalf("pass %d: probe ran %d times with %d migrations before it, want 1 and 2", pass, runs, before)
|
||||
}
|
||||
d.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationsFailClosed(t *testing.T) {
|
||||
dsn := scratchDSN(t, "closed")
|
||||
d, err := openWithMigrations(dsn, withEmbedded(t, fstest.MapFS{"0003_probe.sql": probeMigration}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d.Close()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
fsys fs.FS
|
||||
want []string
|
||||
}{
|
||||
{"checksum changed", withEmbedded(t, fstest.MapFS{"0003_probe.sql": {Data: append([]byte("-- edited\n"), probeMigration.Data...)}}),
|
||||
[]string{"0003", "0003_probe.sql", "checksum"}},
|
||||
{"version missing from binary", withEmbedded(t, fstest.MapFS{}),
|
||||
[]string{"0003", "0003_probe.sql", "missing from this binary"}},
|
||||
{"name changed", withEmbedded(t, fstest.MapFS{"0003_renamed.sql": probeMigration}),
|
||||
[]string{"0003", "0003_probe.sql", "0003_renamed.sql"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
d, err := openWithMigrations(dsn, c.fsys)
|
||||
if err == nil {
|
||||
d.Close()
|
||||
t.Errorf("%s: Open succeeded, want error", c.name)
|
||||
continue
|
||||
}
|
||||
for _, w := range c.want {
|
||||
if !strings.Contains(err.Error(), w) {
|
||||
t.Errorf("%s: error %q does not mention %q", c.name, err, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailingMigrationRollsBackAndBlocksOpen(t *testing.T) {
|
||||
dsn := scratchDSN(t, "fail")
|
||||
fsys := withEmbedded(t, fstest.MapFS{
|
||||
"0003_probe.sql": probeMigration,
|
||||
"0004_broken.sql": {Data: []byte(`
|
||||
CREATE TABLE migration_broken (x INTEGER);
|
||||
SELECT 1/0;
|
||||
`)},
|
||||
})
|
||||
d, err := openWithMigrations(dsn, fsys)
|
||||
if err == nil {
|
||||
d.Close()
|
||||
t.Fatal("Open succeeded with a failing migration")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "0004") || !strings.Contains(err.Error(), "0004_broken.sql") {
|
||||
t.Errorf("error %q does not name the failing migration", err)
|
||||
}
|
||||
|
||||
// Inspect with the migrations that succeeded: earlier ones stay applied,
|
||||
// the failing one left nothing behind.
|
||||
d, err = openWithMigrations(dsn, withEmbedded(t, fstest.MapFS{"0003_probe.sql": probeMigration}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.Close()
|
||||
applied := readApplied(t, d)
|
||||
if len(applied) != 3 || applied[2].version != 3 {
|
||||
t.Fatalf("schema_migrations = %+v, want versions 1..3", applied)
|
||||
}
|
||||
var broken bool
|
||||
if err := d.QueryRow(`SELECT to_regclass('migration_broken') IS NOT NULL`).Scan(&broken); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if broken {
|
||||
t.Error("migration_broken exists: the failed migration was not rolled back")
|
||||
}
|
||||
}
|
||||
|
||||
// A database already at 0003 must refuse a binary that adds 0002 below it,
|
||||
// before writing anything.
|
||||
func TestMigrationsRefuseOutOfOrderVersion(t *testing.T) {
|
||||
dsn := scratchDSN(t, "outoforder")
|
||||
records, err := fs.ReadFile(migrationsFS(), llmRecordsMigration)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d, err := openWithMigrations(dsn, fstest.MapFS{
|
||||
llmRecordsMigration: {Data: records},
|
||||
"0003_probe.sql": probeMigration,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := readApplied(t, d)
|
||||
d.Close()
|
||||
if len(before) != 2 || before[0].version != 1 || before[1].version != 3 {
|
||||
t.Fatalf("setup schema_migrations = %+v, want versions 1 and 3", before)
|
||||
}
|
||||
|
||||
d, err = openWithMigrations(dsn, withEmbedded(t, fstest.MapFS{"0003_probe.sql": probeMigration}))
|
||||
if err == nil {
|
||||
d.Close()
|
||||
t.Fatal("Open succeeded with 0002 pending below applied 0003")
|
||||
}
|
||||
for _, w := range []string{llmUsageMigration, "(0002)", "0003_probe.sql", "(0003)", "never inserted"} {
|
||||
if !strings.Contains(err.Error(), w) {
|
||||
t.Errorf("error %q does not mention %q", err, w)
|
||||
}
|
||||
}
|
||||
|
||||
raw, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer raw.Close()
|
||||
after := readApplied(t, &DB{raw})
|
||||
if len(after) != len(before) {
|
||||
t.Fatalf("schema_migrations changed: before %+v, after %+v", before, after)
|
||||
}
|
||||
for i := range before {
|
||||
if after[i].version != before[i].version || after[i].checksum != before[i].checksum || !after[i].appliedAt.Equal(before[i].appliedAt) {
|
||||
t.Errorf("schema_migrations row %d changed: before %+v, after %+v", i, before[i], after[i])
|
||||
}
|
||||
}
|
||||
var usage bool
|
||||
if err := raw.QueryRow(`SELECT to_regclass('llm_usage') IS NOT NULL`).Scan(&usage); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if usage {
|
||||
t.Error("llm_usage exists: the refused 0002 migration ran")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
|
||||
CREATE TABLE IF NOT EXISTS llm_records (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
ts TIMESTAMPTZ DEFAULT now(),
|
||||
model TEXT,
|
||||
profile_name TEXT,
|
||||
session_id TEXT,
|
||||
task_id TEXT,
|
||||
worker TEXT,
|
||||
latency_ms INTEGER,
|
||||
input_tokens INTEGER,
|
||||
output_tokens INTEGER,
|
||||
cache_read INTEGER,
|
||||
cache_write INTEGER,
|
||||
status TEXT,
|
||||
error TEXT,
|
||||
request_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_session ON llm_records(session_id);
|
||||
|
||||
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 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;
|
||||
@@ -0,0 +1,19 @@
|
||||
|
||||
CREATE TABLE IF NOT EXISTS llm_usage (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
ts TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
task_id TEXT,
|
||||
exploration_id BIGINT,
|
||||
worker TEXT,
|
||||
model TEXT,
|
||||
profile_name TEXT,
|
||||
latency_ms INTEGER,
|
||||
input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_read INTEGER NOT NULL DEFAULT 0,
|
||||
cache_write INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_llm_usage_task ON llm_usage(task_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_llm_usage_model ON llm_usage(task_id, model);
|
||||
CREATE INDEX IF NOT EXISTS idx_llm_usage_exp ON llm_usage(exploration_id);
|
||||
@@ -0,0 +1,9 @@
|
||||
-- The coverage target CTE (contextCoverageCTE / covTargetCTE / scopeTargetCTE)
|
||||
-- joins assets to task_scope on an OR of per-kind predicates. PostgreSQL plans it
|
||||
-- as one BitmapOr probe of task_scope per asset. Every branch had an index except
|
||||
-- kind='icp', which fell back to uq_task_scope_v2 with only "kind = 'icp'" as the
|
||||
-- index condition (task_id is the leading column), so each probe walked the
|
||||
-- whole unique index: about 70% of the buffer hits of the coverage-graph query on
|
||||
-- a 3,000-asset task scoped by 800 subdomains. This partial index makes the icp
|
||||
-- probe an equality lookup on value.
|
||||
CREATE INDEX IF NOT EXISTS idx_ts_icp ON task_scope(value) WHERE kind = 'icp';
|
||||
@@ -447,6 +447,14 @@ func restoreArchiveAssets(tx *sql.Tx, taskID int64, raw json.RawMessage) (map[in
|
||||
row["company_source"] = "explicit"
|
||||
warnings = append(warnings, fmt.Sprintf("asset %d's company %d has been deleted; restored as unaffiliated", oldID, companyID))
|
||||
}
|
||||
// Archives taken before normalizeURL stripped userinfo can hold
|
||||
// http://user:pass@host URLs. Normalise like the live upserts so the
|
||||
// restore neither stores the credential nor misses the clean row.
|
||||
if typeName, _ := row["type"].(string); typeName == "service" || typeName == "endpoint" {
|
||||
if rawURL, ok := row["url"].(string); ok {
|
||||
row["url"] = normalizeURL(rawURL)
|
||||
}
|
||||
}
|
||||
if existing, found, err := findArchiveAssetNaturalID(tx, row); err != nil {
|
||||
return nil, nil, err
|
||||
} else if found {
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const restoreURLHost = "restore-userinfo.example"
|
||||
|
||||
// bigintArray renders ids as a PostgreSQL array literal for a $n::bigint[] cast.
|
||||
func bigintArray(ids ...int64) string {
|
||||
parts := make([]string, len(ids))
|
||||
for i, id := range ids {
|
||||
parts[i] = fmt.Sprint(id)
|
||||
}
|
||||
return "{" + strings.Join(parts, ",") + "}"
|
||||
}
|
||||
|
||||
// archivedAssetRows exports live asset rows the way SnapshotTaskArchive does,
|
||||
// so the restore tests start from realistic archive JSON.
|
||||
func archivedAssetRows(t *testing.T, d *DB, ids ...int64) []map[string]any {
|
||||
t.Helper()
|
||||
raw, _, err := queryArchiveRows(d, `SELECT asset.* FROM assets asset WHERE asset.id = ANY($1::bigint[]) ORDER BY asset.id`, bigintArray(ids...))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows, err := decodeArchiveRows(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != len(ids) {
|
||||
t.Fatalf("exported %d archive rows, want %d", len(rows), len(ids))
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// restoreArchivedAssets runs restoreArchiveAssets on rows in a committed tx.
|
||||
func restoreArchivedAssets(t *testing.T, d *DB, taskID int64, rows []map[string]any) map[int64]int64 {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(rows)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tx, err := d.Begin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck
|
||||
mapping, _, err := restoreArchiveAssets(tx, taskID, raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return mapping
|
||||
}
|
||||
|
||||
func restoreURLCleanup(d *DB) {
|
||||
d.Exec(`DELETE FROM assets WHERE url LIKE '%` + restoreURLHost + `%' OR domain LIKE '%` + restoreURLHost + `' OR root_domain = '` + restoreURLHost + `' OR ip = '198.51.100.231'`) //nolint:errcheck
|
||||
}
|
||||
|
||||
func countRestoreURLRows(t *testing.T, d *DB, assetType, cleanURL string) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := d.QueryRow(`SELECT count(*) FROM assets WHERE type=$1 AND (url=$2 OR url LIKE '%@%' || $3::text || '%')`, assetType, cleanURL, restoreURLHost).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func assertNoRestoreURLCredential(t *testing.T, d *DB, id int64) {
|
||||
t.Helper()
|
||||
var row string
|
||||
if err := d.QueryRow(`SELECT row_to_json(a)::text FROM assets a WHERE id=$1`, id).Scan(&row); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, secret := range []string{"alice", "bob", "s3cr3t"} {
|
||||
if strings.Contains(row, secret) {
|
||||
t.Fatalf("restored asset %d still carries %q: %s", id, secret, row)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreArchiveAssetsStripsURLUserinfo(t *testing.T) {
|
||||
d, av2, _ := testSetup(t)
|
||||
defer d.Close()
|
||||
restoreURLCleanup(d)
|
||||
defer restoreURLCleanup(d)
|
||||
const taskID = int64(880000001)
|
||||
|
||||
svcURL := "http://" + restoreURLHost + ":8080/admin"
|
||||
epURL := "https://api." + restoreURLHost + "/users?id=1"
|
||||
svcID, err := av2.UpsertHTTPService(UpsertHTTPServiceReq{URL: svcURL, TaskID: taskID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
epID, err := av2.UpsertEndpoint(UpsertEndpointReq{URL: epURL, Method: "GET", TaskID: taskID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows := archivedAssetRows(t, d, svcID, epID)
|
||||
// Simulate an archive taken before normalizeURL dropped userinfo.
|
||||
rows[0]["url"] = "http://alice:s3cr3t-one@" + restoreURLHost + ":8080/admin"
|
||||
rows[1]["url"] = "https://bob:s3cr3t-two@api." + restoreURLHost + "/users?id=1"
|
||||
archived := map[string]map[string]any{"service": {}, "endpoint": {}}
|
||||
for _, row := range rows {
|
||||
typeName := row["type"].(string)
|
||||
for _, key := range []string{"domain", "root_domain", "port", "service_name", "method"} {
|
||||
archived[typeName][key] = row[key]
|
||||
}
|
||||
}
|
||||
if _, err := d.Exec(`DELETE FROM assets WHERE id = ANY($1::bigint[])`, bigintArray(svcID, epID)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mapping := restoreArchivedAssets(t, d, taskID, rows)
|
||||
for oldID, wantURL := range map[int64]string{svcID: svcURL, epID: epURL} {
|
||||
newID := mapping[oldID]
|
||||
if newID == 0 {
|
||||
t.Fatalf("asset %d was not restored: %v", oldID, mapping)
|
||||
}
|
||||
var gotURL, typeName string
|
||||
var domain, rootDomain, serviceName, method *string
|
||||
var port *int64
|
||||
if err := d.QueryRow(`SELECT url,type,domain,root_domain,port,service_name,method FROM assets WHERE id=$1`, newID).
|
||||
Scan(&gotURL, &typeName, &domain, &rootDomain, &port, &serviceName, &method); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotURL != wantURL {
|
||||
t.Fatalf("restored %s url=%q, want %q", typeName, gotURL, wantURL)
|
||||
}
|
||||
assertNoRestoreURLCredential(t, d, newID)
|
||||
// Only url is normalised: the other columns keep the archived values.
|
||||
want := archived[typeName]
|
||||
got := map[string]any{"domain": domain, "root_domain": rootDomain, "port": port, "service_name": serviceName, "method": method}
|
||||
for key, value := range got {
|
||||
gotJSON, _ := json.Marshal(value)
|
||||
wantJSON, _ := json.Marshal(want[key])
|
||||
if string(gotJSON) != string(wantJSON) {
|
||||
t.Errorf("%s %s=%s, want archived %s", typeName, key, gotJSON, wantJSON)
|
||||
}
|
||||
}
|
||||
if n := countRestoreURLRows(t, d, typeName, wantURL); n != 1 {
|
||||
t.Fatalf("%s rows for %q = %d, want 1", typeName, wantURL, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreArchiveAssetsReusesCleanURLRow(t *testing.T) {
|
||||
d, av2, _ := testSetup(t)
|
||||
defer d.Close()
|
||||
restoreURLCleanup(d)
|
||||
defer restoreURLCleanup(d)
|
||||
const liveTaskID, archivedTaskID = int64(880000002), int64(880000003)
|
||||
|
||||
svcURL := "http://" + restoreURLHost + ":8080/admin"
|
||||
epURL := "https://api." + restoreURLHost + "/users?id=1"
|
||||
svcID, err := av2.UpsertHTTPService(UpsertHTTPServiceReq{URL: svcURL, TaskID: liveTaskID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
epID, err := av2.UpsertEndpoint(UpsertEndpointReq{URL: epURL, Method: "GET", TaskID: liveTaskID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows := archivedAssetRows(t, d, svcID, epID)
|
||||
// The archived rows are other, older rows whose url still carries userinfo.
|
||||
rows[0]["id"], rows[0]["url"] = int64(990000101), "http://alice:s3cr3t-one@"+restoreURLHost+":8080/admin"
|
||||
rows[1]["id"], rows[1]["url"] = int64(990000102), "https://bob:s3cr3t-two@api."+restoreURLHost+"/users?id=1"
|
||||
for _, row := range rows {
|
||||
row["task_ids"] = []any{archivedTaskID}
|
||||
}
|
||||
|
||||
mapping := restoreArchivedAssets(t, d, archivedTaskID, rows)
|
||||
for oldID, existingID := range map[int64]int64{990000101: svcID, 990000102: epID} {
|
||||
if mapping[oldID] != existingID {
|
||||
t.Fatalf("archived asset %d mapped to %d, want existing clean row %d", oldID, mapping[oldID], existingID)
|
||||
}
|
||||
var taskIDs string
|
||||
if err := d.QueryRow(`SELECT task_ids::text FROM assets WHERE id=$1`, existingID).Scan(&taskIDs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := bigintArray(liveTaskID, archivedTaskID); taskIDs != want {
|
||||
t.Fatalf("asset %d task_ids=%s, want %s", existingID, taskIDs, want)
|
||||
}
|
||||
assertNoRestoreURLCredential(t, d, existingID)
|
||||
var leftover int
|
||||
if err := d.QueryRow(`SELECT count(*) FROM assets WHERE id=$1`, oldID).Scan(&leftover); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if leftover != 0 {
|
||||
t.Fatalf("archived asset %d inserted beside the clean row", oldID)
|
||||
}
|
||||
}
|
||||
if n := countRestoreURLRows(t, d, "service", svcURL); n != 1 {
|
||||
t.Fatalf("service rows = %d, want 1", n)
|
||||
}
|
||||
if n := countRestoreURLRows(t, d, "endpoint", epURL); n != 1 {
|
||||
t.Fatalf("endpoint rows = %d, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreArchiveAssetsDedupsURLAcrossCredentials(t *testing.T) {
|
||||
d, av2, _ := testSetup(t)
|
||||
defer d.Close()
|
||||
restoreURLCleanup(d)
|
||||
defer restoreURLCleanup(d)
|
||||
const taskID = int64(880000004)
|
||||
|
||||
svcURL := "http://" + restoreURLHost + ":8080/admin"
|
||||
svcID, err := av2.UpsertHTTPService(UpsertHTTPServiceReq{URL: svcURL, TaskID: taskID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := archivedAssetRows(t, d, svcID)[0]
|
||||
second := archivedAssetRows(t, d, svcID)[0]
|
||||
if _, err := d.Exec(`DELETE FROM assets WHERE id=$1`, svcID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first["id"], first["url"] = int64(990000201), "http://alice:s3cr3t-one@"+restoreURLHost+":8080/admin"
|
||||
second["id"], second["url"] = int64(990000202), "http://bob:s3cr3t-two@"+restoreURLHost+":8080/admin"
|
||||
|
||||
mapping := restoreArchivedAssets(t, d, taskID, []map[string]any{first, second})
|
||||
if mapping[990000201] == 0 || mapping[990000201] != mapping[990000202] {
|
||||
t.Fatalf("credential variants restored as separate rows: %v", mapping)
|
||||
}
|
||||
if n := countRestoreURLRows(t, d, "service", svcURL); n != 1 {
|
||||
t.Fatalf("service rows = %d, want exactly 1", n)
|
||||
}
|
||||
assertNoRestoreURLCredential(t, d, mapping[990000201])
|
||||
}
|
||||
|
||||
func TestRestoreArchiveAssetsLeavesDomainAndIPURLUntouched(t *testing.T) {
|
||||
d, av2, _ := testSetup(t)
|
||||
defer d.Close()
|
||||
restoreURLCleanup(d)
|
||||
defer restoreURLCleanup(d)
|
||||
const taskID = int64(880000005)
|
||||
|
||||
domainID, err := av2.UpsertRootDomain(UpsertRootDomainReq{Domain: restoreURLHost, TaskID: taskID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ipID, err := av2.UpsertIP(UpsertIPReq{IP: "198.51.100.231", TaskID: taskID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows := archivedAssetRows(t, d, domainID, ipID)
|
||||
// A url normalizeURL would rewrite proves the normalisation is scoped to
|
||||
// service and endpoint rows.
|
||||
const oddURL = "HTTP://Restore-Userinfo.example/"
|
||||
for _, row := range rows {
|
||||
row["url"] = oddURL
|
||||
}
|
||||
want := map[int64][]any{}
|
||||
for _, row := range rows {
|
||||
id, _ := jsonInt64(row["id"])
|
||||
want[id] = []any{row["type"], row["domain"], row["root_domain"], row["ip"]}
|
||||
}
|
||||
if _, err := d.Exec(`DELETE FROM assets WHERE id = ANY($1::bigint[])`, bigintArray(domainID, ipID)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mapping := restoreArchivedAssets(t, d, taskID, rows)
|
||||
for oldID, fields := range want {
|
||||
var typeName string
|
||||
var domain, rootDomain, ip, gotURL *string
|
||||
if err := d.QueryRow(`SELECT type,domain,root_domain,ip,url FROM assets WHERE id=$1`, mapping[oldID]).
|
||||
Scan(&typeName, &domain, &rootDomain, &ip, &gotURL); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := json.Marshal([]any{typeName, domain, rootDomain, ip})
|
||||
wantJSON, _ := json.Marshal(fields)
|
||||
if string(got) != string(wantJSON) {
|
||||
t.Errorf("asset %d restored as %s, want %s", oldID, got, wantJSON)
|
||||
}
|
||||
if gotURL == nil || *gotURL != oddURL {
|
||||
t.Errorf("%s asset url changed to %v, want %q", typeName, gotURL, oddURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
+62
-17
@@ -4,6 +4,7 @@ import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
@@ -534,16 +535,44 @@ ORDER BY a.id`, taskID)
|
||||
}
|
||||
}
|
||||
}
|
||||
// One query for every missing root instead of one per root.
|
||||
roots := make([]string, 0, len(missingRoots))
|
||||
for root := range missingRoots {
|
||||
var id, companyID int64
|
||||
err := s.db.QueryRow(`SELECT id, COALESCE(company_id,0) FROM assets
|
||||
WHERE type='root_domain' AND domain=$1 LIMIT 1`, root).Scan(&id, &companyID)
|
||||
roots = append(roots, root)
|
||||
}
|
||||
sort.Strings(roots)
|
||||
type rootRow struct{ id, companyID int64 }
|
||||
rootRows := map[string]rootRow{}
|
||||
if len(roots) > 0 {
|
||||
rrows, err := s.db.Query(`SELECT domain, id, COALESCE(company_id,0) FROM assets
|
||||
WHERE type='root_domain' AND domain=ANY($1::text[]) ORDER BY id`, roots)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rrows.Next() {
|
||||
var domain string
|
||||
var r rootRow
|
||||
if err := rrows.Scan(&domain, &r.id, &r.companyID); err != nil {
|
||||
rrows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if _, ok := rootRows[domain]; !ok {
|
||||
rootRows[domain] = r
|
||||
}
|
||||
}
|
||||
err = rrows.Err()
|
||||
rrows.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, root := range roots {
|
||||
var node CoverageGraphNode
|
||||
if err == nil && id > 0 {
|
||||
node = CoverageGraphNode{Key: assetKey(id), Kind: "root_domain", AssetID: id,
|
||||
CompanyID: companyID, Domain: root, Label: root}
|
||||
if companyID > 0 {
|
||||
companyIDs[companyID] = true
|
||||
if r, ok := rootRows[root]; ok && r.id > 0 {
|
||||
node = CoverageGraphNode{Key: assetKey(r.id), Kind: "root_domain", AssetID: r.id,
|
||||
CompanyID: r.companyID, Domain: root, Label: root}
|
||||
if r.companyID > 0 {
|
||||
companyIDs[r.companyID] = true
|
||||
}
|
||||
} else {
|
||||
node = CoverageGraphNode{Key: "r:" + root, Kind: "root_domain", Domain: root, Label: root}
|
||||
@@ -552,18 +581,34 @@ WHERE type='root_domain' AND domain=$1 LIMIT 1`, root).Scan(&id, &companyID)
|
||||
rootByDomain[root] = node.Key
|
||||
}
|
||||
|
||||
// Company nodes for every referenced company id — always gray context.
|
||||
// Company nodes for every referenced company id — always gray context. One
|
||||
// query for all of them; an id with no company row gets no node.
|
||||
ids := make([]int64, 0, len(companyIDs))
|
||||
for id := range companyIDs {
|
||||
key := companyKey(id)
|
||||
if _, ok := byKey[key]; ok {
|
||||
continue
|
||||
if _, ok := byKey[companyKey(id)]; !ok {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
var name string
|
||||
if err := s.db.QueryRow(`SELECT name FROM companies WHERE id=$1`, id).Scan(&name); err != nil {
|
||||
continue
|
||||
}
|
||||
if len(ids) > 0 {
|
||||
crows, err := s.db.Query(`SELECT id, name FROM companies WHERE id=ANY($1::bigint[]) ORDER BY id`, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for crows.Next() {
|
||||
var id int64
|
||||
var name string
|
||||
if err := crows.Scan(&id, &name); err != nil {
|
||||
crows.Close()
|
||||
return nil, err
|
||||
}
|
||||
add(CoverageGraphNode{Key: companyKey(id), Kind: "company", CompanyID: id,
|
||||
Label: name, AssetID: 0})
|
||||
}
|
||||
err = crows.Err()
|
||||
crows.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
add(CoverageGraphNode{Key: key, Kind: "company", CompanyID: id,
|
||||
Label: name, AssetID: 0})
|
||||
}
|
||||
|
||||
// Derived containment edges (only when the parent node exists).
|
||||
|
||||
+2
-2
@@ -54,10 +54,10 @@ services:
|
||||
- "${ARTEX_BIND_ADDR:-127.0.0.1}:8787:8787"
|
||||
- "127.0.0.1:8788:8788"
|
||||
volumes:
|
||||
# Bind the data directory (SQLite, task files, etc.) to data/ in the project root,
|
||||
# Bind the runtime data directory (traffic index, task files, transcripts, etc.) to data/ in the project root,
|
||||
# so it is easy to inspect/back up on the host and survives container rebuilds.
|
||||
- ./data:/app/data
|
||||
# JWT signing key (jwt.key), kept out of data/, which the file manager browses.
|
||||
# JWT signing key and secret key (jwt.key, secret.key), kept out of data/, which the file manager browses.
|
||||
# Persisting it keeps users signed in across container rebuilds.
|
||||
- ./keys:/app/keys
|
||||
# Mount skills/ from the project root into the container (overriding the image's built-in /app/skills);
|
||||
|
||||
+5
-5
@@ -29,7 +29,7 @@ const mcpUntrustedNote = "\nOutput of this tool may contain attacker-controlled
|
||||
// capture is on) to add to EVERY agent's base list — the DB tools table then
|
||||
// filters them per-agent binding. Empty/nil → no host tools this run (capture off).
|
||||
func wireAgentAugment(pg *db.DB, skillDir string, hostTools func() ([]actool.CoreTool, map[string][]string)) {
|
||||
agent.ToolAugment = func(ctx context.Context, agentKey string) ([]actool.CoreTool, agent.DeferredInfo, func()) {
|
||||
agent.SetToolAugment(func(ctx context.Context, agentKey string) ([]actool.CoreTool, agent.DeferredInfo, func()) {
|
||||
a, err := pg.GetAgentByKey(agentKey)
|
||||
if err != nil || a == nil {
|
||||
return nil, agent.DeferredInfo{}, nil
|
||||
@@ -188,7 +188,7 @@ func wireAgentAugment(pg *db.DB, skillDir string, hostTools func() ([]actool.Cor
|
||||
UnlockSkill: unlockSkill,
|
||||
}
|
||||
return extra, def, cleanup
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func idSet(ids []int64) map[int64]bool {
|
||||
@@ -222,7 +222,7 @@ func seedPrompts(pg *db.DB) {
|
||||
// flag and, if kept, wrapped so the model sees the DB-overridden description/schema
|
||||
// and default params get injected. MCP/skill/host tools have no row and pass through.
|
||||
func wireTools(pg *db.DB, domainReg map[string]actool.CoreTool) {
|
||||
agent.FindingTrafficBindingEnabled = func() bool { return pg.GetBool(settingAgentTrafficBinding, false) }
|
||||
agent.SetFindingTrafficBindingEnabled(func() bool { return pg.GetBool(settingAgentTrafficBinding, false) })
|
||||
// Seed the built-in domain tools (first-insert only; DO NOTHING preserves edits).
|
||||
// No startup prune: rows we didn't seed are left alone so future user-defined
|
||||
// custom tools (system=false, added via the UI) survive restarts.
|
||||
@@ -248,7 +248,7 @@ func wireTools(pg *db.DB, domainReg map[string]actool.CoreTool) {
|
||||
// interactive_shell is on, so Bash points at shell_open for interactive programs
|
||||
// without ever referencing a tool that isn't injected (§14.1/§14.2).
|
||||
const bashInteractiveShellNote = "\n\nPrograms that need [interactive input] (msfconsole / interactive ssh login / REPLs like mysql, psql, python / password or yes/no prompts / nc reverse shells) must not use Bash (it has no stdin and would hang); open an interactive session with shell_open instead (and shell_close when done). One-shot, non-interactive commands still use Bash."
|
||||
agent.ToolResolve = func(ctx context.Context, agentKey string, tools []actool.CoreTool) []actool.CoreTool {
|
||||
agent.SetToolResolve(func(ctx context.Context, agentKey string, tools []actool.CoreTool) []actool.CoreTool {
|
||||
rows, err := pg.ListTools()
|
||||
if err != nil {
|
||||
log.Printf("[tools] failed to read tools table, passing through with code defaults: %v", err)
|
||||
@@ -333,7 +333,7 @@ func wireTools(pg *db.DB, domainReg map[string]actool.CoreTool) {
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func contains(ss []string, v string) bool {
|
||||
|
||||
@@ -49,7 +49,7 @@ func TestAssembleVisibleSkill(t *testing.T) {
|
||||
}
|
||||
|
||||
wireAgentAugment(pg, skillDir, nil)
|
||||
t.Cleanup(func() { agent.ToolAugment = nil })
|
||||
t.Cleanup(func() { agent.SetToolAugment(nil) })
|
||||
|
||||
ag, _ := pg.GetAgentByKey("planner")
|
||||
if ag == nil {
|
||||
@@ -65,7 +65,7 @@ func TestAssembleVisibleSkill(t *testing.T) {
|
||||
t.Cleanup(func() { _ = pg.SetAgentSkillVisibility(ag.ID, orig) })
|
||||
|
||||
// before: skill not visible → no Skill meta-tool
|
||||
extra, _, cleanup := agent.ToolAugment(context.Background(), "planner")
|
||||
extra, _, cleanup := agent.ToolAugment()(context.Background(), "planner")
|
||||
cleanup()
|
||||
if hasSkillTool(extra) {
|
||||
t.Fatalf("expected no Skill meta-tool before the skill is made visible")
|
||||
@@ -78,7 +78,7 @@ func TestAssembleVisibleSkill(t *testing.T) {
|
||||
t.Cleanup(func() { _ = pg.ToggleSkillVisibility(ag.ID, skillName, false) })
|
||||
|
||||
// after: the Skill meta-tool is packed in
|
||||
extra, _, cleanup = agent.ToolAugment(context.Background(), "planner")
|
||||
extra, _, cleanup = agent.ToolAugment()(context.Background(), "planner")
|
||||
defer cleanup()
|
||||
if !hasSkillTool(extra) {
|
||||
t.Fatalf("expected the Skill meta-tool after making the skill visible")
|
||||
|
||||
@@ -18,7 +18,9 @@ func TestChatUnavailableReasonDistinguishesStates(t *testing.T) {
|
||||
}
|
||||
defer m.Close()
|
||||
td := t.TempDir()
|
||||
s := New(context.Background(), m, td, td, td)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s := New(ctx, m, td, td, td)
|
||||
defer stopTestServer(t, s, cancel)
|
||||
|
||||
// Start from a clean profile table; other tests in the shared DB may have left rows.
|
||||
existing, _ := m.pg.ListProfiles()
|
||||
@@ -68,7 +70,9 @@ func TestResolveChatAgentHonoursConversationProfile(t *testing.T) {
|
||||
}
|
||||
defer m.Close()
|
||||
td := t.TempDir()
|
||||
s := New(context.Background(), m, td, td, td)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s := New(ctx, m, td, td, td)
|
||||
defer stopTestServer(t, s, cancel)
|
||||
|
||||
// Force the global fallback to nil so a non-nil result can ONLY come from the
|
||||
// conversation's own profile — this is exactly the situation the user hit
|
||||
|
||||
+3
-1
@@ -24,7 +24,9 @@ func TestCoreTaskLifecyclePG(t *testing.T) {
|
||||
}
|
||||
defer m.Close()
|
||||
td := t.TempDir()
|
||||
s := New(context.Background(), m, td, td, td)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s := New(ctx, m, td, td, td)
|
||||
defer stopTestServer(t, s, cancel)
|
||||
h := s.Handler()
|
||||
tok, err := signJWT(s.jwtKey)
|
||||
if err != nil {
|
||||
|
||||
@@ -24,7 +24,7 @@ func TestRecordFactSourceSchemaUpgrade(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
s := New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
t.Cleanup(func() { agent.ToolResolve = nil })
|
||||
t.Cleanup(func() { agent.SetToolResolve(nil) })
|
||||
pg := m.pg
|
||||
|
||||
old, err := pg.GetTool("record_fact")
|
||||
@@ -96,7 +96,7 @@ func TestRecordFactSourceSchemaUpgrade(t *testing.T) {
|
||||
// serves it to the worker.
|
||||
func resolvedRecordFactProps(t *testing.T) map[string]any {
|
||||
t.Helper()
|
||||
tools := agent.ToolResolve(context.Background(), "worker", agent.NewToolSet(nil, "").WorkerTools())
|
||||
tools := agent.ToolResolve()(context.Background(), "worker", agent.NewToolSet(nil, "").WorkerTools())
|
||||
for _, tl := range tools {
|
||||
if tl.Name() == "record_fact" {
|
||||
props, _ := tl.InputSchema()["properties"].(map[string]any)
|
||||
|
||||
@@ -55,16 +55,18 @@ func newRetestServer(t *testing.T) (*Server, int64) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldAugment, oldResolve, oldPrompt := agent.ToolAugment, agent.ToolResolve, agent.PromptOverride
|
||||
agent.ToolAugment = func(context.Context, string) ([]actool.CoreTool, agent.DeferredInfo, func()) {
|
||||
oldAugment, oldResolve, oldPrompt := agent.ToolAugment(), agent.ToolResolve(), agent.PromptOverride()
|
||||
agent.SetToolAugment(func(context.Context, string) ([]actool.CoreTool, agent.DeferredInfo, func()) {
|
||||
return s.findingRetestTools(), agent.DeferredInfo{}, func() {}
|
||||
}
|
||||
agent.ToolResolve = nil
|
||||
agent.PromptOverride = func(string) (string, bool) { return agent.RetesterDefaultPrompt, true }
|
||||
})
|
||||
agent.SetToolResolve(nil)
|
||||
agent.SetPromptOverride(func(string) (string, bool) { return agent.RetesterDefaultPrompt, true })
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
waitRetestIdle(t, s)
|
||||
agent.ToolAugment, agent.ToolResolve, agent.PromptOverride = oldAugment, oldResolve, oldPrompt
|
||||
agent.SetToolAugment(oldAugment)
|
||||
agent.SetToolResolve(oldResolve)
|
||||
agent.SetPromptOverride(oldPrompt)
|
||||
_, _ = pg.Exec(`DELETE FROM conversations WHERE id IN (SELECT conversation_id FROM finding_retests WHERE finding_id=$1)`, fid)
|
||||
_, _ = pg.DeleteFinding(fid)
|
||||
pg.Close()
|
||||
|
||||
@@ -67,7 +67,9 @@ func TestInheritedActivityDetailAndRelationDeletion(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s := New(context.Background(), m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s := New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
defer stopTestServer(t, s, cancel)
|
||||
token, err := signJWT(s.jwtKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -50,7 +50,9 @@ SELECT id,'completed','fixed','{}'::jsonb FROM findings WHERE task_id=$1 AND nam
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s := New(context.Background(), m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s := New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
defer stopTestServer(t, s, cancel)
|
||||
token, err := signJWT(s.jwtKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -144,7 +146,9 @@ VALUES ($1,'xss','Reflected XSS','reflected in search','high','confirmed')`, tas
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s := New(context.Background(), m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s := New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
defer stopTestServer(t, s, cancel)
|
||||
token, err := signJWT(s.jwtKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
+7
-1
@@ -18,8 +18,14 @@ func TestMgmtAPI(t *testing.T) {
|
||||
if m.pg == nil {
|
||||
t.Skip("postgres unavailable — skipping management API test")
|
||||
}
|
||||
// New starts the notifier and task-archive workers on context.Background();
|
||||
// closing the pool stops them from claiming later tests' digest batches
|
||||
// and archive jobs.
|
||||
defer m.Close()
|
||||
td := t.TempDir()
|
||||
s := New(context.Background(), m, td, td, td)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s := New(ctx, m, td, td, td)
|
||||
defer stopTestServer(t, s, cancel)
|
||||
h := s.Handler()
|
||||
tok, err := signJWT(s.jwtKey)
|
||||
if err != nil {
|
||||
|
||||
+10
-10
@@ -192,7 +192,7 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi
|
||||
// Wire DB-stored prompt templates into the agents (new design plan §3.3 / §5a). With no
|
||||
// override row, agents keep their built-in defaults — behavior is unchanged.
|
||||
if m.pg != nil {
|
||||
agent.PromptOverride = func(key string) (string, bool) {
|
||||
agent.SetPromptOverride(func(key string) (string, bool) {
|
||||
a, err := m.pg.GetAgentByKey(key)
|
||||
if err != nil || a == nil {
|
||||
return "", false
|
||||
@@ -202,38 +202,38 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi
|
||||
return "", false
|
||||
}
|
||||
return t, true
|
||||
}
|
||||
})
|
||||
// Wire DB-stored wrap-up (settlement) prompts. Empty column → built-in default.
|
||||
agent.WrapupOverride = func(key string) (string, bool) {
|
||||
agent.SetWrapupOverride(func(key string) (string, bool) {
|
||||
a, err := m.pg.GetAgentByKey(key)
|
||||
if err != nil || a == nil || a.WrapupPrompt == "" {
|
||||
return "", false
|
||||
}
|
||||
return a.WrapupPrompt, true
|
||||
}
|
||||
})
|
||||
// Wire DB-stored wrap-up turn budgets. 0 / missing → built-in per-agent default.
|
||||
agent.WrapupMaxTurnsOverride = func(key string) (int, bool) {
|
||||
agent.SetWrapupMaxTurnsOverride(func(key string) (int, bool) {
|
||||
a, err := m.pg.GetAgentByKey(key)
|
||||
if err != nil || a == nil || a.WrapupMaxTurns <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return a.WrapupMaxTurns, true
|
||||
}
|
||||
})
|
||||
// Wire DB-stored TASK-TIMEOUT wrap-up prompt/turns (worker/planner). Empty/0 → default.
|
||||
agent.WrapupTaskTimeoutOverride = func(key string) (string, bool) {
|
||||
agent.SetWrapupTaskTimeoutOverride(func(key string) (string, bool) {
|
||||
a, err := m.pg.GetAgentByKey(key)
|
||||
if err != nil || a == nil || a.TaskTimeoutWrapupPrompt == "" {
|
||||
return "", false
|
||||
}
|
||||
return a.TaskTimeoutWrapupPrompt, true
|
||||
}
|
||||
agent.WrapupTaskTimeoutTurnsOverride = func(key string) (int, bool) {
|
||||
})
|
||||
agent.SetWrapupTaskTimeoutTurnsOverride(func(key string) (int, bool) {
|
||||
a, err := m.pg.GetAgentByKey(key)
|
||||
if err != nil || a == nil || a.TaskTimeoutWrapupMaxTurns <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return a.TaskTimeoutWrapupMaxTurns, true
|
||||
}
|
||||
})
|
||||
wireAgentAugment(m.pg, s.skillDir, s.hostTools) // wire visible skills/MCP + traffic/orchestration host tools into the agent toolset
|
||||
domainReg := buildDomainReg(m.Assets())
|
||||
wireTools(m.pg, domainReg) // built-in tools table: filter per agent + override description/schema + inject defaults
|
||||
|
||||
@@ -33,7 +33,9 @@ INSERT INTO skill_usage(skill,agent_key,task_id,found) VALUES
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s := New(context.Background(), m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s := New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
defer stopTestServer(t, s, cancel)
|
||||
token, err := signJWT(s.jwtKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -21,7 +21,9 @@ func TestTaskCategoryBatchRoute(t *testing.T) {
|
||||
defer m.Close()
|
||||
|
||||
td := t.TempDir()
|
||||
s := New(context.Background(), m, td, td, td)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s := New(ctx, m, td, td, td)
|
||||
defer stopTestServer(t, s, cancel)
|
||||
h := s.Handler()
|
||||
token, err := signJWT(s.jwtKey)
|
||||
if err != nil {
|
||||
|
||||
@@ -16,7 +16,9 @@ func TestWorkerControlRoutes(t *testing.T) {
|
||||
defer m.Close()
|
||||
|
||||
td := t.TempDir()
|
||||
s := New(context.Background(), m, td, td, td)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s := New(ctx, m, td, td, td)
|
||||
defer stopTestServer(t, s, cancel)
|
||||
h := s.Handler()
|
||||
token, err := signJWT(s.jwtKey)
|
||||
if err != nil {
|
||||
|
||||
@@ -38,7 +38,9 @@ VALUES ($1,'goal','{}','met'), ($1,'goal','{}','open')`, task.ExpID); err != nil
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s := New(context.Background(), m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s := New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
defer stopTestServer(t, s, cancel)
|
||||
token, err := signJWT(s.jwtKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -24,7 +24,9 @@ func TestTaskMetadataPatchReturnsRenameAndPin(t *testing.T) {
|
||||
}
|
||||
taskID, _ := strconv.ParseInt(task.ID, 10, 64)
|
||||
defer func() { _ = m.pg.DeleteTask(taskID) }()
|
||||
s := New(context.Background(), m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s := New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
defer stopTestServer(t, s, cancel)
|
||||
token, err := signJWT(s.jwtKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -66,7 +68,9 @@ func TestConversationBatchDeleteReportsMissing(t *testing.T) {
|
||||
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||
}
|
||||
defer m.Close()
|
||||
s := New(context.Background(), m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s := New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
defer stopTestServer(t, s, cancel)
|
||||
first, err := m.pg.CreateConversation("mainagent", "batch-http-first", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -23,6 +23,9 @@ func TestTaskOutcomeCard(t *testing.T) {
|
||||
}
|
||||
taskID, _ := strconv.ParseInt(task.ID, 10, 64)
|
||||
defer func() { _ = m.pg.DeleteTask(taskID) }()
|
||||
// skill_usage has no foreign keys, so DeleteTask leaves these rows behind,
|
||||
// and their misses would outrank TestSkillGapsReport's fixture in later runs.
|
||||
defer func() { _, _ = m.pg.Exec(`DELETE FROM skill_usage WHERE task_id=$1`, taskID) }()
|
||||
|
||||
if _, err := m.pg.Exec(`
|
||||
INSERT INTO findings(task_id,vulnclass,name,severity,status) VALUES
|
||||
@@ -57,7 +60,9 @@ INSERT INTO skill_usage(skill,task_id,found) VALUES ('api-recon',$1,true),('grap
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s := New(context.Background(), m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s := New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
defer stopTestServer(t, s, cancel)
|
||||
token, err := signJWT(s.jwtKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -23,7 +23,7 @@ func TestTaskScopeDescriptionUpgrade(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
s := New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
t.Cleanup(func() { agent.ToolResolve = nil })
|
||||
t.Cleanup(func() { agent.SetToolResolve(nil) })
|
||||
pg := m.pg
|
||||
|
||||
var want string
|
||||
@@ -84,7 +84,7 @@ func TestTaskScopeDescriptionUpgrade(t *testing.T) {
|
||||
// ToolResolve serves it to the planner.
|
||||
func resolvedTaskScopeDescription(t *testing.T) string {
|
||||
t.Helper()
|
||||
tools := agent.ToolResolve(context.Background(), "planner", agent.NewToolSet(nil, "").PlannerTools())
|
||||
tools := agent.ToolResolve()(context.Background(), "planner", agent.NewToolSet(nil, "").PlannerTools())
|
||||
for _, tl := range tools {
|
||||
if tl.Name() == "add_task_scope" {
|
||||
return tl.Description()
|
||||
|
||||
@@ -20,7 +20,9 @@ func TestTaskTemplateHTTPCRUD(t *testing.T) {
|
||||
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||
}
|
||||
defer m.Close()
|
||||
s := New(context.Background(), m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s := New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
defer stopTestServer(t, s, cancel)
|
||||
h := s.Handler()
|
||||
token, err := signJWT(s.jwtKey)
|
||||
if err != nil {
|
||||
@@ -90,7 +92,9 @@ func TestConversationPatchReturnsPinState(t *testing.T) {
|
||||
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||
}
|
||||
defer m.Close()
|
||||
s := New(context.Background(), m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s := New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
defer stopTestServer(t, s, cancel)
|
||||
conversation, err := m.pg.CreateConversation("mainagent", "pin through http", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// stopTestServer cancels a test server's context and joins what New started.
|
||||
// New restores every unfinished task in the shared database and runs its
|
||||
// planner, worker and deadline loops, which write under m.dir. Call it with
|
||||
// defer right after New, so it runs before m.Close and the TempDir cleanups.
|
||||
func stopTestServer(t *testing.T, s *Server, cancel context.CancelFunc) {
|
||||
t.Helper()
|
||||
cancel()
|
||||
for _, task := range s.m.List() {
|
||||
s.engine.BeginDelete(task.ID)
|
||||
waitCtx, waitCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
if err := s.waitTaskQuiescent(waitCtx, task.ID); err != nil {
|
||||
t.Errorf("task %s still running at teardown: %v", task.ID, err)
|
||||
}
|
||||
waitCancel()
|
||||
s.engine.StopTask(task.ID)
|
||||
}
|
||||
s.archiveWG.Wait()
|
||||
<-s.side.done
|
||||
}
|
||||
@@ -33,7 +33,7 @@ func TestWireTools(t *testing.T) {
|
||||
defer pg.Close()
|
||||
|
||||
wireTools(pg, nil) // nil domainReg: test only covers filter/decoration, not injection
|
||||
t.Cleanup(func() { agent.ToolResolve = nil })
|
||||
t.Cleanup(func() { agent.SetToolResolve(nil) })
|
||||
// Prevent interactive-shell Bash decoration: if the worker agent has
|
||||
// interactive_shell=true in the DB, ToolResolve would append a note to
|
||||
// Bash's description. Disable that for this test so the undecorated
|
||||
@@ -52,7 +52,7 @@ func TestWireTools(t *testing.T) {
|
||||
base := append(ts.WorkerTools(), actool.DefaultTools()...)
|
||||
ctx := context.Background()
|
||||
|
||||
worker := names(agent.ToolResolve(ctx, "worker", base))
|
||||
worker := names(agent.ToolResolve()(ctx, "worker", base))
|
||||
if _, ok := worker["record_fact"]; !ok {
|
||||
t.Error("worker lost record_fact")
|
||||
}
|
||||
@@ -65,7 +65,7 @@ func TestWireTools(t *testing.T) {
|
||||
t.Error("Bash should pass through undecorated, but description changed")
|
||||
}
|
||||
// planner is not bound to record_fact → resolving a base that contains it drops it.
|
||||
planner := names(agent.ToolResolve(ctx, "planner", base))
|
||||
planner := names(agent.ToolResolve()(ctx, "planner", base))
|
||||
if _, ok := planner["record_fact"]; ok {
|
||||
t.Error("planner should not get record_fact (not bound)")
|
||||
}
|
||||
@@ -85,7 +85,7 @@ func TestWireTools(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
worker2 := names(agent.ToolResolve(ctx, "worker", base))
|
||||
worker2 := names(agent.ToolResolve()(ctx, "worker", base))
|
||||
got := worker2["record_fact"]
|
||||
if got == nil {
|
||||
t.Fatal("record_fact missing after edit")
|
||||
|
||||
Reference in New Issue
Block a user