Files
ARTEX/agent/tools_creds.go
BaaRai c177ab9d86 feat(tasks): per-task test-account credentials
Task credentials (#3): declare test accounts at task creation, and never
lose the accounts the agent registers on its own.

- task_credentials table (unique per task+domain+username; upsert
  overwrites the password in place), idempotent schema migration
- POST /api/tasks accepts credentials; GET/POST/DELETE
  /api/tasks/{id}/credentials serve the UI (manual add, e.g. relaying an
  OTP-verified account; delete)
- empty password = the "agent generates" mode: the entry surfaces in the
  worker's graph overview with an explicit instruction to register that
  account with a strong password and record it
- new tools save_task_credential / list_task_credentials for worker and
  pentest; the graph overview injects the task's credentials each round
  with a standing note: reuse matching entries, always record accounts
  you register
- New Task dialog: optional credentials section (domain, username,
  password, note) with a dedicated-test-accounts hint
- task detail: Credentials tab, passwords masked with explicit
  reveal/copy, agent-recorded badge, light polling so mid-run recordings
  appear

One-shot binding migration (settings-flag-guarded, same pattern as the
other seeds) grants the two tools to worker+pentest on databases seeded
before this change; fresh DBs get them via WorkerTools()/the pentest
catalog.

Closes #3
2026-10-09 14:28:08 +02:00

119 lines
4.5 KiB
Go

package agent
import (
"context"
"encoding/json"
"github.com/Autumn-27/artex/db"
actool "github.com/Autumn-27/norma/tool"
)
// Task credential tools (#3): a per-task record of test accounts. User-declared
// accounts (e.g. a program-provided alias) are injected into the worker's graph
// overview; accounts the agent registers itself MUST be recorded via
// save_task_credential so they are not forgotten when the session context rolls
// over. Handlers guard nil stores internally, mirroring the asset tools.
func (t *ToolSet) saveTaskCredential() actool.CoreTool {
return writeTool(
"save_task_credential",
"Record (or update) a test account for this task's target. ALWAYS call this immediately after registering an account on the target, with the exact username and password you chose, so the account is not lost. Also use it to update a known account whose password changed. Same (domain, username) is overwritten in place, not duplicated.\n"+
"Returns: {id, domain, username, source}.",
obj(map[string]any{
"domain": str("domain or host the account belongs to (e.g. target.example.com)"),
"username": str("login name / email used"),
"password": str("the exact password that was set — required"),
"note": str("optional context, e.g. which registration flow created it"),
}, "domain", "username", "password"),
func(_ context.Context, in json.RawMessage) (actool.Result, error) {
var a struct {
Domain string `json:"domain"`
Username string `json:"username"`
Password string `json:"password"`
Note string `json:"note"`
}
if err := json.Unmarshal(in, &a); err != nil {
return actool.Errorf("invalid input: " + err.Error()), nil
}
if t.ts == nil {
return actool.Errorf("save_task_credential is not enabled: task store is not initialized"), nil
}
taskID := t.taskID
if taskID <= 0 {
var err error
taskID, err = t.ts.TaskID()
if err != nil || taskID <= 0 {
return actool.Errorf("save_task_credential is only available inside a task"), nil
}
t.taskID = taskID
}
c, err := t.ts.UpsertTaskCredential(taskID, db.TaskCredentialInput{
Domain: a.Domain, Username: a.Username, Password: a.Password,
Source: "agent", Note: a.Note,
})
if err != nil {
return actool.Errorf("save_task_credential failed: " + err.Error()), nil
}
return jsonResult(map[string]any{
"id": c.ID, "domain": c.Domain, "username": c.Username, "source": c.Source,
})
})
}
func (t *ToolSet) listTaskCredentials() actool.CoreTool {
return writeTool(
"list_task_credentials",
"List this task's test accounts: user-declared accounts (e.g. program-provided aliases; a user-declared entry with an empty password means: register an account on that domain with a strong password, then record it via save_task_credential) plus accounts recorded by earlier runs. Check this BEFORE registering a new account, and use matching entries for authenticated testing.",
obj(map[string]any{}, ""),
func(_ context.Context, _ json.RawMessage) (actool.Result, error) {
if t.ts == nil {
return actool.Errorf("list_task_credentials is not enabled: task store is not initialized"), nil
}
taskID := t.taskID
if taskID <= 0 {
var err error
taskID, err = t.ts.TaskID()
if err != nil || taskID <= 0 {
return actool.Errorf("list_task_credentials is only available inside a task"), nil
}
t.taskID = taskID
}
list, err := t.ts.TaskCredentials(taskID)
if err != nil {
return actool.Errorf("list_task_credentials failed: " + err.Error()), nil
}
return jsonResult(map[string]any{"credentials": list})
})
}
// taskCredentialOverview renders the credentials block injected into the worker's
// graph overview. Empty user-side passwords surface as an explicit instruction
// (the agent generates and records one) rather than a half-entry.
func (t *ToolSet) taskCredentialOverview() []map[string]any {
if t.ts == nil || t.taskID <= 0 {
return nil
}
list, err := t.ts.TaskCredentials(t.taskID)
if err != nil || len(list) == 0 {
return nil
}
out := make([]map[string]any, 0, len(list))
for _, c := range list {
m := map[string]any{
"domain": c.Domain,
"username": c.Username,
"source": c.Source,
}
if c.Note != "" {
m["note"] = c.Note
}
if c.Password == "" {
m["instruction"] = "no password yet: register an account with this username on this domain (choose a strong unique password), then record it via save_task_credential"
} else {
m["password"] = c.Password
}
out = append(out, m)
}
return out
}