Files
ARTEX/db/task_credentials.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

126 lines
4.5 KiB
Go

package db
import (
"errors"
"strings"
"time"
)
// TaskCredential is a per-task test account (#3): either declared by the user at
// task creation (source=user, e.g. a program-provided alias account) or registered
// by the agent itself and recorded so it is not forgotten (source=agent).
//
// Passwords are stored in the local Postgres, plaintext — the same trust domain as
// captured traffic evidence, which already contains credentials by nature. The UI
// masks them by default; anything injected into agent context transits to the LLM
// provider, so only dedicated test accounts belong here.
type TaskCredential struct {
ID int64 `json:"id"`
TaskID int64 `json:"task_id"`
Domain string `json:"domain"`
Username string `json:"username"`
Password string `json:"password"`
Source string `json:"source"` // "user" | "agent"
Note string `json:"note,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TaskCredentialInput validates and normalizes one upsert. Empty username or
// domain is rejected; an empty password is allowed only for source=user (the
// "agent generates and records it later" mode from #3).
type TaskCredentialInput struct {
Domain string
Username string
Password string
Source string
Note string
}
var ErrTaskCredentialInvalid = errors.New("credential domain and username are required")
func (in TaskCredentialInput) normalize() (TaskCredentialInput, error) {
in.Domain = strings.TrimSpace(in.Domain)
in.Username = strings.TrimSpace(in.Username)
in.Password = strings.TrimSpace(in.Password)
in.Note = strings.TrimSpace(in.Note)
if in.Source != "agent" {
in.Source = "user"
}
if in.Domain == "" || in.Username == "" {
return in, ErrTaskCredentialInvalid
}
if in.Password == "" && in.Source == "agent" {
return in, errors.New("agent-recorded credentials need the password that was set")
}
return in, nil
}
// UpsertTaskCredential inserts or updates the (task, domain, username) entry: an
// agent re-registering the same account overwrites the password in place instead
// of piling up a second row.
func (d *DB) UpsertTaskCredential(taskID int64, in TaskCredentialInput) (TaskCredential, error) {
in, err := in.normalize()
if err != nil {
return TaskCredential{}, err
}
var c TaskCredential
err = d.QueryRow(`
INSERT INTO task_credentials (task_id, domain, username, password, source, note)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (task_id, domain, username) DO UPDATE
SET password = EXCLUDED.password,
source = EXCLUDED.source,
note = CASE WHEN EXCLUDED.note = '' THEN task_credentials.note ELSE EXCLUDED.note END,
updated_at = now()
RETURNING id, task_id, domain, username, password, source, note, created_at, updated_at`,
taskID, in.Domain, in.Username, in.Password, in.Source, in.Note).
Scan(&c.ID, &c.TaskID, &c.Domain, &c.Username, &c.Password, &c.Source, &c.Note, &c.CreatedAt, &c.UpdatedAt)
return c, err
}
// TaskCredentials lists a task's credentials, oldest first (the user-declared
// ones keep their position ahead of agent-discovered ones).
func (d *DB) TaskCredentials(taskID int64) ([]TaskCredential, error) {
rows, err := d.Query(`
SELECT id, task_id, domain, username, password, source, note, created_at, updated_at
FROM task_credentials WHERE task_id = $1 ORDER BY id`, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []TaskCredential
for rows.Next() {
var c TaskCredential
if err := rows.Scan(&c.ID, &c.TaskID, &c.Domain, &c.Username, &c.Password, &c.Source, &c.Note, &c.CreatedAt, &c.UpdatedAt); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// DeleteTaskCredential removes one entry; taskID scopes the delete so a stale
// request cannot cross tasks.
func (d *DB) DeleteTaskCredential(taskID, id int64) error {
tag, err := d.Exec(`DELETE FROM task_credentials WHERE id = $1 AND task_id = $2`, id, taskID)
if err != nil {
return err
}
if n, _ := tag.RowsAffected(); n == 0 {
return errors.New("credential not found")
}
return nil
}
// Thin wrappers on ExplorationStore: the ToolSet already holds the task's
// exploration store, so credential tools need no extra wiring.
func (s *ExplorationStore) UpsertTaskCredential(taskID int64, in TaskCredentialInput) (TaskCredential, error) {
return s.db.UpsertTaskCredential(taskID, in)
}
func (s *ExplorationStore) TaskCredentials(taskID int64) ([]TaskCredential, error) {
return s.db.TaskCredentials(taskID)
}