From 88306a05679bb6b175b9512dcaa3c6cb3501422f Mon Sep 17 00:00:00 2001 From: BaaRai <130491490+BaaRai@users.noreply.github.com> Date: Thu, 12 Nov 2026 09:38:52 +0100 Subject: [PATCH] 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. --- server/orchestration.go | 29 ++++++++ server/task_scope_description_test.go | 95 +++++++++++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 server/task_scope_description_test.go diff --git a/server/orchestration.go b/server/orchestration.go index 2f184d9..d03dc4a 100644 --- a/server/orchestration.go +++ b/server/orchestration.go @@ -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 diff --git a/server/task_scope_description_test.go b/server/task_scope_description_test.go new file mode 100644 index 0000000..d943385 --- /dev/null +++ b/server/task_scope_description_test.go @@ -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 "" +}