fix(tools): migrate the add_task_scope description on existing databases (F8)

add_task_scope is seeded through BuiltinToolSeeds, SeedTool inserts only
once, and ToolResolve serves the stored row, so existing installs kept the
old "this is the task's authorization boundary" text.

seedTaskScopeDescription runs from seedOrchestrationTools and updates the
description only:

  UPDATE tools SET description=$new, updated_at=now()
  WHERE key='add_task_scope' AND system AND description=$old

$old is the description as seeded at 0dc8bdf, kept verbatim; $new is the
code description from BuiltinToolSeeds. It is idempotent, a no-op when the
row is absent, and leaves an operator-edited description alone. It does not
use RefreshToolDefaults, which overwrites without condition.

TestTaskScopeDescriptionUpgrade (server, PG) covers the replacement, the
preserved custom text, an unchanged updated_at on a second run, and the
description ToolResolve serves to the planner.
This commit is contained in:
BaaRai
2026-11-12 09:38:52 +01:00
parent cd8a830b53
commit 88306a0567
2 changed files with 124 additions and 0 deletions
+29
View File
@@ -572,6 +572,7 @@ func (s *Server) seedOrchestrationTools() {
s.seedFindingVerificationSchema() // S1: poe_level / evidence_quotes on update_finding_report, added once per DB
s.seedFindingEvidenceSchema() // optional counterevidence / confidence_rationale on report_finding, added once per DB
s.seedFactSourceSchema() // source enum on record_fact, added once per DB
s.seedTaskScopeDescription() // add_task_scope: "coverage scope, not enforced" replaces the authorization-boundary text
// Note: pentest's default tool bindings need no migration — BuiltinToolSeeds already seeds
// list_assets/insert_assets/report_finding/list_findings/list_companies together with
// pentest at fresh initialization (no old DBs exist in this project, so no migration).
@@ -612,6 +613,34 @@ WHERE key='record_fact' AND system AND NOT(COALESCE(schema->'properties','{}'::j
}
}
// oldTaskScopeDescription is the add_task_scope description as seeded up to commit
// 0dc8bdf, which called task_scope the task's authorization boundary. Kept verbatim:
// seedTaskScopeDescription replaces only a row that still holds exactly this text.
const oldTaskScopeDescription = "Add test scope to [this task] — this is the task's authorization boundary, and also the denominator of asset test coverage.\n" +
"Supported kinds: company (all assets under the company) / root_domain (a whole root domain, including all subdomains) / subdomain (a single precise subdomain) / ip / cidr / icp / keyword.\n" +
"Note: hosts a worker touches one by one are [automatically] added to the scope by the system (precise subdomains); this tool is for [deliberate widening] — pulling in a whole root domain / whole company, or adding a specific subdomain/IP.\n" +
"value: for company pass the company name or id (the company must already exist); for root_domain/subdomain pass a domain; for ip/cidr pass an IP or range; for icp/keyword pass an ICP number or company keyword.\n" +
"Always give reason explaining the basis (auditable). Use the entries array for multiple entries."
// seedTaskScopeDescription brings the add_task_scope description of existing
// databases to the code text (SeedTool is first-insert only, and ToolResolve serves
// the stored description, so without this the model would keep reading
// "authorization boundary"). Idempotent: only a system row still holding the old
// seeded text is replaced; an absent row or an operator-edited text is left alone.
func (s *Server) seedTaskScopeDescription() {
for _, seed := range agent.BuiltinToolSeeds() {
if seed.Key != "add_task_scope" {
continue
}
_, err := s.m.pg.Exec(`UPDATE tools SET description=$1,updated_at=now()
WHERE key='add_task_scope' AND system AND description=$2`, seed.Desc, oldTaskScopeDescription)
if err != nil {
log.Printf("[tools] upgrade add_task_scope description: %v", err)
}
return
}
}
// refreshBuiltinToolSchemas propagates code schema/description changes on the
// orchestration + platform tools into already-seeded rows ONCE per version flag —
// SeedTool is first-insert-only, so a new param (e.g. spawn_task's llm_profile) never
+95
View File
@@ -0,0 +1,95 @@
package server
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/Autumn-27/artex/agent"
)
// PG: on an existing database whose add_task_scope row still holds the old
// "authorization boundary" text, the startup seeders replace it with the code
// description; an operator-edited description is preserved; a second run changes
// nothing; and ToolResolve then serves the new description to the model.
func TestTaskScopeDescriptionUpgrade(t *testing.T) {
skipWithoutDSN(t)
m, err := NewManager(t.TempDir(), "")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { m.Close() })
ctx, cancel := context.WithCancel(context.Background())
cancel()
s := New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir())
t.Cleanup(func() { agent.ToolResolve = nil })
pg := m.pg
var want string
for _, seed := range agent.BuiltinToolSeeds() {
if seed.Key == "add_task_scope" {
want = seed.Desc
}
}
if want == "" || want == oldTaskScopeDescription {
t.Fatalf("precondition: code description must exist and differ from the old text: %q", want)
}
old, err := pg.GetTool("add_task_scope")
if err != nil || old == nil {
t.Fatal("missing add_task_scope", err)
}
bindings, _ := json.Marshal(old.Agents)
t.Cleanup(func() { pg.UpdateTool(old.Key, old.Description, old.Schema, bindings, old.Enabled) })
snapshot := func() (string, time.Time) {
var desc string
var upd time.Time
if err := pg.QueryRow(`SELECT description, updated_at FROM tools WHERE key='add_task_scope'`).Scan(&desc, &upd); err != nil {
t.Fatal(err)
}
return desc, upd
}
// An existing install still holding the old seeded text is upgraded.
if err := pg.UpdateTool("add_task_scope", oldTaskScopeDescription, old.Schema, bindings, true); err != nil {
t.Fatal(err)
}
s.seedOrchestrationTools()
desc1, upd1 := snapshot()
if desc1 != want {
t.Fatalf("old description not replaced: %q", desc1)
}
s.seedOrchestrationTools()
desc2, upd2 := snapshot()
if desc2 != desc1 || !upd2.Equal(upd1) {
t.Fatalf("second run changed the row: %q %v -> %q %v", desc1, upd1, desc2, upd2)
}
if got := resolvedTaskScopeDescription(t); got != want {
t.Fatalf("ToolResolve serves %q, want the new description", got)
}
// An operator-edited description is left alone.
if err := pg.UpdateTool("add_task_scope", "operator description", old.Schema, bindings, true); err != nil {
t.Fatal(err)
}
s.seedOrchestrationTools()
if desc, _ := snapshot(); desc != "operator description" {
t.Fatalf("operator description overwritten: %q", desc)
}
}
// resolvedTaskScopeDescription returns add_task_scope's description as
// ToolResolve serves it to the planner.
func resolvedTaskScopeDescription(t *testing.T) string {
t.Helper()
tools := agent.ToolResolve(context.Background(), "planner", agent.NewToolSet(nil, "").PlannerTools())
for _, tl := range tools {
if tl.Name() == "add_task_scope" {
return tl.Description()
}
}
t.Fatal("planner lost add_task_scope")
return ""
}