mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 11:40:53 +08:00
feat: ask_operator tool - workers can request operator input (captcha/OTP) with a screenshot and pause
A worker hitting a wall only a human can clear (captcha, email/SMS OTP, 2FA,
a decision) calls ask_operator with a precise question and an optional
screenshot. The question lands in the intent's transcript as a user-role turn
with an attachment card (the same {text, attachments} shape chat uploads use,
so the existing transcript UI renders it natively), and the intent pauses. The
operator replies through the existing message-to-worker flow; the reply resumes
the run with the answer as the next input. Images are whitelisted (png/jpg/
jpeg/webp/gif), capped at 10MB, and copied into the task uploads dir.
Closes #41
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/agent"
|
||||
actool "github.com/Autumn-27/norma/tool"
|
||||
)
|
||||
|
||||
const askOperatorMaxImage = 10 << 20
|
||||
|
||||
var askOperatorImageExt = map[string]bool{".png": true, ".jpg": true, ".jpeg": true, ".webp": true, ".gif": true}
|
||||
|
||||
// toolAskOperator lets a worker formally request operator input (captcha, OTP,
|
||||
// 2FA, a decision): the question and an optional screenshot land in the intent's
|
||||
// transcript as a user-role turn with an attachment card, and the intent pauses.
|
||||
// The operator replies through the existing message-to-worker flow; the reply
|
||||
// resumes the run with the answer as the next input (#41).
|
||||
func (s *Server) toolAskOperator() actool.CoreTool {
|
||||
return wrTool("ask_operator",
|
||||
"Ask the human operator for input you cannot obtain yourself (a captcha solution, an email/SMS OTP code, a 2FA answer, a decision). The question appears in this intent's chat for the operator, optionally with a screenshot, and this intent then pauses; the operator's reply resumes this run with the answer as your next input. Ask precisely, and only when you are genuinely blocked.",
|
||||
map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"question": map[string]any{
|
||||
"type": "string",
|
||||
"description": "What you need from the operator, precisely (e.g. 'enter the 6-digit code sent to x@y', 'solve the captcha in the attached screenshot').",
|
||||
},
|
||||
"image_path": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Optional path to a screenshot (png/jpg/jpeg/webp/gif, max 10MB), e.g. the browser_take_screenshot output. It is copied into the task uploads and shown to the operator.",
|
||||
},
|
||||
},
|
||||
"required": []any{"question"},
|
||||
},
|
||||
func(ctx context.Context, in json.RawMessage) (actool.Result, error) {
|
||||
var req struct {
|
||||
Question string `json:"question"`
|
||||
ImagePath string `json:"image_path"`
|
||||
}
|
||||
_ = json.Unmarshal(in, &req)
|
||||
q := strings.TrimSpace(req.Question)
|
||||
if q == "" {
|
||||
return actool.Errorf("Error: question is required"), nil
|
||||
}
|
||||
ri := agent.RunInfoFrom(ctx)
|
||||
if ri.TaskID == 0 || ri.IntentID == 0 {
|
||||
return actool.Errorf("Error: ask_operator can only be used inside a worker intent run"), nil
|
||||
}
|
||||
taskID := fmt.Sprintf("%d", ri.TaskID)
|
||||
t := s.m.ResolveTask(taskID)
|
||||
if t == nil {
|
||||
return actool.Errorf("Error: task not found"), nil
|
||||
}
|
||||
att, err := stageAskOperatorImage(s.m.dir, taskID, req.ImagePath)
|
||||
if err != nil {
|
||||
return actool.Errorf("Error: " + err.Error()), nil
|
||||
}
|
||||
text := "OPERATOR INPUT REQUESTED: " + q + " (this intent is pausing; reply here to resume it with your answer)"
|
||||
act := userActivityWithAttachments("worker", text, att)
|
||||
node := ri.IntentID
|
||||
act.NodeID = &node
|
||||
s.engine.emitActivity(t, act)
|
||||
// The control call runs on a detached context: pausing may cancel the
|
||||
// run's context, and the pause bookkeeping itself must complete.
|
||||
if _, err := s.applyIntentControl(context.Background(), t, ri.IntentID, "pause", firstLine(q, 120), ""); err != nil {
|
||||
return actool.Errorf("Error: question delivered but pausing the intent failed: " + err.Error()), nil
|
||||
}
|
||||
return actool.Text("Question delivered to the operator. This intent is pausing now: finish your current step, record everything you already know as facts, and stop. The operator's reply will resume this run and arrive as your next input."), nil
|
||||
})
|
||||
}
|
||||
|
||||
// stageAskOperatorImage copies the referenced image into the task's uploads dir
|
||||
// (the same place chat uploads land, so the transcript attachment card resolves)
|
||||
// and returns the attachment descriptor. Empty src returns no attachment.
|
||||
func stageAskOperatorImage(dataDir, taskID, src string) ([]chatAttachment, error) {
|
||||
src = strings.TrimSpace(src)
|
||||
if src == "" {
|
||||
return nil, nil
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(src))
|
||||
if !askOperatorImageExt[ext] {
|
||||
return nil, fmt.Errorf("image_path must be a png/jpg/jpeg/webp/gif file, got %q", filepath.Base(src))
|
||||
}
|
||||
st, err := os.Stat(src)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("image not readable: %v", err)
|
||||
}
|
||||
if st.Size() > askOperatorMaxImage {
|
||||
return nil, fmt.Errorf("image too large (%s, max 10MB)", humanBytes(st.Size()))
|
||||
}
|
||||
dir := filepath.Join(dataDir, "tasks", taskID, "uploads")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name := fmt.Sprintf("ask-operator-%d%s", time.Now().Unix(), ext)
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.Create(filepath.Join(dir, name))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer out.Close()
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []chatAttachment{{Name: name, Path: "uploads/" + name, Size: st.Size()}}, nil
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStageAskOperatorImage(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "shot.png")
|
||||
if err := os.WriteFile(src, []byte("fakepng"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// no image -> no attachment
|
||||
att, err := stageAskOperatorImage(dir, "7", "")
|
||||
if err != nil || att != nil {
|
||||
t.Fatalf("empty src: att=%v err=%v", att, err)
|
||||
}
|
||||
|
||||
// valid image -> copied under the task uploads, descriptor matches the chat format
|
||||
att, err = stageAskOperatorImage(dir, "7", src)
|
||||
if err != nil || len(att) != 1 {
|
||||
t.Fatalf("valid src: att=%v err=%v", att, err)
|
||||
}
|
||||
if !strings.HasPrefix(att[0].Path, "uploads/ask-operator-") || !strings.HasSuffix(att[0].Path, ".png") {
|
||||
t.Fatalf("unexpected attachment path %q", att[0].Path)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "tasks", "7", "uploads", att[0].Name)); err != nil {
|
||||
t.Fatalf("copy missing: %v", err)
|
||||
}
|
||||
|
||||
// wrong extension rejected
|
||||
bad := filepath.Join(dir, "payload.exe")
|
||||
if err := os.WriteFile(bad, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := stageAskOperatorImage(dir, "7", bad); err == nil || !strings.Contains(err.Error(), "png/jpg") {
|
||||
t.Fatalf("expected extension rejection, got %v", err)
|
||||
}
|
||||
|
||||
// missing file rejected
|
||||
if _, err := stageAskOperatorImage(dir, "7", filepath.Join(dir, "nope.png")); err == nil {
|
||||
t.Fatal("expected missing-file rejection")
|
||||
}
|
||||
}
|
||||
|
||||
// The emitted activity must carry the {text, attachments} Detail shape the
|
||||
// transcript parser expects, so the operator sees the question with the
|
||||
// attachment card for the screenshot.
|
||||
func TestAskOperatorActivityShape(t *testing.T) {
|
||||
att := []chatAttachment{{Name: "ask-operator-1.png", Path: "uploads/ask-operator-1.png", Size: 7}}
|
||||
a := userActivityWithAttachments("worker", "OPERATOR INPUT REQUESTED: solve the captcha", att)
|
||||
if a.Kind != "user" || a.Worker != "worker" {
|
||||
t.Fatalf("unexpected activity kind/worker: %+v", a)
|
||||
}
|
||||
var parsed struct {
|
||||
Text string `json:"text"`
|
||||
Attachments []chatAttachment `json:"attachments"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(a.Detail), &parsed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(parsed.Text, "OPERATOR INPUT REQUESTED") || len(parsed.Attachments) != 1 || parsed.Attachments[0].Path != att[0].Path {
|
||||
t.Fatalf("unexpected detail payload: %+v", parsed)
|
||||
}
|
||||
}
|
||||
@@ -489,6 +489,10 @@ func (s *Server) seedOrchestrationTools() {
|
||||
// health visibility is for the executors too, not just the platform agent
|
||||
bindings = json.RawMessage(`["auto","worker","pentest"]`)
|
||||
}
|
||||
if t.Name() == "ask_operator" {
|
||||
// operator input requests are an executor concern (captcha/OTP walls)
|
||||
bindings = json.RawMessage(`["worker","pentest"]`)
|
||||
}
|
||||
_ = s.m.PG().SeedTool(t.Name(), t.Description(), schema, bindings)
|
||||
}
|
||||
s.refreshBuiltinToolSchemas()
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
func (s *Server) platformTools() []actool.CoreTool {
|
||||
return []actool.CoreTool{
|
||||
s.toolMCPStatus(),
|
||||
s.toolAskOperator(),
|
||||
s.toolCreateSkill(),
|
||||
s.toolUpdateSkillFile(),
|
||||
s.toolCreateCustomTool(),
|
||||
|
||||
Reference in New Issue
Block a user