fix(cli): plug v0.3 final review findings (json + auth + path + bounds + kb)

Seven bugs surfaced via two audit rounds — parallel reviewer agents
plus a real-server end-to-end demo. Each fix arrives with a
regression test.

1. doc upload --recursive --json corrupted the envelope stream.
   Per-file FAIL/OK plain lines printed unconditionally to stdout,
   then a Success envelope, then on partial failure a typed error
   that the root handler turned into a SECOND Failure envelope —
   three outputs where one was expected. Fix: gate the plain lines
   behind !opts.JSONOut, and add cmdutil.Error.Silent so the JSON-
   path partial-failure preserves its typed exit code without
   triggering PrintErrorEnvelope's default Failure-envelope write.

2. auth refresh / AuthRetryTransport misclassified HTTP failures as
   network.error. RefreshAndPersist wrapped every refresher error
   with CodeNetworkError, but the SDK emits "HTTP error 401: ..."
   for a rejected refresh token — which should surface as
   auth.token_expired. Switched to WrapHTTP for proper status-
   derived classification. Affects both `auth refresh` and the
   transport's refresh closure.

3. doc download accepted ".." as a server-suggested filename. The
   rejection list covered "" / "." / filepath.Separator but not
   bare ".." — filepath.Base("..") is "..", which slipped through
   to os.Create and produced a confusing local.file_io wrap. Added
   to the rejection set.

4. search chunks / docs / kb / sessions had no lower bound on
   --limit. `-L 0` / `-L -1` was forwarded to the server with
   undefined behavior. Added a 1..1000 bound at the RunE boundary
   across all four (matching doc list / session list page-size
   bounds). Internal callers in tests can still pass Limit==0 for
   the "no client-side cap" runChunks path — the bound only applies
   at the user-input layer.

5. cli/AGENTS.md ADR-3 verb-canon summary listed only v0.2 verbs as
   "gh-canonical" and missed v0.3 additions (edit, pin, unpin,
   download — all gh-canonical) plus locally-introduced ones
   (empty, refresh, add, remove, link). Rewritten as an explicit
   gh-canonical / locally-introduced split.

6. kb pin returned 404. Server registers /knowledge-bases/{id}/pin
   as PUT (router.go:292); SDK was using POST. gin's router silently
   404s on method-mismatch (treats it as path-not-found, not 405),
   so the CLI classified the response as resource.not_found and
   masked the real failure mode. Switched the SDK to http.MethodPut.

   The asymmetry that hid this past round 1: kb unpin on a freshly-
   created KB hits the no-op branch in cmd/kb/pin.go that skips the
   SDK call entirely, so unpin "worked" without ever exercising the
   broken path. Only the real-server demo, where kb pin actually
   fires, surfaced it.

7. kb edit clobbered current Name when only --description was
   passed. EditOptions used *string to distinguish "unset" from
   "set to empty", but sdk.UpdateKnowledgeBaseRequest declares both
   fields as plain string (no omitempty), so the JSON body always
   carried `"name": ""`. Server requires Name → 400. Fix: runEdit
   does fetch-then-update — GetKnowledgeBase first, build the PUT
   body with current values, then overlay user-set fields. Same
   TOCTOU window as kb pin / unpin.

Audit-flagged items intentionally NOT changed:
- kb pin / unpin check-then-toggle TOCTOU: documented; the clean
  fix would be a server-side setter and belongs in a separate API
  change.
- AuthRetryTransport singleflight test gap for one concurrency
  scenario; v0.4 polish.
- cli/README.md:50 "once v0.2 ships" and CHANGELOG.md:8
  "10 top-level commands": v0.2-PR artifacts, not v0.3-introduced.
- kb edit / kb pin are v0.3-new commands, so neither bug needs a
  cli/CHANGELOG.md Fixed entry — the v0.3 release ships them
  working as the Added bullets advertise.
