4a5449233d
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.
66 lines
2.4 KiB
Go
66 lines
2.4 KiB
Go
package cmdutil
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
|
|
"github.com/Tencent/WeKnora/cli/internal/secrets"
|
|
sdk "github.com/Tencent/WeKnora/client"
|
|
)
|
|
|
|
// Refresher is the narrow SDK surface RefreshAndPersist depends on.
|
|
// *sdk.Client satisfies it implicitly; tests inject fakes.
|
|
type Refresher interface {
|
|
RefreshToken(ctx context.Context, refreshToken string) (*sdk.RefreshTokenResponse, error)
|
|
}
|
|
|
|
// RefreshAndPersist reads the stored refresh token for ctxName, exchanges it
|
|
// for a new access + refresh pair via refresher, and writes both back to the
|
|
// secrets store. Returns the new access token (the refresh is already
|
|
// persisted as a side-effect, so callers only need the access value to
|
|
// retry the original request).
|
|
//
|
|
// Single canonical implementation shared by `weknora auth refresh` and the
|
|
// AuthRetryTransport's refresh closure — both used to inline the same
|
|
// six-step sequence with subtly diverging error wording.
|
|
func RefreshAndPersist(ctx context.Context, store secrets.Store, refresher Refresher, ctxName string) (string, error) {
|
|
refresh, err := store.Get(ctxName, "refresh")
|
|
if errors.Is(err, secrets.ErrNotFound) || refresh == "" {
|
|
return "", &Error{
|
|
Code: CodeAuthTokenExpired,
|
|
Message: "refresh token missing for context " + ctxName,
|
|
Hint: "run `weknora auth login` to re-authenticate",
|
|
}
|
|
}
|
|
if err != nil {
|
|
return "", Wrapf(CodeLocalKeychainDenied, err, "load refresh token")
|
|
}
|
|
|
|
resp, err := refresher.RefreshToken(ctx, refresh)
|
|
if err != nil {
|
|
// 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"
|
|
if resp != nil && resp.Message != "" {
|
|
msg = "refresh token rejected: " + resp.Message
|
|
}
|
|
return "", &Error{
|
|
Code: CodeAuthTokenExpired,
|
|
Message: msg,
|
|
Hint: "run `weknora auth login` to re-authenticate",
|
|
}
|
|
}
|
|
|
|
if err := store.Set(ctxName, "access", resp.AccessToken); err != nil {
|
|
return "", Wrapf(CodeLocalKeychainDenied, err, "save access token")
|
|
}
|
|
if err := store.Set(ctxName, "refresh", resp.RefreshToken); err != nil {
|
|
return "", Wrapf(CodeLocalKeychainDenied, err, "save refresh token")
|
|
}
|
|
return resp.AccessToken, nil
|
|
}
|