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.
This commit is contained in:
liuxinyang.lxy
2026-07-09 18:18:19 +08:00
parent 75bdefa513
commit cb8e2c58df
12 changed files with 87 additions and 53 deletions

View File

@@ -59,10 +59,10 @@ func TestCapabilityMatrixDiverges(t *testing.T) {
if ec.ArtifactDownload || ec.FileInput || ec.TaskCancel {
t.Errorf("echo should be the minimal set (no artifact/file/cancel), got %+v", ec)
}
if !ec.MultiTurn || !ec.TaskGet || !ec.TaskList {
t.Errorf("echo should support multi_turn/task_get/task_list, got %+v", ec)
if !ec.ContextList || !ec.ContextGet || !ec.ContextDelete || !ec.TaskGet || !ec.TaskList {
t.Errorf("echo should support context_list/get/delete + task_get/task_list, got %+v", ec)
}
if !(rc.ArtifactDownload && rc.FileInput && rc.TaskCancel && rc.InputRequired && rc.MultiTurn && rc.TaskGet && rc.TaskList) {
if !(rc.ArtifactDownload && rc.FileInput && rc.TaskCancel && rc.InputRequired && rc.ContextList && rc.ContextGet && rc.ContextDelete && rc.TaskGet && rc.TaskList) {
t.Errorf("reporter should have everything enabled, got %+v", rc)
}
}

View File

@@ -139,9 +139,11 @@ func printCardPretty(w io.Writer, card *iagent.AgentCard) {
// matching the sorted output of the earlier map-based representation.
for _, k := range []string{
iagent.CapArtifactDownload,
iagent.CapContextDelete,
iagent.CapContextGet,
iagent.CapContextList,
iagent.CapFileInput,
iagent.CapInputRequired,
iagent.CapMultiTurn,
iagent.CapTaskCancel,
iagent.CapTaskGet,
iagent.CapTaskList,

View File

@@ -29,8 +29,8 @@ func cardTestOpts(t *testing.T, ref string) (*cardOptions, *core.CliConfig) {
// TestAgentCardRun_ExampleStaticCard verifies that `agent card example:echo`
// returns the statically synthesized capability card (no API), with
// task_cancel gated off and multi_turn on, and the agent_id echoed from the
// ref.
// task_cancel gated off and the three context_* caps on, and the agent_id
// echoed from the ref.
func TestAgentCardRun_ExampleStaticCard(t *testing.T) {
opts, _ := cardTestOpts(t, "example:echo")
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
@@ -67,8 +67,8 @@ func TestAgentCardRun_ExampleStaticCard(t *testing.T) {
if caps["task_cancel"] != false {
t.Errorf("echo task_cancel should be false, got %v", caps["task_cancel"])
}
if caps["multi_turn"] != true {
t.Errorf("echo multi_turn should be true, got %v", caps["multi_turn"])
if caps["context_list"] != true || caps["context_get"] != true || caps["context_delete"] != true {
t.Errorf("echo should support the three context capabilities, got %v", caps)
}
// parameters / identity must serialize as non-null (guard against omitempty
// regression): parameters is always an array (empty [] for example),
@@ -110,8 +110,8 @@ func TestAgentCardRun_PrettyFormat(t *testing.T) {
if !strings.Contains(text, "echo") {
t.Errorf("pretty output should contain agent_id: %s", text)
}
// multi_turn is a declared capability of the echo card; it must appear.
if !strings.Contains(text, "multi_turn") {
// context_list is a declared capability of the echo card; it must appear.
if !strings.Contains(text, "context_list") {
t.Errorf("pretty output should list capabilities: %s", text)
}
}
@@ -183,8 +183,8 @@ func TestPrintCardPretty_AllOptionalFields(t *testing.T) {
{Type: "bot", Precondition: "需加入渠道白名单"},
},
Capabilities: iagent.Capabilities{
MultiTurn: true,
TaskCancel: false,
ContextList: true,
TaskCancel: false,
},
Parameters: []iagent.CardParam{
{Name: "locale", Type: "string", Required: true, Desc: "reply locale"},

View File

@@ -29,7 +29,8 @@ type contextOptions struct {
}
// NewCmdAgentContext builds the `agent context` command group: manage a remote
// agent's multi-turn contexts (requires card multi_turn=true). It is a pure group with
// agent's multi-turn contexts (each verb gated on its own capability:
// context_list / context_get / context_delete). It is a pure group with
// no RunE so an unknown subcommand is reported rather than silently swallowed.
func NewCmdAgentContext(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
@@ -133,10 +134,10 @@ func agentContextListRun(opts *contextOptions) error {
if err != nil {
return err
}
// Capability gate BEFORE the client: multi_turn is derived from ListContexts
// Capability gate BEFORE the client: context_list is derived from ListContexts
// being wired, so a spec without it returns unsupported_capability offline.
if spec.ListContexts == nil {
return capabilityError(opts.Ref, "context list", iagent.CapMultiTurn)
return capabilityError(opts.Ref, "context list", iagent.CapContextList)
}
rt, err := runtimeFor(f, id, agentID)
if err != nil {
@@ -175,7 +176,7 @@ func agentContextGetRun(opts *contextOptions) error {
}
// Capability gate BEFORE the client.
if spec.GetContext == nil {
return capabilityError(opts.Ref, "context get", iagent.CapMultiTurn)
return capabilityError(opts.Ref, "context get", iagent.CapContextGet)
}
rt, err := runtimeFor(f, id, agentID)
if err != nil {
@@ -214,7 +215,7 @@ func agentContextDeleteRun(opts *contextOptions) error {
}
// Capability gate BEFORE the client.
if spec.DeleteContext == nil {
return capabilityError(opts.Ref, "context delete", iagent.CapMultiTurn)
return capabilityError(opts.Ref, "context delete", iagent.CapContextDelete)
}
rt, err := runtimeFor(f, id, agentID)
if err != nil {

View File

@@ -471,9 +471,9 @@ func downloadArtifact(opts *taskOptions) error {
}
// fetchArtifactURL is the production URL fetch: it SSRF-validates rawURL, builds
// a download-hardened HTTP client from the Factory and reads at most
// maxArtifactBytes of the body. The artifact host is untrusted external content,
// so both the URL and the redirect chain are guarded.
// a download-hardened HTTP client from the Factory and reads the body up to
// maxArtifactBytes, refusing anything larger. The artifact host is untrusted
// external content, so both the URL and the redirect chain are guarded.
func fetchArtifactURL(ctx context.Context, f *cmdutil.Factory, rawURL string) ([]byte, error) {
if err := validate.ValidateDownloadSourceURL(ctx, rawURL); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "被拦截的产物 URL: %v", err).
@@ -504,9 +504,18 @@ func fetchArtifactURL(ctx context.Context, f *cmdutil.Factory, rawURL string) ([
if resp.StatusCode != http.StatusOK {
return nil, errs.NewNetworkError(errs.SubtypeNetworkServer, "下载产物失败: HTTP %d", resp.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, maxArtifactBytes))
// Read ONE byte past the cap so an oversized body is detected rather than
// silently truncated: io.LimitReader returns EOF (not an error) at the cap, so
// reading exactly maxArtifactBytes cannot distinguish "fits" from "overflowed".
// A body over the cap is refused with a typed error instead of writing a
// corrupt, partial file that would otherwise report success.
data, err := io.ReadAll(io.LimitReader(resp.Body, maxArtifactBytes+1))
if err != nil {
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "读取产物响应失败: %v", err).WithCause(err)
}
if int64(len(data)) > maxArtifactBytes {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"产物超过大小上限 %d 字节,拒绝下载(避免写入被截断的残缺文件)", int64(maxArtifactBytes))
}
return data, nil
}

View File

@@ -847,28 +847,29 @@ func TestFetchArtifactURL_Success(t *testing.T) {
}
}
// TestFetchArtifactURL_LimitEnforced pins the io.LimitReader(maxArtifactBytes)
// guard: an oversized body is truncated at exactly maxArtifactBytes so a
// hostile host cannot stream an unbounded body onto disk. Uses a streaming
// RoundTripper that would otherwise emit far more than the cap.
// 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; LimitReader must stop at the cap.
// 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
}
got, err := fetchArtifactURL(context.Background(), f, publicArtifactURL)
if err != nil {
t.Fatalf("an oversized download should not error (should truncate, not fail): %v", err)
_, 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 int64(len(got)) != int64(maxArtifactBytes) {
t.Fatalf("downloaded bytes should be truncated to %d, got %d", maxArtifactBytes, len(got))
if !errs.IsValidation(err) {
t.Fatalf("oversized artifact should be a validation error, got %T: %v", err, err)
}
}

View File

@@ -16,18 +16,25 @@ const (
CapInputRequired = "input_required"
CapFileInput = "file_input"
CapArtifactDownload = "artifact_download"
CapMultiTurn = "multi_turn"
// The three multi-turn (context) verbs are independently wired, so each has
// its own capability bit — a provider may support listing sessions without
// supporting get or delete. (There is no umbrella "multi_turn" bit: a single
// flag cannot honestly represent three separately-deliverable hooks.)
CapContextList = "context_list"
CapContextGet = "context_get"
CapContextDelete = "context_delete"
)
// Capabilities is the closed set of capabilities: making it a struct means an
// omitted field is an explicit false and a typo is a compile error. Fields are
// ordered by json tag alphabetically to keep the key order identical to the old
// map serialization.
// ordered by json tag alphabetically so the emitted key order is stable.
type Capabilities struct {
ArtifactDownload bool `json:"artifact_download"`
ContextDelete bool `json:"context_delete"`
ContextGet bool `json:"context_get"`
ContextList bool `json:"context_list"`
FileInput bool `json:"file_input"`
InputRequired bool `json:"input_required"`
MultiTurn bool `json:"multi_turn"`
TaskCancel bool `json:"task_cancel"`
TaskGet bool `json:"task_get"`
TaskList bool `json:"task_list"`
@@ -62,7 +69,9 @@ func DeriveCapabilities(s *AgentSpec) Capabilities {
TaskList: s.ListTasks != nil,
TaskCancel: s.CancelTask != nil,
ArtifactDownload: s.DownloadArtifact != nil,
MultiTurn: s.ListContexts != nil,
ContextList: s.ListContexts != nil,
ContextGet: s.GetContext != nil,
ContextDelete: s.DeleteContext != nil,
FileInput: s.FileInput,
InputRequired: s.InputRequired,
}
@@ -145,8 +154,12 @@ func (c *AgentCard) Supports(capKey string) bool {
return c.Capabilities.FileInput
case CapInputRequired:
return c.Capabilities.InputRequired
case CapMultiTurn:
return c.Capabilities.MultiTurn
case CapContextList:
return c.Capabilities.ContextList
case CapContextGet:
return c.Capabilities.ContextGet
case CapContextDelete:
return c.Capabilities.ContextDelete
case CapTaskCancel:
return c.Capabilities.TaskCancel
case CapTaskGet:

View File

@@ -24,32 +24,34 @@ func (fakeRT) CallMultipart(context.Context, string, string, map[string]string,
}
func TestCardSupports(t *testing.T) {
c := &AgentCard{Capabilities: Capabilities{TaskCancel: false, MultiTurn: true}}
c := &AgentCard{Capabilities: Capabilities{TaskCancel: false, ContextList: true}}
if c.Supports(CapTaskCancel) {
t.Error("task_cancel should not be supported")
}
if !c.Supports(CapMultiTurn) {
t.Error("multi_turn should be supported")
if !c.Supports(CapContextList) {
t.Error("context_list should be supported")
}
if c.Supports("nonexistent") {
t.Error("unknown capability should be treated as unsupported")
}
// nil guard branch: a nil receiver is treated as unsupported; a zero-value Capabilities is all false.
var nilCard *AgentCard
if nilCard.Supports(CapMultiTurn) {
if nilCard.Supports(CapContextList) {
t.Error("nil card should be treated as unsupported")
}
if (&AgentCard{}).Supports(CapMultiTurn) {
if (&AgentCard{}).Supports(CapContextList) {
t.Error("zero-value Capabilities should be treated as unsupported")
}
// Each capability constant must map to its own struct field (the switch has no gaps or mismatches).
all := &AgentCard{Capabilities: Capabilities{
ArtifactDownload: true, FileInput: true, InputRequired: true,
MultiTurn: true, TaskCancel: true, TaskGet: true, TaskList: true,
ContextList: true, ContextGet: true, ContextDelete: true,
TaskCancel: true, TaskGet: true, TaskList: true,
}}
for _, k := range []string{
CapArtifactDownload, CapFileInput, CapInputRequired,
CapMultiTurn, CapTaskCancel, CapTaskGet, CapTaskList,
CapContextList, CapContextGet, CapContextDelete,
CapTaskCancel, CapTaskGet, CapTaskList,
} {
if !all.Supports(k) {
t.Errorf("Supports(%q) should be true when all Capabilities are true", k)
@@ -66,10 +68,12 @@ func TestDeriveCapabilities(t *testing.T) {
if !c.TaskGet {
t.Error("task_get should be true (GetTask is a mandatory core hook)")
}
if !c.MultiTurn {
t.Error("multi_turn should be true (ListContexts wired)")
if !c.ContextList {
t.Error("context_list should be true (ListContexts wired)")
}
if c.TaskCancel || c.ArtifactDownload || c.TaskList || c.FileInput || c.InputRequired {
// The three context caps are independent: only ListContexts is wired here, so
// context_get / context_delete stay false (no umbrella multi_turn bit).
if c.TaskCancel || c.ArtifactDownload || c.TaskList || c.FileInput || c.InputRequired || c.ContextGet || c.ContextDelete {
t.Errorf("unwired capabilities should be false, got %+v", c)
}
@@ -78,11 +82,13 @@ func TestDeriveCapabilities(t *testing.T) {
full.ListTasks = func(context.Context, Runtime, string) ([]TaskSummary, error) { return nil, nil }
full.CancelTask = func(context.Context, Runtime, string) error { return nil }
full.ListContexts = func(context.Context, Runtime) ([]ContextSummary, error) { return nil, nil }
full.GetContext = func(context.Context, Runtime, string) (*ContextDetail, error) { return nil, nil }
full.DeleteContext = func(context.Context, Runtime, string) error { return nil }
full.DownloadArtifact = func(context.Context, Runtime, string, string) (*ArtifactData, error) { return nil, nil }
full.FileInput = true
full.InputRequired = true
c = DeriveCapabilities(&full)
if !(c.TaskGet && c.TaskList && c.TaskCancel && c.MultiTurn && c.ArtifactDownload && c.FileInput && c.InputRequired) {
if !(c.TaskGet && c.TaskList && c.TaskCancel && c.ContextList && c.ContextGet && c.ContextDelete && c.ArtifactDownload && c.FileInput && c.InputRequired) {
t.Errorf("a fully-wired spec should have every capability true, got %+v", c)
}
}

View File

@@ -81,7 +81,7 @@ metadata:
- `submitted` / `working` → 还在跑,稍后再 `task get`(或 `--watch`
- **停轮询条件** = `is_terminal`(∈{completed,failed,canceled,rejected})为真 **或** state ∈ {`input_required`,`auth_required`}(后两者不是错误,是"该你续发了")。
- **artifact**:任务产出物(图/文件),列在 `data.artifacts[]`(每项含 `id` + 粗粒度 `kind` 提示);用 `task get --artifact <id> -o <file>` 落盘。选 `-o` 后缀看 `kind`(下载前)与下载输出的 `suggested_name`(下载后,带扩展名);两者仅参考,落盘以 `-o` 为准。
- **能力门控**card `capabilities`7 键(`task_get/task_list/task_cancel/input_required/file_input/artifact_download/multi_turn`),为 false 的动词报 `unsupported_capability`不静默降级。context 动词无独立键,由 `multi_turn` 伞形覆盖:`multi_turn=false` 时别调 `context list/get/delete`。card 无键的低频能力由运行时兜底——调用报 `unsupported_capability` 与 card 为 false 同样权威,别重试。能力以 `agent card` 实际输出为准provider 特例见对应 provider 文件。
- **能力门控**card `capabilities`9 键(`task_get/task_list/task_cancel/input_required/file_input/artifact_download/context_list/context_get/context_delete`),为 false 的动词报 `unsupported_capability`不静默降级。context 三个动词各有独立能力位(`context_list/context_get/context_delete`)——一个 provider 可能能列会话却不能删会话,按需分别判断,别用单一位一概而论。card 无键的低频能力由运行时兜底——调用报 `unsupported_capability` 与 card 为 false 同样权威,别重试。能力以 `agent card` 实际输出为准provider 特例见对应 provider 文件。
## 异步与轮询(子进程契约)

View File

@@ -43,9 +43,11 @@ lark-cli agent card <provider>:<agent_id> --jq '.data.capabilities'
"description": "把你发的话原样复读一遍(同一会话续发时带轮次,证明上下文记忆)。最小能力集示范。",
"capabilities": {
"artifact_download": false,
"context_delete": true,
"context_get": true,
"context_list": true,
"file_input": false,
"input_required": false,
"multi_turn": true,
"task_cancel": false,
"task_get": true,
"task_list": true

View File

@@ -2,7 +2,7 @@
> **前置条件:** 先读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)(含高危 exit-10 确认机制)。
管理远程 agent 的**多轮上下文(会话)**。一个 context`context_id`)串起同一会话里的多个任务;需 card `multi_turn=true`。续发/追问在 [`agent send --context-id`](lark-agent-send.md),不在此。三个动词都要求该 provider 的全部 scopeall-or-nothing缺任一即本地报 `missing_scope`,照抄 hint 授权scope 全集见 provider 文件)。
管理远程 agent 的**多轮上下文(会话)**。一个 context`context_id`)串起同一会话里的多个任务;三个动词各由 `context_list` / `context_get` / `context_delete` 能力位分别门控provider 可能只支持其中一部分,以 `agent card` 为准)。续发/追问在 [`agent send --context-id`](lark-agent-send.md),不在此。三个动词都要求该 provider 的全部 scopeall-or-nothing缺任一即本地报 `missing_scope`,照抄 hint 授权scope 全集见 provider 文件)。
**分诊心法**`context list`(哪个会话要处理)→ `context get`(该会话总览 + `active_task`)→ [`agent task list --context-id`](lark-agent-task.md)(该会话全部任务)→ [`agent task get`](lark-agent-task.md)(单任务完整详情)。

View File

@@ -39,7 +39,7 @@ catalog 型必可枚举,`agent list example` 直接列全部 agent含 name/
| capability | `example:echo` | `example:reporter` | 差异含义 |
|---|---|---|---|
| `task_get` / `task_list` / `multi_turn` | true | true | 两者都支持查任务、列任务多轮会话 |
| `task_get` / `task_list` / `context_list` / `context_get` / `context_delete` | true | true | 两者都支持查/列任务多轮会话(列/查/删会话)|
| `task_cancel` | **false** | true | 对 echo 发 cancel 被命令层门控直接拒(见下方错误样例,不发任何请求);对 reporter 的 cancel 会真正派发(但 mock 任务即时终态,见下方 failed_precondition 样例) |
| `file_input` | **false** | true | echo 带 `--file``unsupported_capability`reporter 接收附件并在回复里确认 |
| `artifact_download` | **false** | true | 只有 reporter 产出 artifact内联 CSV`kind=text`,下载输出 `mime=text/csv``suggested_name=quarterly_report.csv` |