This commit is contained in:
nullkey
2026-05-13 15:39:09 +08:00
committed by lyingbug
parent 13cce78332
commit 4a5449233d
15 changed files with 147 additions and 30 deletions
+1 -1
View File
@@ -207,7 +207,7 @@ agent-aware error model. Documented deviations:
misconfigured embeddings / storage / credentials, so a structured
4-status diagnostic is the agent-readable surface for that.
Verb canon: `list / view / create / delete / upload / use` (all gh-canonical).
Verb canon (gh-canonical): `list / view / create / edit / delete / upload / download / pin / unpin / use`. Locally introduced for resource semantics gh lacks: `empty` (bulk-delete contents preserving the container), `refresh` (token), `add` / `remove` (context CRUD), `link` (project bind).
**ADR-4 — Factory closures + narrow Service interfaces.** `cmdutil.Factory`
exposes four lazy closures (Config / Client / Prompter / Secrets) that
+1 -1
View File
@@ -102,7 +102,7 @@ func resolveDownloadDest(opts *DownloadOptions, suggested string) (string, error
}
}
base := filepath.Base(suggested)
if base == "" || base == "." || base == string(filepath.Separator) {
if base == "" || base == "." || base == ".." || base == string(filepath.Separator) {
return "", &cmdutil.Error{
Code: cmdutil.CodeInputInvalidArgument,
Message: fmt.Sprintf("server returned an unusable filename %q", suggested),
+15
View File
@@ -124,6 +124,21 @@ func TestDownload_RejectsServerPathTraversal(t *testing.T) {
assert.Equal(t, "exfil", string(got))
}
// TestDownload_RejectsBareDotDot covers the literal-".." case: a server
// returning Content-Disposition: attachment; filename=".." would, before
// the rejection list was extended, pass `filepath.Base("..") == ".."`
// through to os.Create and produce a confusing local.file_io wrap.
func TestDownload_RejectsBareDotDot(t *testing.T) {
_, _ = iostreams.SetForTest(t)
for _, name := range []string{"..", "../"} {
_, err := resolveDownloadDest(&DownloadOptions{}, name)
require.Error(t, err, "filename=%q must be rejected", name)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
}
}
func TestDownload_ForceOverwrites(t *testing.T) {
_, _ = iostreams.SetForTest(t)
dest := filepath.Join(t.TempDir(), "exists.bin")
+14 -2
View File
@@ -91,7 +91,11 @@ func runUploadRecursive(ctx context.Context, opts *UploadOptions, svc UploadServ
firstFailCode = code
}
failed = append(failed, uploadOutcome{Path: p, Error: err.Error()})
fmt.Fprintf(iostreams.IO.Out, "FAIL %s: %v\n", filepath.Base(p), err)
// Per-file progress lines are human progress signal; suppress
// under --json so they don't precede the envelope on stdout.
if !opts.JSONOut {
fmt.Fprintf(iostreams.IO.Out, "FAIL %s: %v\n", filepath.Base(p), err)
}
continue
}
id := ""
@@ -99,7 +103,9 @@ func runUploadRecursive(ctx context.Context, opts *UploadOptions, svc UploadServ
id = k.ID
}
uploaded = append(uploaded, uploadOutcome{Path: p, ID: id})
fmt.Fprintf(iostreams.IO.Out, "OK %s (id: %s)\n", filepath.Base(p), id)
if !opts.JSONOut {
fmt.Fprintf(iostreams.IO.Out, "OK %s (id: %s)\n", filepath.Base(p), id)
}
}
if opts.JSONOut {
@@ -114,9 +120,15 @@ func runUploadRecursive(ctx context.Context, opts *UploadOptions, svc UploadServ
}
if len(failed) > 0 {
// Silent on the --json path: the success envelope above already
// carries per-file uploaded[]/failed[] detail. Without Silent the
// root error handler would write a second Failure envelope on
// stdout, corrupting the stream. ExitCode still walks Code so the
// typed exit-code-by-class contract is preserved.
return &cmdutil.Error{
Code: firstFailCode,
Message: fmt.Sprintf("%d of %d uploads failed", len(failed), len(matches)),
Silent: opts.JSONOut,
}
}
return nil
+12
View File
@@ -168,6 +168,18 @@ func TestUploadRecursive_JSON_Envelope(t *testing.T) {
assert.Contains(t, body, `"failed":`)
assert.Contains(t, body, `ok.pdf`)
assert.Contains(t, body, `bad.pdf`)
// --json must emit exactly ONE envelope. Per-file "FAIL"/"OK" progress
// lines belong on the human path; the typed error is Silent so the root
// handler doesn't write a second Failure envelope on top of ours.
assert.NotContains(t, body, "FAIL ", "per-file plain lines must not appear under --json")
assert.NotContains(t, body, "OK ", "per-file plain lines must not appear under --json")
assert.Equal(t, 1, strings.Count(body, `"ok":`), "exactly one envelope on stdout")
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.True(t, typed.Silent, "JSON-path partial failure must be Silent")
assert.Equal(t, cmdutil.CodeServerError, typed.Code)
}
func TestUploadRecursive_DryRun(t *testing.T) {
+31 -6
View File
@@ -25,7 +25,13 @@ type EditOptions struct {
DryRun bool
}
// EditService is the narrow SDK surface this command depends on. GetKnowledgeBase
// is needed for the fetch-then-update flow: the server's UpdateKnowledgeBase
// endpoint requires Name on the PUT body (UpdateKnowledgeBaseRequest.Name is
// `string`, not `*string`, and the server validates `required`), so passing
// only --description without fetching the current Name would 400.
type EditService interface {
GetKnowledgeBase(ctx context.Context, id string) (*sdk.KnowledgeBase, error)
UpdateKnowledgeBase(ctx context.Context, id string, req *sdk.UpdateKnowledgeBaseRequest) (*sdk.KnowledgeBase, error)
}
@@ -72,7 +78,31 @@ func runEdit(ctx context.Context, opts *EditOptions, svc EditService, id string)
}
}
req := &sdk.UpdateKnowledgeBaseRequest{}
risk := &format.Risk{Level: format.RiskWrite, Action: fmt.Sprintf("edit knowledge base %s", id)}
if opts.DryRun {
// Dry-run renders only the user-set fields so the preview reflects
// intent; the real-run fetch path fills in the rest from the server.
preview := &sdk.UpdateKnowledgeBaseRequest{}
if opts.Name != nil {
preview.Name = *opts.Name
}
if opts.Description != nil {
preview.Description = *opts.Description
}
return cmdutil.EmitDryRun(opts.JSONOut, preview, &format.Meta{KBID: id}, risk)
}
// Fetch current state so we can fill in fields the user didn't touch.
// TOCTOU note: another writer could change Name/Description between
// our Get and Put; matches the same race window kb pin / unpin have.
current, err := svc.GetKnowledgeBase(ctx, id)
if err != nil {
return cmdutil.WrapHTTP(err, "fetch knowledge base %s", id)
}
req := &sdk.UpdateKnowledgeBaseRequest{
Name: current.Name,
Description: current.Description,
}
if opts.Name != nil {
req.Name = *opts.Name
}
@@ -80,11 +110,6 @@ func runEdit(ctx context.Context, opts *EditOptions, svc EditService, id string)
req.Description = *opts.Description
}
risk := &format.Risk{Level: format.RiskWrite, Action: fmt.Sprintf("edit knowledge base %s", id)}
if opts.DryRun {
return cmdutil.EmitDryRun(opts.JSONOut, req, &format.Meta{KBID: id}, risk)
}
updated, err := svc.UpdateKnowledgeBase(ctx, id, req)
if err != nil {
return cmdutil.WrapHTTP(err, "edit knowledge base %s", id)
+39 -16
View File
@@ -14,12 +14,25 @@ import (
sdk "github.com/Tencent/WeKnora/client"
)
// fakeEditSvc captures the (id, request) pair handed to UpdateKnowledgeBase.
// fakeEditSvc captures the (id, request) pair handed to UpdateKnowledgeBase
// and scripts the GetKnowledgeBase fetch used by the fetch-then-update path.
type fakeEditSvc struct {
gotID string
gotReq *sdk.UpdateKnowledgeBaseRequest
resp *sdk.KnowledgeBase
err error
current *sdk.KnowledgeBase // returned by GetKnowledgeBase
currentErr error
gotID string
gotReq *sdk.UpdateKnowledgeBaseRequest
resp *sdk.KnowledgeBase
err error
}
func (f *fakeEditSvc) GetKnowledgeBase(_ context.Context, id string) (*sdk.KnowledgeBase, error) {
if f.currentErr != nil {
return nil, f.currentErr
}
if f.current != nil {
return f.current, nil
}
return &sdk.KnowledgeBase{ID: id}, nil
}
func (f *fakeEditSvc) UpdateKnowledgeBase(_ context.Context, id string, req *sdk.UpdateKnowledgeBaseRequest) (*sdk.KnowledgeBase, error) {
@@ -40,9 +53,16 @@ func TestEdit_RequiresAtLeastOneFlag(t *testing.T) {
assert.Contains(t, typed.Hint, "--description")
}
func TestEdit_OnlyName(t *testing.T) {
// When only --name is passed, the request must carry the user's new name
// AND the current Description (preserved via the fetch). Sending Description=""
// would clobber the server-side value because UpdateKnowledgeBaseRequest
// fields are `string`, not `*string`.
func TestEdit_OnlyName_PreservesCurrentDescription(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeEditSvc{resp: &sdk.KnowledgeBase{ID: "kb_abc", Name: "new"}}
svc := &fakeEditSvc{
current: &sdk.KnowledgeBase{ID: "kb_abc", Name: "old", Description: "keep me"},
resp: &sdk.KnowledgeBase{ID: "kb_abc", Name: "new", Description: "keep me"},
}
opts := &EditOptions{}
opts.Name = stringPtr("new")
require.NoError(t, runEdit(context.Background(), opts, svc, "kb_abc"))
@@ -50,24 +70,25 @@ func TestEdit_OnlyName(t *testing.T) {
assert.Equal(t, "kb_abc", svc.gotID)
require.NotNil(t, svc.gotReq)
assert.Equal(t, "new", svc.gotReq.Name)
// Description must be empty string (not "<nil>"), so server doesn't
// confuse "unset" with "set-to-empty". Actually the SDK ships an empty
// string either way — we just verify we didn't accidentally serialize a
// description override.
assert.Equal(t, "", svc.gotReq.Description)
assert.Equal(t, "keep me", svc.gotReq.Description, "Description must be preserved from fetch when not in --description")
assert.Contains(t, out.String(), "kb_abc")
}
func TestEdit_OnlyDescription(t *testing.T) {
// Symmetric: only --description must preserve current Name. The server's
// `Name required` validation made this case fail without the fetch.
func TestEdit_OnlyDescription_PreservesCurrentName(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeEditSvc{resp: &sdk.KnowledgeBase{ID: "kb_abc"}}
svc := &fakeEditSvc{
current: &sdk.KnowledgeBase{ID: "kb_abc", Name: "keep me", Description: "old"},
resp: &sdk.KnowledgeBase{ID: "kb_abc"},
}
opts := &EditOptions{}
opts.Description = stringPtr("new desc")
require.NoError(t, runEdit(context.Background(), opts, svc, "kb_abc"))
require.NotNil(t, svc.gotReq)
assert.Equal(t, "new desc", svc.gotReq.Description)
assert.Equal(t, "", svc.gotReq.Name)
assert.Equal(t, "keep me", svc.gotReq.Name, "Name must be preserved from fetch when not in --name")
}
func TestEdit_BothFlags(t *testing.T) {
@@ -95,7 +116,9 @@ func TestEdit_DryRun_JSON(t *testing.T) {
func TestEdit_NotFound(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeEditSvc{err: errors.New("HTTP error 404: not found")}
// 404 must come from the GetKnowledgeBase pre-fetch in the fetch-then-
// update flow — that's the first server roundtrip when the id is bad.
svc := &fakeEditSvc{currentErr: errors.New("HTTP error 404: not found")}
opts := &EditOptions{}
opts.Name = stringPtr("x")
err := runEdit(context.Background(), opts, svc, "kb_missing")
+6 -1
View File
@@ -51,6 +51,9 @@ func NewCmdChunks(f *cmdutil.Factory) *cobra.Command {
if err := opts.validate(); err != nil {
return err
}
if opts.Limit < 1 || opts.Limit > 1000 {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "--limit must be between 1 and 1000")
}
cli, err := f.Client()
if err != nil {
return err
@@ -81,7 +84,9 @@ func bindChunksFlags(cmd *cobra.Command, opts *ChunksOptions) {
cmd.Flags().BoolVar(&opts.JSONOut, "json", false, "Output JSON envelope")
}
// validate checks the option set before any SDK call.
// validate checks the option set before any SDK call. Limit bounds are
// enforced separately in RunE (user-input boundary) so internal callers
// can pass Limit==0 for the "no client-side cap" path.
func (o *ChunksOptions) validate() error {
if o.Query == "" {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "query argument cannot be empty")
+3
View File
@@ -54,6 +54,9 @@ func NewCmdDocs(f *cmdutil.Factory) *cobra.Command {
if opts.Query == "" {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "query argument cannot be empty")
}
if opts.Limit < 1 || opts.Limit > 1000 {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "--limit must be between 1 and 1000")
}
cli, err := f.Client()
if err != nil {
return err
+3
View File
@@ -47,6 +47,9 @@ func NewCmdKB(f *cmdutil.Factory) *cobra.Command {
if opts.Query == "" {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "query argument cannot be empty")
}
if opts.Limit < 1 || opts.Limit > 1000 {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "--limit must be between 1 and 1000")
}
cli, err := f.Client()
if err != nil {
return err
+3
View File
@@ -47,6 +47,9 @@ func NewCmdSessions(f *cmdutil.Factory) *cobra.Command {
if opts.Query == "" {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "query argument cannot be empty")
}
if opts.Limit < 1 || opts.Limit > 1000 {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "--limit must be between 1 and 1000")
}
cli, err := f.Client()
if err != nil {
return err
+4 -1
View File
@@ -38,7 +38,10 @@ func RefreshAndPersist(ctx context.Context, store secrets.Store, refresher Refre
resp, err := refresher.RefreshToken(ctx, refresh)
if err != nil {
return "", Wrapf(CodeNetworkError, err, "refresh access token")
// WrapHTTP rather than fixed CodeNetworkError so a refresh
// rejected by the server (401/403) surfaces as auth.token_expired /
// auth.forbidden instead of collapsing to network.error.
return "", WrapHTTP(err, "refresh access token")
}
if resp == nil || !resp.Success || resp.AccessToken == "" || resp.RefreshToken == "" {
msg := "refresh token rejected"
+6
View File
@@ -94,6 +94,12 @@ type Error struct {
// Stored as the format.Risk JSON shape via OperationRisk to avoid an
// import cycle with internal/format.
OperationRisk *OperationRisk
// Silent suppresses the default Failure envelope written by
// PrintErrorEnvelope while preserving the typed Code for ExitCode.
// Set by commands that already wrote their own envelope (e.g. bulk
// operations reporting partial-success data) but still need to surface
// a non-zero exit code matched to the failure class.
Silent bool
}
// OperationRisk mirrors format.Risk in the cmdutil layer (avoiding a circular
+4
View File
@@ -89,6 +89,10 @@ func PrintErrorEnvelope(w io.Writer, err error) {
if err == nil || errors.Is(err, SilentError) {
return
}
var typed *Error
if errors.As(err, &typed) && typed.Silent {
return
}
env := format.Failure(ToErrorBody(err))
if r := operationRiskOf(err); r != nil {
env.Risk = &format.Risk{Level: format.RiskLevel(r.Level), Action: r.Action}
+5 -2
View File
@@ -371,10 +371,13 @@ func (c *Client) HybridSearch(ctx context.Context, knowledgeBaseID string, param
return response.Data, nil
}
// TogglePinKnowledgeBase toggles the pin status of a knowledge base
// TogglePinKnowledgeBase toggles the pin status of a knowledge base.
// Server route is PUT (see internal/router/router.go); using POST silently
// 404s — the router treats unknown method on a known path as not-found,
// not 405.
func (c *Client) TogglePinKnowledgeBase(ctx context.Context, knowledgeBaseID string) (*KnowledgeBase, error) {
path := fmt.Sprintf("/api/v1/knowledge-bases/%s/pin", knowledgeBaseID)
resp, err := c.doRequest(ctx, http.MethodPost, path, nil, nil)
resp, err := c.doRequest(ctx, http.MethodPut, path, nil, nil)
if err != nil {
return nil, err
}