Compare commits

..
Author SHA1 Message Date
BaaRai 30c787d690 perf(db): batch coverage-graph lookups and index icp scope probes
BuildCoverageGraph issued one QueryRow per connector root domain and one per company: 221 statements for a task scoped by 800 subdomains over 200 roots and 20 companies. Fetch them with one ANY() query each. The coverage target join probes task_scope once per asset, and the icp branch fell back to uq_task_scope_v2 with only kind as index condition; migration 0003 adds a partial index on task_scope(value) for icp rows. Large-fixture invariants test and benchmark included.
2026-11-14 21:16:49 +01:00
BaaRai 430950570c Merge pull request #152 from xulytiengviet/patch-1
Create SECURITY.md
2026-11-14 20:57:08 +01:00
BaaRai f8bc149872 Merge pull request #225 from err0rgod/docs/fix-stale-postgres-docs
docs: correct stale database and boundary references
2026-11-14 20:57:03 +01:00
BaaRai f3a8e82d29 docs(security): point reports to the BaaRai repository and drop placeholders
Replace the [owner] placeholder and the unfilled email and PGP lines with GitHub Security Advisories as the sole reporting channel. Replace the 0.4/0.5 support table with the latest release line, and turn the response timeframes into best-effort goals.
2026-11-14 20:47:28 +01:00
BaaRai e300f85987 Merge pull request #229 from BaaRai/ci/race-server
ci: run ./server under the race detector
2026-11-14 19:57:20 +01:00
BaaRai dfa0e8a566 ci: run ./server under the race detector
The server suite now passes go test -race against postgres, and its test teardown no longer leaks live servers (#227, #228). Add ./server to the go test -race step; the DSN and -p 1 are unchanged.
2026-11-14 19:11:10 +01:00
BaaRai 9b20a46eba Merge pull request #228 from BaaRai/fix/flaky-server-tests
test(server): fix flaky SkillGaps, notify digest and TempDir cleanup tests
2026-11-14 19:03:16 +01:00
BaaRai 8c8ae97400 test(server): join every live test server before its TempDir cleanup
New restores every unfinished task in the shared database and runs its planner, worker and deadline loops on the server context. Tests that passed context.Background() never stopped or joined them, so they kept writing under the manager TempDir after the test returned and the TempDir cleanup failed with directory not empty. Add stopTestServer, which cancels the context, waits for each task to go quiescent, joins its loops, the archive worker and the side-question writer, and defer it right after New in every test that started a live server.
2026-11-14 18:21:52 +01:00
BaaRai 149151919f test(server): join restored task loops before TestTaskDetailReturnsListMetrics cleanup
New restores every unfinished task in the shared database and runs its planner and worker loops on the server context. They kept writing transcripts under the TempDir after the test returned, so the TempDir cleanup failed with directory not empty. Cancel the context, wait for each task to go quiescent, and join its loops before m.Close and the TempDir cleanup.
2026-11-14 16:21:31 +01:00
BaaRai 5cdb720122 test(server): stop leaked fixtures from polluting later tests
TestMgmtAPI never closed its manager, so the notifier and task-archive workers started by New(context.Background()) kept claiming other tests digest batches and archive jobs. TestTaskOutcomeCard left a skill_usage miss row behind (the table has no foreign keys), which outranked TestSkillGapsReport fixture from the fourth -count iteration on.
2026-11-14 15:44:52 +01:00
BaaRai ae792126dc Merge pull request #227 from BaaRai/fix/agent-hook-race
agent: synchronise package-level hooks (data races under -race)
2026-11-14 15:01:30 +01:00
BaaRai 54ff51177d agent: guard prompt and wrap-up override hooks against concurrent access
PromptOverride, WrapupOverride, WrapupMaxTurnsOverride,
WrapupTaskTimeoutOverride and WrapupTaskTimeoutTurnsOverride are wired
in server.New while restored tasks may already render prompts and build
settlement configs on other goroutines. go test -race ./server reported
races between server.New (server.go:195/207/215) and agent.renderSystem /
resolveWrapup / resolveWrapupTurns.

Same treatment as the tool hooks: unexported variables behind a
sync.RWMutex (one for the prompt hook, one shared by the four wrap-up
hooks), Set* setters and getters, and readers that copy the function
value under the lock and call it outside. A nil hook still means the
built-in default.

TestAgentHooksConcurrentSwap now covers all eight hooks.
2026-11-14 14:19:13 +01:00
BaaRai 37f2573a53 agent: guard ToolAugment/ToolResolve hooks against concurrent access
server wiring (wireAgentAugment, wireTools) assigned the package-level
hooks agent.ToolAugment and agent.ToolResolve while restored tasks could
already be running AugmentTools on other goroutines. go test -race ./server
reported this as a DATA RACE between agent.AugmentTools and
server.wireAgentAugment.

Each hook is now an unexported variable behind a sync.RWMutex, with a
setter (SetToolAugment, SetToolResolve) and a getter (ToolAugment(),
ToolResolve()). AugmentTools copies the function value through the getter
and calls it outside the lock. A nil hook still means no augmentation /
no resolution, as before.

FindingTrafficBindingEnabled is wired in the same server/assembly.go code
and read on the same AugmentTools path, so it gets the same treatment.

Adds TestAugmentToolsHooksConcurrentSwap, which runs AugmentTools while
the setters swap the hooks, so -race catches an unsynchronised access
without depending on test ordering.
2026-11-14 13:46:03 +01:00
BaaRai 5cb44fb969 Merge pull request #226 from BaaRai/feat/versioned-migrations
db: versioned migrations runner with schema_migrations
2026-11-14 13:19:38 +01:00
err0rgod 622b7490e0 docs: finish the post-SQLite data-directory wording
Update the Dockerfile, Compose file and upgrade note to describe what ./data and ./keys actually hold now that the graph stores are PostgreSQL.
2026-11-14 16:58:39 +05:30
err0rgod b6e863c746 docs: correct stale database and boundary references
- describe the graph stores as PostgreSQL in the entrypoint and agent package
- document that -data holds the traffic index, task files and transcripts
- add an Unreleased note for the untrusted-output boundary default flip
- use a placeholder version in the README/build.sh release examples
2026-11-14 16:55:11 +05:30
Long Ngo 33078a7d35 Create SECURITY.md 2026-11-06 14:01:04 +07:00
45 changed files with 805 additions and 154 deletions
+5 -3
View File
@@ -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
+6
View File
@@ -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
View File
@@ -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"]
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+87
View File
@@ -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()
}
+3 -3
View File
@@ -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)
+22 -2
View File
@@ -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
+11 -7
View File
@@ -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
View File
@@ -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
}
}
+8 -8
View File
@@ -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
View File
@@ -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") {
+8 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
}
+2 -2
View File
@@ -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
View File
@@ -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()
+198
View File
@@ -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")
}
}
}
+5 -5
View File
@@ -119,8 +119,8 @@ func readApplied(t *testing.T, d *DB) []appliedMigration {
return out
}
// assertEmbeddedApplied checks that both LLM tables exist and that 0001 and
// 0002 are recorded with the SHA-256 of their embedded files.
// 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"} {
@@ -136,9 +136,9 @@ func assertEmbeddedApplied(t *testing.T, d *DB) []appliedMigration {
want := []struct {
version int
name string
}{{1, llmRecordsMigration}, {2, llmUsageMigration}}
}{{1, llmRecordsMigration}, {2, llmUsageMigration}, {3, "0003_task_scope_icp_index.sql"}}
if len(applied) != len(want) {
t.Fatalf("schema_migrations = %+v, want versions 1 and 2", applied)
t.Fatalf("schema_migrations = %+v, want versions 1 to 3", applied)
}
for i, w := range want {
content, err := fs.ReadFile(migrationsFS(), w.name)
@@ -301,7 +301,7 @@ func TestMigrationsFailClosed(t *testing.T) {
}{
{"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", migrationsFS(),
{"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"}},
@@ -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';
+62 -17
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+3 -3
View File
@@ -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")
+6 -2
View File
@@ -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
View File
@@ -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 {
+2 -2
View File
@@ -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)
+8 -6
View File
@@ -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()
+3 -1
View File
@@ -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)
+6 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
+3 -1
View File
@@ -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)
+3 -1
View File
@@ -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 {
+3 -1
View File
@@ -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 {
+3 -1
View File
@@ -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)
+6 -2
View File
@@ -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)
+6 -1
View File
@@ -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)
+2 -2
View File
@@ -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()
+6 -2
View File
@@ -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)
+27
View File
@@ -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
}
+4 -4
View File
@@ -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")