diff --git a/server/ask_operator.go b/server/ask_operator.go new file mode 100644 index 0000000..4780146 --- /dev/null +++ b/server/ask_operator.go @@ -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 +} diff --git a/server/ask_operator_test.go b/server/ask_operator_test.go new file mode 100644 index 0000000..7447e00 --- /dev/null +++ b/server/ask_operator_test.go @@ -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) + } +} diff --git a/server/orchestration.go b/server/orchestration.go index 2251a2c..694b6af 100644 --- a/server/orchestration.go +++ b/server/orchestration.go @@ -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() diff --git a/server/platform_tools.go b/server/platform_tools.go index f37cef6..7ebde5e 100644 --- a/server/platform_tools.go +++ b/server/platform_tools.go @@ -19,6 +19,7 @@ import ( func (s *Server) platformTools() []actool.CoreTool { return []actool.CoreTool{ s.toolMCPStatus(), + s.toolAskOperator(), s.toolCreateSkill(), s.toolUpdateSkillFile(), s.toolCreateCustomTool(),