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 "" +}