Files
larksuite-cli/cmd/agent/task_test.go
liuxinyang.lxy cb8e2c58df fix: reject oversized artifacts and split multi_turn into per-verb caps
Two review follow-ups on the agent command tree.

Artifact download (data integrity): fetchArtifactURL now reads one byte past
maxArtifactBytes and refuses a body over the cap with a typed error, instead of
letting io.LimitReader silently truncate a >256 MiB artifact to a corrupt,
partial file that reported success (exit 0). The LimitEnforced test is inverted
to assert the rejection.

Capabilities: the single multi_turn card bit is replaced by three independent
capabilities — context_list / context_get / context_delete — each derived from
its own wired hook (ListContexts / GetContext / DeleteContext). One bit could
not honestly represent three separately-deliverable verbs: a provider wiring
ListContexts but not DeleteContext advertised multi_turn=true while `context
delete` failed claiming multi_turn=false. Each context verb now gates on and
reports its own capability. Card schema, capability matrix, the three context
gates, tests, and the lark-agent skill docs are updated in lockstep.
2026-07-13 14:08:42 +08:00

1142 lines
43 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
iagent "github.com/larksuite/cli/internal/agent"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
// taskCmdCtx builds a `lark-cli agent task <leaf>` command whose CommandPath()
// is non-empty (required for content-safety scanning) and whose --as flag is set
// to bot so ResolveAs honors it verbatim.
func taskCmdCtx(t *testing.T, leaf string) *cobra.Command {
t.Helper()
root := &cobra.Command{Use: "lark-cli"}
group := &cobra.Command{Use: "agent"}
task := &cobra.Command{Use: "task"}
l := &cobra.Command{Use: leaf}
root.AddCommand(group)
group.AddCommand(task)
task.AddCommand(l)
l.Flags().String("as", "", "identity")
if err := l.Flags().Set("as", "bot"); err != nil {
t.Fatal(err)
}
l.SetContext(context.Background())
return l
}
// taskTestOpts wires a taskOptions against a real (test) Factory, addressing
// the scripted fakeflow agent agt_x under an explicit bot identity. The
// Factory's httpmock registry holds zero stubs, so any HTTP attempt fails the
// test; provider behavior is scripted via setScripted.
func taskTestOpts(t *testing.T, leaf string) (*taskOptions, *httpmock.Registry) {
t.Helper()
registerScripted()
cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu}
f, _, _, reg := cmdutil.TestFactory(t, cfg)
return &taskOptions{
Factory: f,
Cmd: taskCmdCtx(t, leaf),
Ref: "fakeflow:agt_x",
TaskID: "chat_1",
As: "bot",
}, reg
}
// TestTaskCancelUnsupportedGated pins that cancel against an agent whose spec
// does not wire CancelTask (task_cancel=false, example:echo) is gated offline —
// it returns an unsupported_capability validation error before any network
// access (the httpmock registry has zero stubs, so any request would fail
// differently).
func TestTaskCancelUnsupportedGated(t *testing.T) {
cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu}
f, _, _, _ := cmdutil.TestFactory(t, cfg)
err := agentTaskCancelRun(&taskOptions{
Factory: f, Cmd: taskCmdCtx(t, "cancel"), Ref: "example:echo", TaskID: "t1", As: "bot",
})
if err == nil {
t.Fatal("task cancel with task_cancel=false should report unsupported_capability")
}
if !errs.IsValidation(err) {
t.Fatalf("want validation error, got %T", err)
}
p, ok := errs.ProblemOf(err)
if !ok || p.Subtype != errs.Subtype("unsupported_capability") {
t.Fatalf("subtype should be unsupported_capability, got %+v", p)
}
if output.ExitCodeOf(err) != output.ExitValidation {
t.Fatalf("exit should be %d, got %d", output.ExitValidation, output.ExitCodeOf(err))
}
}
// TestArtifactRequiresOutput pins that `task get --artifact` without -o is a
// validation error raised before any provider is built (client-side guard).
func TestArtifactRequiresOutput(t *testing.T) {
err := agentTaskGetRun(&taskOptions{Ref: "example:agt_x", TaskID: "t1", ArtifactID: "a1", Output: ""})
if err == nil {
t.Fatal("--artifact without -o should error")
}
if !errs.IsValidation(err) {
t.Fatalf("want validation error, got %T", err)
}
p, ok := errs.ProblemOf(err)
if !ok || p.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype should be invalid_argument, got %+v", p)
}
// hint contract: --artifact without -o must carry a remediation hint, and
// the param uses the -- prefix.
if !strings.Contains(p.Hint, "-o") {
t.Errorf("hint should guide adding -o, got %q", p.Hint)
}
var verr *errs.ValidationError
if !errors.As(err, &verr) || verr.Param != "--output" {
t.Errorf("param should be --output, got %+v", verr)
}
}
// TestTaskGetSingle pins the single get (no --watch): GetTask returns the
// provider's task and the command emits it in a success envelope, exit 0.
func TestTaskGetSingle(t *testing.T) {
opts, _ := taskTestOpts(t, "get")
setScripted(t, scriptedHooks{getTask: func(taskID string) (*iagent.AgentTask, error) {
return &iagent.AgentTask{TaskID: taskID, ContextID: "sess_1", State: iagent.StateCompleted, IsTerminal: true}, nil
}})
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentTaskGetRun(opts); err != nil {
t.Fatalf("get should not error: %v", err)
}
var env output.Envelope
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("output should be valid envelope JSON: %v (%s)", err, string(out.Bytes()))
}
data, _ := env.Data.(map[string]interface{})
if data["state"] != string(iagent.StateCompleted) {
t.Errorf("state should be completed, got %v", data["state"])
}
}
// TestTaskGetError surfaces a provider GetTask failure unchanged.
func TestTaskGetError(t *testing.T) {
opts, _ := taskTestOpts(t, "get")
setScripted(t, scriptedHooks{getTask: func(string) (*iagent.AgentTask, error) {
return nil, errs.NewAPIError(errs.SubtypeNotFound, "not found").WithCode(1254000)
}})
if err := agentTaskGetRun(opts); err == nil {
t.Fatal("GetTask error should propagate")
}
}
// TestTaskGetWatchTerminalFailureExit pins that a --watch that lands on a failed
// terminal state emits the envelope but returns the silent exit-1.
func TestTaskGetWatchTerminalFailureExit(t *testing.T) {
restore := swapSleep()
defer restore()
opts, _ := taskTestOpts(t, "get")
opts.Watch = true
setScripted(t, scriptedHooks{getTask: func(taskID string) (*iagent.AgentTask, error) {
return &iagent.AgentTask{TaskID: taskID, State: iagent.StateFailed, IsTerminal: true}, nil
}})
if err := agentTaskGetRun(opts); output.ExitCodeOf(err) != 1 {
t.Fatalf("failed terminal state should return exit 1, got %d (err=%v)", output.ExitCodeOf(err), err)
}
}
// TestTaskGetWatchTerminalSuccessExit pins that a --watch reaching a successful
// terminal state emits the task and exits 0.
func TestTaskGetWatchTerminalSuccessExit(t *testing.T) {
restore := swapSleep()
defer restore()
opts, _ := taskTestOpts(t, "get")
opts.Watch = true
setScripted(t, scriptedHooks{getTask: func(taskID string) (*iagent.AgentTask, error) {
return &iagent.AgentTask{TaskID: taskID, State: iagent.StateCompleted, IsTerminal: true}, nil
}})
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
err := agentTaskGetRun(opts)
if output.ExitCodeOf(err) != output.ExitOK {
t.Fatalf("completed terminal state should exit 0, got %d (err=%v)", output.ExitCodeOf(err), err)
}
var env output.Envelope
if uerr := json.Unmarshal(out.Bytes(), &env); uerr != nil {
t.Fatalf("output should be valid envelope JSON: %v (%s)", uerr, string(out.Bytes()))
}
data, _ := env.Data.(map[string]interface{})
if data["state"] != string(iagent.StateCompleted) {
t.Errorf("--watch should poll to completed, got %v", data["state"])
}
}
// TestTaskGetTimeoutRequiresWatch pins the combo guard: --timeout is meaningful
// only with --watch, so passing it alone is a client-side validation error
// (exit 2, param --timeout) before any network access.
func TestTaskGetTimeoutRequiresWatch(t *testing.T) {
opts, _ := taskTestOpts(t, "get")
opts.Timeout = 30 * time.Second // no --watch
err := agentTaskGetRun(opts)
if err == nil {
t.Fatal("--timeout without --watch should error")
}
if !errs.IsValidation(err) || output.ExitCodeOf(err) != output.ExitValidation {
t.Fatalf("want validation error (exit 2), got %T exit=%d", err, output.ExitCodeOf(err))
}
var ve *errs.ValidationError
if !errors.As(err, &ve) || ve.Param != "--timeout" {
t.Fatalf("param should be --timeout, got %+v", ve)
}
if !strings.Contains(ve.Message, "--timeout 需与 --watch 一起使用") {
t.Errorf("message should explain --timeout must be used with --watch, got %q", ve.Message)
}
}
// TestTaskGetWatchBoundedTimeout pins the bounded watch: with --watch and a
// short --timeout against a task that never terminates (always Running), the
// poll deadline fires and the command degrades gracefully — it prints the
// current (working) state, exits 0 (a timeout is not a failure), and its
// meta.next suggests a fresh bounded watch (`--watch --timeout`). A real (short)
// deadline is used so the context.WithTimeout wiring itself is exercised; the
// backoff sleep is cut short by the deadline.
func TestTaskGetWatchBoundedTimeout(t *testing.T) {
opts, _ := taskTestOpts(t, "get")
opts.Watch = true
opts.Timeout = 40 * time.Millisecond
// The initial get + each poll iteration all observe a still-working task.
setScripted(t, scriptedHooks{getTask: func(taskID string) (*iagent.AgentTask, error) {
return &iagent.AgentTask{TaskID: taskID, State: iagent.StateWorking}, nil
}})
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
err := agentTaskGetRun(opts)
if output.ExitCodeOf(err) != output.ExitOK {
t.Fatalf("watch timeout should exit 0 (timeout is not a failure), got %d (err=%v)", output.ExitCodeOf(err), err)
}
var env output.Envelope
if uerr := json.Unmarshal(out.Bytes(), &env); uerr != nil {
t.Fatalf("output should be valid envelope JSON: %v (%s)", uerr, string(out.Bytes()))
}
data, _ := env.Data.(map[string]interface{})
if data["state"] != string(iagent.StateWorking) {
t.Errorf("timeout should return the current working state, got %v", data["state"])
}
if env.Meta == nil || len(env.Meta.Next) == 0 {
t.Fatalf("non-terminal should provide a meta.next continue-watch command, got %+v", env.Meta)
}
if cmd := env.Meta.Next[0].Command; !strings.Contains(cmd, "--watch --timeout") {
t.Errorf("meta.next should suggest a bounded watch (--watch --timeout), got %q", cmd)
}
}
// TestTaskListEmitsCount pins that `task list` returns {tasks:[...]} with a
// meta.count reflecting the number of tasks.
func TestTaskListEmitsCount(t *testing.T) {
opts, _ := taskTestOpts(t, "list")
setScripted(t, scriptedHooks{listTasks: func(contextID string) ([]iagent.TaskSummary, error) {
return []iagent.TaskSummary{
{TaskID: "chat_1", State: iagent.StateCompleted, IsTerminal: true},
{TaskID: "chat_2", State: iagent.StateWorking},
}, nil
}})
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentTaskListRun(opts); err != nil {
t.Fatalf("list should not error: %v", err)
}
var env output.Envelope
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("output should be valid envelope JSON: %v (%s)", err, string(out.Bytes()))
}
data, _ := env.Data.(map[string]interface{})
tasks, ok := data["tasks"].([]interface{})
if !ok || len(tasks) != 2 {
t.Fatalf("data.tasks should have 2, got %v", data["tasks"])
}
if env.Meta == nil || env.Meta.Count != 2 {
t.Errorf("meta.count should be 2, got %+v", env.Meta)
}
}
// TestTaskListEmptyEmitsArray pins the array convention: an empty task list
// serializes as [] (never null), matching Card.Parameters.
func TestTaskListEmptyEmitsArray(t *testing.T) {
opts, _ := taskTestOpts(t, "list")
setScripted(t, scriptedHooks{listTasks: func(string) ([]iagent.TaskSummary, error) {
return nil, nil
}})
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentTaskListRun(opts); err != nil {
t.Fatalf("list should not error: %v", err)
}
var env output.Envelope
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("output should be valid envelope JSON: %v (%s)", err, string(out.Bytes()))
}
data, _ := env.Data.(map[string]interface{})
v, present := data["tasks"]
if !present {
t.Fatal("data.tasks key should be present")
}
if _, ok := v.([]interface{}); !ok {
t.Errorf("empty task list should emit a JSON array (not null), got %T: %v", v, v)
}
if env.Meta == nil || env.Meta.Count != 0 {
t.Errorf("meta.count should be 0, got %+v", env.Meta)
}
}
// TestTaskListError surfaces a provider ListTasks failure.
func TestTaskListError(t *testing.T) {
opts, _ := taskTestOpts(t, "list")
setScripted(t, scriptedHooks{listTasks: func(string) ([]iagent.TaskSummary, error) {
return nil, errs.NewAPIError(errs.SubtypeUnknown, "app ticket invalid").WithCode(99991663)
}})
if err := agentTaskListRun(opts); err == nil {
t.Fatal("ListTasks error should propagate")
}
}
// TestTaskGetArtifactURLDownload pins the --artifact URL path: DownloadArtifact
// returns a URL, which is SSRF-validated, fetched, and written to -o via vfs.
func TestTaskGetArtifactURLDownload(t *testing.T) {
// A local test server stands in for the artifact host; its loopback address
// is allowlisted for the test via the download client seam below.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("REPORT-BYTES"))
}))
defer srv.Close()
restore := swapArtifactFetch(func(_ context.Context, _ *cmdutil.Factory, rawURL string) ([]byte, error) {
resp, err := http.Get(rawURL) //nolint:gosec,noctx // test-only fetch of a loopback httptest server
if err != nil {
return nil, err
}
defer resp.Body.Close()
buf := make([]byte, 0, 32)
tmp := make([]byte, 32)
for {
n, rerr := resp.Body.Read(tmp)
buf = append(buf, tmp[:n]...)
if rerr != nil {
break
}
}
return buf, nil
})
defer restore()
// SafeOutputPath requires a relative path within the CWD, so run from a
// temp dir with a relative -o.
chdirTemp(t)
outPath := "report.txt"
opts, _ := taskTestOpts(t, "get")
opts.ArtifactID = "art_1"
opts.Output = outPath
// name is untrusted server input, deliberately set to a path-traversal value:
// if it participates in local path construction, the artifact would escape
// the -o path and be caught by the assertions below. This runs the real
// resolveDownload path (resolveProvider → provider.DownloadArtifact), with
// the provider returning a url-type artifact.
setScripted(t, scriptedHooks{downloadArtifact: func(taskID, artifactID string) (*iagent.ArtifactData, error) {
return &iagent.ArtifactData{Name: "../escape.txt", URL: srv.URL}, nil
}})
if err := agentTaskGetRun(opts); err != nil {
t.Fatalf("artifact download should not error: %v", err)
}
got, err := os.ReadFile(outPath)
if err != nil {
t.Fatalf("artifact should be written to the -o path (the response name must not rewrite the on-disk location): %v", err)
}
if string(got) != "REPORT-BYTES" {
t.Errorf("artifact content should be the downloaded bytes, got %q", string(got))
}
if _, err := os.Stat("../escape.txt"); !os.IsNotExist(err) {
t.Errorf("the response name must not participate in path construction: ../escape.txt should not exist (stat err=%v)", err)
}
// name is echoed to suggested_name as advisory info (for the AI to pick -o),
// but never affects the on-disk path: above already proved the file is at
// report.txt and no escape file was created. This proves it is surfaced (honesty).
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
if !strings.Contains(string(out.Bytes()), `"suggested_name": "../escape.txt"`) {
t.Errorf("download output should echo the server-suggested name to suggested_name, got: %s", out.Bytes())
}
}
// TestTaskGetArtifactInlineWrite pins the --artifact inline path: an artifact
// with inline Bytes (no URL) is written straight to -o without any network.
func TestTaskGetArtifactInlineWrite(t *testing.T) {
fetched := false
restore := swapArtifactFetch(func(context.Context, *cmdutil.Factory, string) ([]byte, error) {
fetched = true
return nil, nil
})
defer restore()
chdirTemp(t)
outPath := "inline.bin"
opts, _ := taskTestOpts(t, "get")
opts.ArtifactID = "art_inline"
opts.Output = outPath
// Swap the provider seam so DownloadArtifact yields inline bytes.
restoreP := swapResolveDownload(func(*taskOptions) (*iagent.ArtifactData, error) {
return &iagent.ArtifactData{Mime: "application/octet-stream", Bytes: []byte("INLINE")}, nil
})
defer restoreP()
if err := agentTaskGetRun(opts); err != nil {
t.Fatalf("writing an inline artifact should not error: %v", err)
}
if fetched {
t.Error("an inline artifact should not trigger a URL download")
}
got, err := os.ReadFile(outPath)
if err != nil {
t.Fatalf("inline artifact should be written to the -o path: %v", err)
}
if string(got) != "INLINE" {
t.Errorf("inline artifact content mismatch, got %q", string(got))
}
}
// TestNewCmdAgentTask_GroupHasSubcommands pins that the task group registers
// get/list/cancel and has no RunE of its own.
func TestNewCmdAgentTask_GroupHasSubcommands(t *testing.T) {
cmd := NewCmdAgentTask(nil)
if cmd.RunE != nil {
t.Error("task should be a pure group with no RunE")
}
want := map[string]bool{"get": false, "list": false, "cancel": false}
for _, sub := range cmd.Commands() {
want[sub.Name()] = true
}
for name, found := range want {
if !found {
t.Errorf("task should contain subcommand %q", name)
}
}
}
// TestNewCmdAgentTaskGet_ReadRiskArgsFlags pins ExactArgs(2), read risk and the
// get-specific flag surface.
func TestNewCmdAgentTaskGet_ReadRiskArgsFlags(t *testing.T) {
cmd := NewCmdAgentTaskGet(nil)
if level, ok := cmdutil.GetRisk(cmd); !ok || level != cmdutil.RiskRead {
t.Errorf("task get should be marked read risk, got level=%q ok=%v", level, ok)
}
if err := cmd.Args(cmd, []string{"example:x"}); err == nil {
t.Error("task get missing task-id should raise an args error (ExactArgs 2)")
}
if err := cmd.Args(cmd, []string{"example:x", "t1"}); err != nil {
t.Errorf("task get with two positional args should be valid: %v", err)
}
for _, name := range []string{"watch", "timeout", "artifact", "output", "as", "format", "jq"} {
if cmd.Flags().Lookup(name) == nil {
t.Errorf("task get should have --%s flag", name)
}
}
if cmd.Flags().ShorthandLookup("o") == nil {
t.Error("task get --output should have the -o shorthand")
}
// flag cleanup: --until had only one valid value and was never read, so it
// is removed.
if cmd.Flags().Lookup("until") != nil {
t.Error("task get --until should be removed")
}
if strings.Contains(cmd.Long, "--until") {
t.Errorf("help text should no longer mention --until, got %q", cmd.Long)
}
}
// TestTaskGetPrettyFormat pins that `task get --format pretty` renders
// key: value lines (state / task_id / context_id / first text message
// truncated / artifacts count) with agent text stripped of ANSI escapes.
func TestTaskGetPrettyFormat(t *testing.T) {
opts, _ := taskTestOpts(t, "get")
opts.Format = "pretty"
setScripted(t, scriptedHooks{getTask: func(taskID string) (*iagent.AgentTask, error) {
return &iagent.AgentTask{
TaskID: taskID, ContextID: "sess_1", State: iagent.StateCompleted, IsTerminal: true,
Messages: []iagent.Message{{Role: "agent", Parts: []iagent.Part{
{Type: "text", Text: "\x1b[31m分析完成\x1b[0m"},
}}},
Artifacts: []iagent.Artifact{{ID: "art_1", Kind: "file"}},
}, nil
}})
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentTaskGetRun(opts); err != nil {
t.Fatalf("task get --format pretty should not error: %v", err)
}
text := string(out.Bytes())
for _, want := range []string{"state: completed", "task_id: chat_1", "context_id: sess_1", "text: 分析完成", "artifacts: 1"} {
if !strings.Contains(text, want) {
t.Errorf("pretty output should contain %q, got:\n%s", want, text)
}
}
if strings.Contains(text, "\x1b") {
t.Errorf("the agent body ANSI sequences must be stripped: %q", text)
}
var env output.Envelope
if json.Unmarshal(out.Bytes(), &env) == nil && env.OK {
t.Errorf("pretty should not be a JSON envelope: %s", text)
}
}
// TestTaskListPrettyFormat pins list-class pretty: header TSV whose columns
// mirror the json fields (now including UPDATED_AT + SUMMARY), and a data row
// carrying the timestamp and (flattened) summary.
func TestTaskListPrettyFormat(t *testing.T) {
opts, _ := taskTestOpts(t, "list")
opts.Format = "pretty"
setScripted(t, scriptedHooks{listTasks: func(string) ([]iagent.TaskSummary, error) {
return []iagent.TaskSummary{
{TaskID: "chat_1", ContextID: "sess_1", State: iagent.StateCompleted, IsTerminal: true,
UpdatedAt: "2026-07-05T12:00:00Z", Summary: "分析完成"},
}, nil
}})
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentTaskListRun(opts); err != nil {
t.Fatalf("task list --format pretty should not error: %v", err)
}
text := string(out.Bytes())
if !strings.HasPrefix(text, "TASK_ID\tCONTEXT_ID\tSTATE\tIS_TERMINAL\tUPDATED_AT\tSUMMARY\n") {
t.Errorf("pretty output should start with a header row, got %q", text)
}
if !strings.Contains(text, "chat_1\tsess_1\tcompleted\ttrue\t2026-07-05T12:00:00Z\t分析完成") {
t.Errorf("pretty output should contain a data row with updated_at + summary, got %q", text)
}
}
// TestTaskListPrettySanitizesStateAndTimestamp pins that the agent-controlled
// State and UpdatedAt fields are ANSI-stripped on the pretty/TSV path, not just
// the ids/summary — a malicious provider must not inject terminal escapes via a
// forged state string or timestamp.
func TestTaskListPrettySanitizesStateAndTimestamp(t *testing.T) {
opts, _ := taskTestOpts(t, "list")
opts.Format = "pretty"
setScripted(t, scriptedHooks{listTasks: func(string) ([]iagent.TaskSummary, error) {
return []iagent.TaskSummary{
{TaskID: "chat_1", State: iagent.TaskState("completed\x1b[2J"), IsTerminal: true,
UpdatedAt: "2026-07-05T12:00:00Z\x1b]0;pwned\x07"},
}, nil
}})
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentTaskListRun(opts); err != nil {
t.Fatalf("task list --format pretty should not error: %v", err)
}
if text := string(out.Bytes()); strings.Contains(text, "\x1b") {
t.Errorf("ANSI/OSC sequences in State/UpdatedAt must be stripped, got %q", text)
}
}
// TestTaskListSortedByUpdatedAtDesc pins the ordering + enriched-field
// contract: the provider returns tasks out of order, and the command emits them
// newest-first by updated_at while carrying updated_at + summary on each.
func TestTaskListSortedByUpdatedAtDesc(t *testing.T) {
opts, _ := taskTestOpts(t, "list")
setScripted(t, scriptedHooks{listTasks: func(string) ([]iagent.TaskSummary, error) {
return []iagent.TaskSummary{
{TaskID: "old", State: iagent.StateCompleted, UpdatedAt: "2026-07-05T10:00:00Z", Summary: "第一轮"},
{TaskID: "new", State: iagent.StateInputRequired, UpdatedAt: "2026-07-05T12:00:00Z", Summary: "请补充"},
{TaskID: "mid", State: iagent.StateCompleted, UpdatedAt: "2026-07-05T11:00:00Z", Summary: "第二轮"},
}, nil
}})
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentTaskListRun(opts); err != nil {
t.Fatalf("task list should not error: %v", err)
}
var env output.Envelope
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("output should be valid envelope JSON: %v (%s)", err, string(out.Bytes()))
}
data, _ := env.Data.(map[string]interface{})
tasks, ok := data["tasks"].([]interface{})
if !ok || len(tasks) != 3 {
t.Fatalf("data.tasks should have 3 entries, got %v", data["tasks"])
}
want := []string{"new", "mid", "old"}
for i, w := range want {
m, _ := tasks[i].(map[string]interface{})
if m["task_id"] != w {
t.Errorf("tasks[%d].task_id should be %q (newest-first), got %v", i, w, m["task_id"])
}
}
first, _ := tasks[0].(map[string]interface{})
if first["updated_at"] != "2026-07-05T12:00:00Z" {
t.Errorf("tasks[0].updated_at should be carried, got %v", first["updated_at"])
}
if first["summary"] != "请补充" {
t.Errorf("tasks[0].summary should be carried, got %v", first["summary"])
}
}
// TestNewCmdAgentTaskCancel_WriteRisk pins ExactArgs(2) and write risk on cancel.
func TestNewCmdAgentTaskCancel_WriteRisk(t *testing.T) {
cmd := NewCmdAgentTaskCancel(nil)
if level, ok := cmdutil.GetRisk(cmd); !ok || level != cmdutil.RiskWrite {
t.Errorf("task cancel should be marked write risk, got level=%q ok=%v", level, ok)
}
if err := cmd.Args(cmd, []string{"example:x"}); err == nil {
t.Error("task cancel missing task-id should raise an args error (ExactArgs 2)")
}
}
// TestNewCmdAgentTaskList_ReadRisk pins ExactArgs(1), read risk and --context-id.
func TestNewCmdAgentTaskList_ReadRisk(t *testing.T) {
cmd := NewCmdAgentTaskList(nil)
if level, ok := cmdutil.GetRisk(cmd); !ok || level != cmdutil.RiskRead {
t.Errorf("task list should be marked read risk, got level=%q ok=%v", level, ok)
}
if err := cmd.Args(cmd, []string{}); err == nil {
t.Error("task list missing ref should raise an args error (ExactArgs 1)")
}
if cmd.Flags().Lookup("context-id") == nil {
t.Error("task list should have --context-id flag")
}
}
// chdirTemp switches the working directory to a fresh temp dir for the test
// (restored on cleanup), so a relative --output stays within the CWD as
// SafeOutputPath requires. (Go 1.23 has no t.Chdir.)
func chdirTemp(t *testing.T) {
t.Helper()
prev, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(t.TempDir()); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(prev) })
}
// swapArtifactFetch replaces the package-level URL fetch seam with a stub for
// tests, returning a restore func.
func swapArtifactFetch(fn func(context.Context, *cmdutil.Factory, string) ([]byte, error)) func() {
orig := artifactFetch
artifactFetch = fn
return func() { artifactFetch = orig }
}
// swapResolveDownload replaces the package-level DownloadArtifact seam with a
// stub for tests, returning a restore func.
func swapResolveDownload(fn func(*taskOptions) (*iagent.ArtifactData, error)) func() {
orig := resolveDownload
resolveDownload = fn
return func() { resolveDownload = orig }
}
// TestNewCmdAgentTaskGet_ArtifactHelpMentionsOutput ensures the --artifact help
// tells the user -o is required (discoverability of the client-side guard).
func TestNewCmdAgentTaskGet_ArtifactHelpMentionsOutput(t *testing.T) {
cmd := NewCmdAgentTaskGet(nil)
f := cmd.Flags().Lookup("artifact")
if f == nil {
t.Fatal("should have --artifact flag")
}
if !strings.Contains(f.Usage, "-o") && !strings.Contains(f.Usage, "output") {
t.Errorf("--artifact help should note it must be used with -o, got %q", f.Usage)
}
}
// publicArtifactURL is a public IP-literal artifact URL. Using an IP literal
// (not a hostname) makes ValidateDownloadSourceURL skip DNS entirely: it parses
// the IP, sees it is public (203.0.113.7 is RFC 5737 TEST-NET-3, which
// isRestrictedDownloadIP does not block), and returns nil — so the SSRF guard
// passes deterministically with zero network. The httpmock transport then
// intercepts the request before any real dial to that (non-routable) address.
const publicArtifactURL = "https://203.0.113.7/artifacts/report.txt"
// swapHardenDownloadClient replaces the download-client-build seam so tests can
// feed the interceptable httpmock base client straight into fetchArtifactURL
// (the production hardened client clones a real *http.Transport and would drop
// the mock). Returns a restore func.
func swapHardenDownloadClient(fn func(*http.Client) *http.Client) func() {
orig := hardenDownloadClient
hardenDownloadClient = fn
return func() { hardenDownloadClient = orig }
}
// passthroughClient is the hardenDownloadClient stub: it returns the base client
// unchanged so the httpmock RoundTripper survives to intercept the request.
func passthroughClient(base *http.Client) *http.Client { return base }
// TestFetchArtifactURL_SSRFBlocked pins the SSRF guard: a loopback URL is
// rejected by ValidateDownloadSourceURL before any client is built or request
// is made, surfaced as an invalid_argument validation error.
func TestFetchArtifactURL_SSRFBlocked(t *testing.T) {
cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu}
f, _, _, _ := cmdutil.TestFactory(t, cfg)
_, err := fetchArtifactURL(context.Background(), f, "http://127.0.0.1/secret")
if err == nil {
t.Fatal("loopback URL should be blocked by SSRF")
}
if !errs.IsValidation(err) {
t.Fatalf("SSRF block should be a validation error, got %T: %v", err, err)
}
p, ok := errs.ProblemOf(err)
if !ok || p.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype should be invalid_argument, got %+v", p)
}
}
// TestFetchArtifactURL_MalformedURLBlocked pins that a malformed URL string is
// caught by the SSRF/URL guard (ValidateDownloadSourceURL parses first), not by
// the downstream request builder: it is an invalid_argument validation error.
func TestFetchArtifactURL_MalformedURLBlocked(t *testing.T) {
cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu}
f, _, _, _ := cmdutil.TestFactory(t, cfg)
_, err := fetchArtifactURL(context.Background(), f, "ftp://example.com/x")
if err == nil {
t.Fatal("a non-http/https URL should be rejected")
}
if !errs.IsValidation(err) {
t.Fatalf("a malformed URL should be a validation error, got %T: %v", err, err)
}
}
// TestFetchArtifactURL_HttpRejected pins the https-only enforcement, distinct
// from the SSRF guard: a PUBLIC http:// URL passes the SSRF check (routable,
// http-family) but is still rejected because artifact bytes must travel over
// https (no plaintext download). No request is made.
func TestFetchArtifactURL_HttpRejected(t *testing.T) {
cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu}
f, _, _, _ := cmdutil.TestFactory(t, cfg)
_, err := fetchArtifactURL(context.Background(), f, "http://203.0.113.7/artifacts/report.txt")
if err == nil {
t.Fatal("a public plain-http artifact URL should be rejected by the https-only check")
}
if !errs.IsValidation(err) {
t.Fatalf("https-only rejection should be a validation error, got %T: %v", err, err)
}
if !strings.Contains(err.Error(), "https") {
t.Errorf("error should state the https requirement, got %v", err)
}
}
// TestFetchArtifactURL_HttpClientError pins the HttpClient() failure branch: a
// public (SSRF-passing) URL whose Factory cannot mint an http client surfaces as
// an internal sdk_error. No request is made.
func TestFetchArtifactURL_HttpClientError(t *testing.T) {
cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu}
f, _, _, _ := cmdutil.TestFactory(t, cfg)
f.HttpClient = func() (*http.Client, error) { return nil, errors.New("boom no client") }
_, err := fetchArtifactURL(context.Background(), f, publicArtifactURL)
if err == nil {
t.Fatal("HttpClient failure should propagate")
}
if !errs.IsInternal(err) {
t.Fatalf("HttpClient failure should be an internal error, got %T: %v", err, err)
}
p, ok := errs.ProblemOf(err)
if !ok || p.Subtype != errs.SubtypeSDKError {
t.Fatalf("subtype should be sdk_error, got %+v", p)
}
}
// TestFetchArtifactURL_NetworkError pins the client.Do failure branch: the
// injected transport returns a transport-level error, surfaced as a network
// transport error.
func TestFetchArtifactURL_NetworkError(t *testing.T) {
restore := swapHardenDownloadClient(passthroughClient)
defer restore()
cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu}
f, _, _, _ := cmdutil.TestFactory(t, cfg)
// Point the Factory's base client at a transport that always errors.
f.HttpClient = func() (*http.Client, error) {
return &http.Client{Transport: errRoundTripper{err: errors.New("dial refused")}}, nil
}
_, err := fetchArtifactURL(context.Background(), f, publicArtifactURL)
if err == nil {
t.Fatal("transport error should propagate")
}
if !errs.IsNetwork(err) {
t.Fatalf("transport error should be a network error, got %T: %v", err, err)
}
p, ok := errs.ProblemOf(err)
if !ok || p.Subtype != errs.SubtypeNetworkTransport {
t.Fatalf("subtype should be transport, got %+v", p)
}
}
// TestFetchArtifactURL_HTTPStatusError pins the non-200 branch: an HTTP 503 is
// surfaced as a network server error carrying the status code.
func TestFetchArtifactURL_HTTPStatusError(t *testing.T) {
restore := swapHardenDownloadClient(passthroughClient)
defer restore()
cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu}
f, _, _, reg := cmdutil.TestFactory(t, cfg)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "203.0.113.7/artifacts/report.txt",
Status: 503,
RawBody: []byte("Service Unavailable"),
})
_, err := fetchArtifactURL(context.Background(), f, publicArtifactURL)
if err == nil {
t.Fatal("HTTP 503 should propagate")
}
if !errs.IsNetwork(err) {
t.Fatalf("an HTTP status error should be a network error, got %T: %v", err, err)
}
p, ok := errs.ProblemOf(err)
if !ok || p.Subtype != errs.SubtypeNetworkServer {
t.Fatalf("subtype should be server_error, got %+v", p)
}
if !strings.Contains(err.Error(), "503") {
t.Errorf("error message should contain status code 503, got %v", err)
}
}
// TestFetchArtifactURL_Success pins the happy path: a 200 response body is read
// through the LimitReader and returned verbatim.
func TestFetchArtifactURL_Success(t *testing.T) {
restore := swapHardenDownloadClient(passthroughClient)
defer restore()
cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu}
f, _, _, reg := cmdutil.TestFactory(t, cfg)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "203.0.113.7/artifacts/report.txt",
Status: 200,
RawBody: []byte("REPORT-BYTES"),
})
got, err := fetchArtifactURL(context.Background(), f, publicArtifactURL)
if err != nil {
t.Fatalf("download should not error: %v", err)
}
if string(got) != "REPORT-BYTES" {
t.Errorf("artifact bytes should be returned verbatim, got %q", string(got))
}
}
// TestFetchArtifactURL_LimitEnforced pins the size-cap guard: a body larger than
// maxArtifactBytes is REJECTED with a typed error rather than silently truncated
// onto disk (the fetch reads max+1 to detect the overflow), so a hostile host can
// neither stream an unbounded body nor slip a corrupt partial file past as
// success. Uses a streaming RoundTripper that emits one byte past the cap.
func TestFetchArtifactURL_LimitEnforced(t *testing.T) {
restore := swapHardenDownloadClient(passthroughClient)
defer restore()
cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu}
f, _, _, _ := cmdutil.TestFactory(t, cfg)
// A body one byte longer than the cap must be refused, not truncated.
oversized := int64(maxArtifactBytes) + 1
f.HttpClient = func() (*http.Client, error) {
return &http.Client{Transport: streamRoundTripper{n: oversized, b: 'A'}}, nil
}
_, err := fetchArtifactURL(context.Background(), f, publicArtifactURL)
if err == nil {
t.Fatal("an oversized artifact should be rejected with an error, not truncated to a partial file")
}
if !errs.IsValidation(err) {
t.Fatalf("oversized artifact should be a validation error, got %T: %v", err, err)
}
}
// TestFetchArtifactURL_ReadError pins the response-body read-error branch: a 200
// response whose Body.Read fails mid-stream surfaces as a network transport
// error (not a partial success).
func TestFetchArtifactURL_ReadError(t *testing.T) {
restore := swapHardenDownloadClient(passthroughClient)
defer restore()
cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu}
f, _, _, _ := cmdutil.TestFactory(t, cfg)
f.HttpClient = func() (*http.Client, error) {
return &http.Client{Transport: readErrRoundTripper{err: errors.New("body read failed")}}, nil
}
_, err := fetchArtifactURL(context.Background(), f, publicArtifactURL)
if err == nil {
t.Fatal("response body read failure should propagate")
}
if !errs.IsNetwork(err) {
t.Fatalf("a read failure should be a network error, got %T: %v", err, err)
}
p, ok := errs.ProblemOf(err)
if !ok || p.Subtype != errs.SubtypeNetworkTransport {
t.Fatalf("subtype should be transport, got %+v", p)
}
}
// TestDownloadArtifact_SafeOutputPathError pins that an unsafe -o (absolute path)
// is rejected by SafeOutputPath before any provider resolution or write, as an
// invalid_argument validation error carrying the output param.
func TestDownloadArtifact_SafeOutputPathError(t *testing.T) {
fetched := false
restore := swapResolveDownload(func(*taskOptions) (*iagent.ArtifactData, error) {
fetched = true
return &iagent.ArtifactData{Bytes: []byte("x")}, nil
})
defer restore()
opts, _ := taskTestOpts(t, "get")
opts.ArtifactID = "art_1"
opts.Output = "/etc/passwd" // absolute → SafeOutputPath rejects
err := downloadArtifact(opts)
if err == nil {
t.Fatal("an absolute -o should be rejected by SafeOutputPath")
}
if !errs.IsValidation(err) {
t.Fatalf("want validation error, got %T: %v", err, err)
}
if fetched {
t.Error("path validation failure should not trigger artifact resolution/download")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) || ve.Param != "--output" {
t.Fatalf("validation error should carry Param=--output, got %T %+v", err, err)
}
}
// TestDownloadArtifact_ResolveDownloadError pins that a DownloadArtifact
// (provider) failure is propagated unchanged (the descriptor fetch is the seam).
func TestDownloadArtifact_ResolveDownloadError(t *testing.T) {
restore := swapResolveDownload(func(*taskOptions) (*iagent.ArtifactData, error) {
return nil, errs.NewNetworkError(errs.SubtypeNetworkServer, "artifact fetch failed")
})
defer restore()
chdirTemp(t)
opts, _ := taskTestOpts(t, "get")
opts.ArtifactID = "art_1"
opts.Output = "out.bin"
err := downloadArtifact(opts)
if err == nil {
t.Fatal("DownloadArtifact error should propagate")
}
if !errs.IsNetwork(err) {
t.Fatalf("should propagate the provider's network error, got %T: %v", err, err)
}
}
// TestDownloadArtifact_URLFetchError pins that a failure from the URL-fetch seam
// (a URL-type artifact whose download fails) is propagated unchanged.
func TestDownloadArtifact_URLFetchError(t *testing.T) {
restore := swapResolveDownload(func(*taskOptions) (*iagent.ArtifactData, error) {
return &iagent.ArtifactData{Mime: "text/plain", URL: publicArtifactURL}, nil
})
defer restore()
restoreFetch := swapArtifactFetch(func(context.Context, *cmdutil.Factory, string) ([]byte, error) {
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "download blew up")
})
defer restoreFetch()
chdirTemp(t)
opts, _ := taskTestOpts(t, "get")
opts.ArtifactID = "art_1"
opts.Output = "out.bin"
err := downloadArtifact(opts)
if err == nil {
t.Fatal("URL download failure should propagate")
}
if !errs.IsNetwork(err) {
t.Fatalf("should propagate the fetch network error, got %T: %v", err, err)
}
if _, statErr := os.Stat(filepath.Join(".", "out.bin")); !os.IsNotExist(statErr) {
t.Errorf("should not write a file on download failure, stat err=%v", statErr)
}
}
// TestDownloadArtifact_WriteFileError pins the vfs.WriteFile failure branch: the
// path passes SafeOutputPath but its parent directory does not exist, so the
// write fails and is surfaced as an internal file_io error.
func TestDownloadArtifact_WriteFileError(t *testing.T) {
restore := swapResolveDownload(func(*taskOptions) (*iagent.ArtifactData, error) {
return &iagent.ArtifactData{Mime: "application/octet-stream", Bytes: []byte("INLINE")}, nil
})
defer restore()
chdirTemp(t)
opts, _ := taskTestOpts(t, "get")
opts.ArtifactID = "art_1"
// Nonexistent parent dir → passes SafeOutputPath (resolveNearestAncestor),
// but vfs.WriteFile has no parent to write into and fails.
opts.Output = filepath.Join("no_such_dir", "out.bin")
err := downloadArtifact(opts)
if err == nil {
t.Fatal("a write failure should propagate")
}
if !errs.IsInternal(err) {
t.Fatalf("a write failure should be an internal error, got %T: %v", err, err)
}
p, ok := errs.ProblemOf(err)
if !ok || p.Subtype != errs.SubtypeFileIO {
t.Fatalf("subtype should be file_io, got %+v", p)
}
}
// errRoundTripper is a transport that always fails, used to exercise the
// client.Do error branch of fetchArtifactURL.
type errRoundTripper struct{ err error }
// RoundTrip always returns the configured error.
func (e errRoundTripper) RoundTrip(*http.Request) (*http.Response, error) { return nil, e.err }
// readErrRoundTripper returns a 200 response whose body fails on Read, used to
// exercise the io.ReadAll error branch of fetchArtifactURL.
type readErrRoundTripper struct{ err error }
// RoundTrip returns a 200 response backed by a reader that always errors.
func (r readErrRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(errReader{err: r.err}),
Request: req,
}, nil
}
// errReader always fails on Read with its configured error.
type errReader struct{ err error }
// Read always returns the configured error.
func (e errReader) Read([]byte) (int, error) { return 0, e.err }
// streamRoundTripper returns a 200 response whose body streams n copies of byte
// b, used to exercise the io.LimitReader truncation in fetchArtifactURL without
// materializing the whole body in memory up front.
type streamRoundTripper struct {
n int64
b byte
}
// RoundTrip returns a 200 response backed by an n-byte constant reader.
func (s streamRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(&constReader{remaining: s.n, b: s.b}),
Request: req,
}, nil
}
// constReader yields `remaining` bytes of a constant value, then io.EOF. It lets
// the LimitReader test drive an arbitrarily large body cheaply.
type constReader struct {
remaining int64
b byte
}
// Read fills p with the constant byte until `remaining` is exhausted.
func (c *constReader) Read(p []byte) (int, error) {
if c.remaining <= 0 {
return 0, io.EOF
}
n := len(p)
if int64(n) > c.remaining {
n = int(c.remaining)
}
for i := 0; i < n; i++ {
p[i] = c.b
}
c.remaining -= int64(n)
if c.remaining <= 0 {
return n, io.EOF
}
return n, nil
}
// TestDownloadArtifact_RefusesOverwriteWithoutForce pins the -o overwrite guard:
// an existing target is a high-risk clobber, so without --force the download is
// refused with confirmation_required (exit 10) BEFORE any fetch (the
// resolveDownload seam must not be reached), and the file is left untouched.
func TestDownloadArtifact_RefusesOverwriteWithoutForce(t *testing.T) {
fetched := false
restore := swapResolveDownload(func(*taskOptions) (*iagent.ArtifactData, error) {
fetched = true
return &iagent.ArtifactData{Bytes: []byte("NEW")}, nil
})
defer restore()
chdirTemp(t)
if err := os.WriteFile("out.bin", []byte("OLD"), 0o600); err != nil {
t.Fatal(err)
}
opts, _ := taskTestOpts(t, "get")
opts.ArtifactID = "a1"
opts.Output = "out.bin" // already exists, no --force
err := downloadArtifact(opts)
p, ok := errs.ProblemOf(err)
if !ok || p.Subtype != errs.SubtypeConfirmationRequired {
t.Fatalf("overwrite without --force should be confirmation_required, got %+v (err=%v)", p, err)
}
if output.ExitCodeOf(err) != output.ExitConfirmationRequired {
t.Fatalf("exit should be %d, got %d", output.ExitConfirmationRequired, output.ExitCodeOf(err))
}
if fetched {
t.Error("must not fetch/download when refusing to overwrite")
}
if b, _ := os.ReadFile("out.bin"); string(b) != "OLD" {
t.Errorf("existing file must be left untouched, got %q", b)
}
}
// TestDownloadArtifact_ForceOverwrites pins that --force satisfies the overwrite
// guard: an existing target is replaced with the downloaded bytes.
func TestDownloadArtifact_ForceOverwrites(t *testing.T) {
restore := swapResolveDownload(func(*taskOptions) (*iagent.ArtifactData, error) {
return &iagent.ArtifactData{Bytes: []byte("NEW")}, nil
})
defer restore()
chdirTemp(t)
if err := os.WriteFile("out.bin", []byte("OLD"), 0o600); err != nil {
t.Fatal(err)
}
opts, _ := taskTestOpts(t, "get")
opts.ArtifactID = "a1"
opts.Output = "out.bin"
opts.Force = true
if err := downloadArtifact(opts); err != nil {
t.Fatalf("--force should overwrite an existing target: %v", err)
}
if b, _ := os.ReadFile("out.bin"); string(b) != "NEW" {
t.Errorf("--force should have overwritten with downloaded bytes, got %q", b)
}
}