From 17679d32520dc7e70263b26a3f871cdb56974d21 Mon Sep 17 00:00:00 2001 From: "xuyan.smackgg" Date: Tue, 11 Aug 2026 15:16:05 +0800 Subject: [PATCH 01/14] feat: add safe PPE request routing Co-authored-by: Codex --- internal/common/client.go | 178 +++++++++++++++++--- internal/common/client_test.go | 286 +++++++++++++++++++++++++++++++++ internal/config/config.go | 21 +++ internal/config/config_test.go | 45 ++++++ 4 files changed, 509 insertions(+), 21 deletions(-) create mode 100644 internal/common/client_test.go diff --git a/internal/common/client.go b/internal/common/client.go index a10c075..e4a46b1 100644 --- a/internal/common/client.go +++ b/internal/common/client.go @@ -15,9 +15,17 @@ import ( "strings" "time" + "github.com/Pippit-dev/pippit-cli/internal/config" "github.com/bytedance/sonic" ) +const ( + ppeUseHeader = "x-use-ppe" + ppeEnvHeader = "x-tt-env" + ppeScheduleVDCHeader = "x-schedule-vdc" + defaultPPEVDC = "sinfonlinea" +) + type Client interface { SendRequest(ctx context.Context, path string, body any, out any) error SendRequestWithHeaders(ctx context.Context, path string, body any, headers map[string]string, out any) error @@ -40,17 +48,32 @@ type httpClient struct { httpClient *http.Client headers http.Header authorizer RequestAuthorizer + ppeEnv func() string } func NewHTTPClient(baseURL string, timeout time.Duration, authorizer RequestAuthorizer) Client { - return &httpClient{ - baseURL: strings.TrimRight(baseURL, "/"), - httpClient: &http.Client{ - Timeout: timeout, - }, + return newHTTPClient(baseURL, timeout, authorizer, nil) +} + +// NewHTTPClientWithPPEEnv creates a client whose PPE lane is resolved for each +// request. The provider allows Cobra's global --ppe-env flag to override the +// environment after command construction but before the request is sent. +func NewHTTPClientWithPPEEnv(baseURL string, timeout time.Duration, authorizer RequestAuthorizer, ppeEnv func() string) Client { + return newHTTPClient(baseURL, timeout, authorizer, ppeEnv) +} + +func newHTTPClient(baseURL string, timeout time.Duration, authorizer RequestAuthorizer, ppeEnv func() string) Client { + client := &httpClient{ + baseURL: strings.TrimRight(baseURL, "/"), headers: make(http.Header), authorizer: authorizer, + ppeEnv: ppeEnv, + } + client.httpClient = &http.Client{ + Timeout: timeout, + CheckRedirect: client.checkRedirect, } + return client } func (c *httpClient) SendRequest(ctx context.Context, path string, body any, out any) error { @@ -85,15 +108,10 @@ func (c *httpClient) SendRequestWithHeaders(ctx context.Context, path string, bo req.Header.Set("Content-Type", "application/json") } - if c.authorizer == nil { - return fmt.Errorf("授权请求缺少认证器") - } - if err := c.authorizer.Inject(ctx, req); err != nil { - return fmt.Errorf("写入认证请求头失败: %w", err) + if err := c.prepareRequest(ctx, req, headers); err != nil { + return err } - c.injectHeaders(req, headers) - // If out is **http.Response, return the raw response for streaming (e.g. file download). if out != nil { if rv := reflect.ValueOf(out); rv.Kind() == reflect.Ptr && rv.Elem().Kind() == reflect.Ptr { @@ -143,17 +161,11 @@ func (c *httpClient) SendMultipartRequest(ctx context.Context, path string, fiel req.Header.Set("Content-Type", writer.FormDataContentType()) req.Header.Set("Accept", "application/json") - if c.authorizer == nil { + if err := c.prepareRequest(ctx, req, nil); err != nil { _ = pr.Close() _ = pw.Close() - return fmt.Errorf("授权请求缺少认证器") - } - if err := c.authorizer.Inject(ctx, req); err != nil { - _ = pr.Close() - _ = pw.Close() - return fmt.Errorf("写入认证请求头失败: %w", err) + return err } - c.injectHeaders(req, nil) go func() { err := writeMultipartBody(writer, fields, file) @@ -212,6 +224,123 @@ func (c *httpClient) injectHeaders(req *http.Request, headers map[string]string) } } +func (c *httpClient) prepareRequest(ctx context.Context, req *http.Request, headers map[string]string) error { + trusted, err := c.isBaseURLOrigin(req.URL) + if err != nil { + return err + } + + var ppeEnv string + if trusted { + if c.ppeEnv != nil { + ppeEnv, err = config.NormalizePPEEnv(c.ppeEnv()) + if err != nil { + return err + } + } + } + + c.injectHeaders(req, headers) + // Authentication and PPE routing are protected headers. Neither the + // client's generic headers nor a caller-provided map may set or override + // them; rebuild them below only for the configured API origin. + req.Header.Del("Authorization") + req.Header.Del(ppeUseHeader) + req.Header.Del(ppeEnvHeader) + req.Header.Del(ppeScheduleVDCHeader) + if !trusted { + // Absolute third-party URLs are used for result downloads. The protected + // headers remain empty outside the API origin. + return nil + } + if c.authorizer == nil { + return fmt.Errorf("授权请求缺少认证器") + } + if err := c.authorizer.Inject(ctx, req); err != nil { + return fmt.Errorf("写入认证请求头失败: %w", err) + } + if ppeEnv != "" { + req.Header.Set(ppeUseHeader, "1") + req.Header.Set(ppeEnvHeader, ppeEnv) + req.Header.Set(ppeScheduleVDCHeader, defaultPPEVDC) + } + return nil +} + +func (c *httpClient) isBaseURLOrigin(target *url.URL) (bool, error) { + if c.baseURL == "" { + return false, nil + } + base, err := url.Parse(c.baseURL) + if err != nil || base.Scheme == "" || base.Host == "" { + return false, fmt.Errorf("解析 base URL %q 失败", c.baseURL) + } + return sameOrigin(base, target), nil +} + +func (c *httpClient) checkRedirect(req *http.Request, via []*http.Request) error { + if len(via) >= 10 { + return fmt.Errorf("停止重定向:已达到 10 次上限") + } + trusted, err := c.isBaseURLOrigin(req.URL) + if err != nil { + return err + } + initialTrusted, err := c.isBaseURLOrigin(via[0].URL) + if err != nil { + return err + } + if initialTrusted && !trusted { + // API requests may contain unreleased canvas data or upload metadata. + // Stripping credentials is insufficient because Go can preserve the + // method and body for 307/308 redirects. API calls have no valid reason + // to leave the configured origin, so reject the redirect entirely. + return fmt.Errorf("拒绝 Pippit API 跨域重定向到 %s", req.URL.Redacted()) + } + if trusted { + // net/http may rebuild redirect headers from the original request. Once a + // chain has crossed an untrusted origin, never restore protected headers + // even if a later hop points back at the API origin. + for _, previous := range via { + previousTrusted, err := c.isBaseURLOrigin(previous.URL) + if err != nil { + return err + } + if !previousTrusted { + trusted = false + break + } + } + } + if !trusted { + req.Header.Del("Authorization") + req.Header.Del(ppeUseHeader) + req.Header.Del(ppeEnvHeader) + req.Header.Del(ppeScheduleVDCHeader) + } + return nil +} + +func sameOrigin(a, b *url.URL) bool { + return strings.EqualFold(a.Scheme, b.Scheme) && + strings.EqualFold(a.Hostname(), b.Hostname()) && + effectivePort(a) == effectivePort(b) +} + +func effectivePort(u *url.URL) string { + if port := u.Port(); port != "" { + return port + } + switch strings.ToLower(u.Scheme) { + case "http": + return "80" + case "https": + return "443" + default: + return "" + } +} + func (c *httpClient) do(req *http.Request, out any) error { resp, err := c.httpClient.Do(req) if err != nil { @@ -240,7 +369,14 @@ func (c *httpClient) do(req *http.Request, out any) error { } func (c *httpClient) resolveURL(path string, query map[string]string) (string, error) { - if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { + parsed, err := url.Parse(path) + if err != nil { + return "", fmt.Errorf("解析 URL 失败: %w", err) + } + if parsed.IsAbs() { + if parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { + return "", fmt.Errorf("仅支持 http 或 https URL: %q", path) + } return appendQuery(path, query) } if c.baseURL == "" { diff --git a/internal/common/client_test.go b/internal/common/client_test.go new file mode 100644 index 0000000..a9dcb13 --- /dev/null +++ b/internal/common/client_test.go @@ -0,0 +1,286 @@ +package common + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" +) + +type observedHeaders struct { + authorization string + usePPE string + ppeEnv string + scheduleVDC string +} + +func TestHTTPClientScopesCredentialsAndPPEToBaseOrigin(t *testing.T) { + apiHeaders := make(chan observedHeaders, 1) + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + apiHeaders <- captureRoutingHeaders(r) + _, _ = w.Write([]byte(`{}`)) + })) + defer api.Close() + + thirdPartyHeaders := make(chan observedHeaders, 1) + thirdParty := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + thirdPartyHeaders <- captureRoutingHeaders(r) + _, _ = w.Write([]byte(`{}`)) + })) + defer thirdParty.Close() + + client := NewHTTPClientWithPPEEnv( + api.URL, + time.Second, + NewAccessKeyAuthorizer("secret-ak"), + func() string { return "ppe_cli_canvas_ak" }, + ) + if err := client.SendRequest(context.Background(), "/api/test", nil, nil); err != nil { + t.Fatalf("same-origin SendRequest() error = %v", err) + } + gotAPI := <-apiHeaders + if gotAPI.authorization != "Bearer secret-ak" { + t.Fatalf("same-origin Authorization = %q", gotAPI.authorization) + } + if gotAPI.usePPE != "1" || gotAPI.ppeEnv != "ppe_cli_canvas_ak" || gotAPI.scheduleVDC != defaultPPEVDC { + t.Fatalf("same-origin PPE headers = %#v", gotAPI) + } + + thirdPartyURL := thirdParty.URL + "/asset.mp4" + err := client.SendRequestWithHeaders(context.Background(), thirdPartyURL, nil, map[string]string{ + "Authorization": "Bearer caller-supplied", + "x-use-ppe": "1", + "x-tt-env": "ppe_leak", + "x-schedule-vdc": "leak-vdc", + }, nil) + if err != nil { + t.Fatalf("third-party SendRequest() error = %v", err) + } + gotThirdParty := <-thirdPartyHeaders + if gotThirdParty != (observedHeaders{}) { + t.Fatalf("third-party sensitive headers leaked: %#v", gotThirdParty) + } +} + +func TestHTTPClientTreatsSameOriginAbsoluteURLAsAPI(t *testing.T) { + headers := make(chan observedHeaders, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + headers <- captureRoutingHeaders(r) + _, _ = w.Write([]byte(`{}`)) + })) + defer server.Close() + + client := NewHTTPClientWithPPEEnv( + server.URL+"/base-path", + time.Second, + NewAccessKeyAuthorizer("same-origin-ak"), + func() string { return "ppe_absolute" }, + ) + if err := client.SendRequest(context.Background(), server.URL+"/api/absolute", nil, nil); err != nil { + t.Fatalf("SendRequest() error = %v", err) + } + got := <-headers + if got.authorization != "Bearer same-origin-ak" || got.usePPE != "1" || got.ppeEnv != "ppe_absolute" || got.scheduleVDC != defaultPPEVDC { + t.Fatalf("same-origin absolute headers = %#v", got) + } +} + +func TestHTTPClientRejectsAPICrossOriginRedirectBeforeSendingBody(t *testing.T) { + var thirdPartyRequests atomic.Int32 + thirdParty := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + thirdPartyRequests.Add(1) + _, _ = w.Write([]byte(`{}`)) + })) + defer thirdParty.Close() + + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, thirdParty.URL+"/redirected-asset", http.StatusTemporaryRedirect) + })) + defer api.Close() + + client := NewHTTPClientWithPPEEnv( + api.URL, + time.Second, + NewAccessKeyAuthorizer("redirect-ak"), + func() string { return "ppe_redirect" }, + ) + err := client.SendRequest(context.Background(), "/api/redirect", map[string]string{"canvas": "private"}, nil) + if err == nil || !strings.Contains(err.Error(), "拒绝 Pippit API 跨域重定向") { + t.Fatalf("SendRequest() error = %v, want cross-origin redirect rejection", err) + } + if got := thirdPartyRequests.Load(); got != 0 { + t.Fatalf("third party received %d API redirect requests, want 0", got) + } +} + +func TestHTTPClientDoesNotRestoreProtectedHeadersAfterCrossOriginRedirect(t *testing.T) { + finalHeaders := make(chan observedHeaders, 1) + var apiURL string + thirdParty := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := captureRoutingHeaders(r); got != (observedHeaders{}) { + t.Errorf("cross-origin hop leaked protected headers: %#v", got) + } + http.Redirect(w, r, apiURL+"/final", http.StatusFound) + })) + defer thirdParty.Close() + + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + finalHeaders <- captureRoutingHeaders(r) + _, _ = w.Write([]byte(`{}`)) + })) + defer api.Close() + apiURL = api.URL + + client := NewHTTPClientWithPPEEnv( + api.URL, + time.Second, + NewAccessKeyAuthorizer("redirect-bounce-ak"), + func() string { return "ppe_redirect_bounce" }, + ) + if err := client.SendRequest(context.Background(), thirdParty.URL+"/bounce", nil, nil); err != nil { + t.Fatalf("SendRequest() error = %v", err) + } + if got := <-finalHeaders; got != (observedHeaders{}) { + t.Fatalf("trusted return hop restored protected headers: %#v", got) + } +} + +func TestHTTPClientRejectsInvalidPPEEnvBeforeNetwork(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + _, _ = w.Write([]byte(`{}`)) + })) + defer server.Close() + + client := NewHTTPClientWithPPEEnv( + server.URL, + time.Second, + NewAccessKeyAuthorizer("secret-ak"), + func() string { return "production" }, + ) + err := client.SendRequest(context.Background(), "/api/test", nil, nil) + if err == nil || !strings.Contains(err.Error(), "PPE 环境") { + t.Fatalf("SendRequest() error = %v, want invalid PPE error", err) + } + if got := requests.Load(); got != 0 { + t.Fatalf("server received %d requests, want 0", got) + } +} + +func TestHTTPClientProductionRequestOmitsPPEHeaders(t *testing.T) { + headers := make(chan observedHeaders, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + headers <- captureRoutingHeaders(r) + _, _ = w.Write([]byte(`{}`)) + })) + defer server.Close() + + client := NewHTTPClientWithPPEEnv( + server.URL, + time.Second, + NewAccessKeyAuthorizer("production-ak"), + func() string { return "" }, + ) + err := client.SendRequestWithHeaders(context.Background(), "/api/test", nil, map[string]string{ + "Authorization": "Bearer caller-override", + "x-use-ppe": "1", + "x-tt-env": "ppe_caller_injection", + "x-schedule-vdc": "caller-vdc", + }, nil) + if err != nil { + t.Fatalf("SendRequest() error = %v", err) + } + got := <-headers + if got.authorization != "Bearer production-ak" || got.usePPE != "" || got.ppeEnv != "" || got.scheduleVDC != "" { + t.Fatalf("production headers = %#v", got) + } +} + +func TestHTTPClientProtectsConfiguredPPEHeadersFromCallerOverride(t *testing.T) { + headers := make(chan observedHeaders, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + headers <- captureRoutingHeaders(r) + _, _ = w.Write([]byte(`{}`)) + })) + defer server.Close() + + client := NewHTTPClientWithPPEEnv( + server.URL, + time.Second, + NewAccessKeyAuthorizer("configured-ak"), + func() string { return "ppe_configured" }, + ) + err := client.SendRequestWithHeaders(context.Background(), "/api/test", nil, map[string]string{ + "Authorization": "Bearer caller-override", + "x-use-ppe": "0", + "x-tt-env": "ppe_caller_override", + "x-schedule-vdc": "caller-vdc", + }, nil) + if err != nil { + t.Fatalf("SendRequest() error = %v", err) + } + got := <-headers + if got.authorization != "Bearer configured-ak" || got.usePPE != "1" || got.ppeEnv != "ppe_configured" || got.scheduleVDC != defaultPPEVDC { + t.Fatalf("protected headers = %#v", got) + } +} + +func TestHTTPClientMultipartRequestInjectsPPEHeaders(t *testing.T) { + headers := make(chan observedHeaders, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + headers <- captureRoutingHeaders(r) + _, _ = w.Write([]byte(`{}`)) + })) + defer server.Close() + + filePath := filepath.Join(t.TempDir(), "sample.txt") + if err := os.WriteFile(filePath, []byte("sample"), 0o600); err != nil { + t.Fatal(err) + } + client := NewHTTPClientWithPPEEnv( + server.URL, + time.Second, + NewAccessKeyAuthorizer("upload-ak"), + func() string { return "ppe_upload" }, + ) + err := client.SendMultipartRequest(context.Background(), "/api/upload", nil, MultipartFile{ + FieldName: "file", + Path: filePath, + }, nil) + if err != nil { + t.Fatalf("SendMultipartRequest() error = %v", err) + } + got := <-headers + if got.authorization != "Bearer upload-ak" || got.usePPE != "1" || got.ppeEnv != "ppe_upload" || got.scheduleVDC != defaultPPEVDC { + t.Fatalf("multipart headers = %#v", got) + } +} + +func TestSameOriginUsesEffectiveDefaultPorts(t *testing.T) { + httpsDefault, _ := url.Parse("https://xyq.jianying.com/api") + httpsExplicit, _ := url.Parse("https://XYQ.JIANYING.COM:443/asset") + if !sameOrigin(httpsDefault, httpsExplicit) { + t.Fatal("sameOrigin() = false for equivalent HTTPS origins") + } + + differentPort, _ := url.Parse("https://xyq.jianying.com:8443/api") + if sameOrigin(httpsDefault, differentPort) { + t.Fatal("sameOrigin() = true for different ports") + } +} + +func captureRoutingHeaders(r *http.Request) observedHeaders { + return observedHeaders{ + authorization: r.Header.Get("Authorization"), + usePPE: r.Header.Get(ppeUseHeader), + ppeEnv: r.Header.Get(ppeEnvHeader), + scheduleVDC: r.Header.Get(ppeScheduleVDCHeader), + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 48b34e4..2d3371c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,7 +1,9 @@ package config import ( + "fmt" "os" + "regexp" "strings" "time" ) @@ -18,8 +20,11 @@ const ( UploadFilePath = "/api/biz/v1/skill/upload_file" ListThreadFilePath = "/api/biz/v1/skill/list_thread_file" EnvXYQAccessKey = "XYQ_ACCESS_KEY" + EnvPPEEnv = "PIPPIT_CLI_PPE_ENV" ) +var ppeEnvPattern = regexp.MustCompile(`^ppe_[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`) + // Config holds runtime settings selected by the root command and passed down // into lower layers. type Config struct { @@ -27,6 +32,7 @@ type Config struct { HTTPTimeout time.Duration AuthTTL time.Duration AccessKey string + PPEEnv string OAuth *OAuth Paths *Paths } @@ -52,6 +58,7 @@ func Load() *Config { HTTPTimeout: DefaultHTTPTimeout, AuthTTL: DefaultAuthTTL, AccessKey: strings.TrimSpace(os.Getenv(EnvXYQAccessKey)), + PPEEnv: strings.TrimSpace(os.Getenv(EnvPPEEnv)), OAuth: resolveOAuth(), Paths: &Paths{ SubmitRun: SubmitRunPath, @@ -62,6 +69,20 @@ func Load() *Config { } } +// NormalizePPEEnv validates an optional PPE lane name before it can be sent in +// request headers. Empty selects production. Keeping the accepted character +// set narrow also prevents malformed header values from reaching net/http. +func NormalizePPEEnv(value string) (string, error) { + value = strings.TrimSpace(value) + if value == "" { + return "", nil + } + if !ppeEnvPattern.MatchString(value) { + return "", fmt.Errorf("PPE 环境 %q 非法:必须以 ppe_ 开头,且只能包含字母、数字、点、下划线或连字符", value) + } + return value, nil +} + func resolveOAuth() *OAuth { return &OAuth{ ClientKey: DefaultOAuthClientKey, diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 6c9d91b..561e1af 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -4,6 +4,7 @@ import "testing" func TestLoadUsesDefaultConfig(t *testing.T) { t.Setenv(EnvXYQAccessKey, "") + t.Setenv(EnvPPEEnv, "") cfg := Load() if cfg.BaseURL != DefaultBaseURL { t.Fatalf("BaseURL = %q, want %q", cfg.BaseURL, DefaultBaseURL) @@ -17,6 +18,9 @@ func TestLoadUsesDefaultConfig(t *testing.T) { if cfg.AccessKey != "" { t.Fatalf("AccessKey = %q, want empty", cfg.AccessKey) } + if cfg.PPEEnv != "" { + t.Fatalf("PPEEnv = %q, want empty", cfg.PPEEnv) + } if cfg.OAuth.ClientKey != DefaultOAuthClientKey { t.Fatalf("OAuth.ClientKey = %q, want %q", cfg.OAuth.ClientKey, DefaultOAuthClientKey) } @@ -47,3 +51,44 @@ func TestLoadReadsAccessKey(t *testing.T) { t.Fatalf("AccessKey = %q, want trimmed token", cfg.AccessKey) } } + +func TestLoadReadsPPEEnv(t *testing.T) { + t.Setenv(EnvPPEEnv, " ppe_cli_canvas_ak ") + cfg := Load() + if cfg.PPEEnv != "ppe_cli_canvas_ak" { + t.Fatalf("PPEEnv = %q, want trimmed PPE lane", cfg.PPEEnv) + } +} + +func TestNormalizePPEEnv(t *testing.T) { + tests := []struct { + name string + value string + want string + wantErr bool + }{ + {name: "production", value: "", want: ""}, + {name: "trimmed", value: " ppe_cli-canvas.1 ", want: "ppe_cli-canvas.1"}, + {name: "missing prefix", value: "cli_canvas", wantErr: true}, + {name: "header injection", value: "ppe_canvas\r\nx-evil: 1", wantErr: true}, + {name: "space", value: "ppe_canvas lane", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := NormalizePPEEnv(tt.value) + if tt.wantErr { + if err == nil { + t.Fatalf("NormalizePPEEnv(%q) error = nil, want error", tt.value) + } + return + } + if err != nil { + t.Fatalf("NormalizePPEEnv(%q) error = %v", tt.value, err) + } + if got != tt.want { + t.Fatalf("NormalizePPEEnv(%q) = %q, want %q", tt.value, got, tt.want) + } + }) + } +} From 28c1969cdb85be8f414296af1528ae2b027ff9fc Mon Sep 17 00:00:00 2001 From: "xuyan.smackgg" Date: Tue, 11 Aug 2026 15:16:15 +0800 Subject: [PATCH 02/14] feat: add personal canvas create and read core Co-authored-by: Codex --- internal/canvas/allocate.go | 69 +++++++ internal/canvas/create.go | 380 ++++++++++++++++++++++++++++++++++++ internal/canvas/get.go | 156 +++++++++++++++ internal/canvas/types.go | 68 +++++++ 4 files changed, 673 insertions(+) create mode 100644 internal/canvas/allocate.go create mode 100644 internal/canvas/create.go create mode 100644 internal/canvas/get.go create mode 100644 internal/canvas/types.go diff --git a/internal/canvas/allocate.go b/internal/canvas/allocate.go new file mode 100644 index 0000000..95eee07 --- /dev/null +++ b/internal/canvas/allocate.go @@ -0,0 +1,69 @@ +package canvas + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/Pippit-dev/pippit-cli/internal/common" +) + +const MaxAllocateCount = 5000 + +type AllocateResult struct { + AssetIDs []string `json:"asset_ids"` + LogID string `json:"log_id,omitempty"` +} + +type allocateRequest struct { + Count int `json:"count"` + Base map[string]any `json:"Base"` +} + +func Allocate(ctx context.Context, count int, runner *common.Runner) (*AllocateResult, error) { + client, err := runnerClient(runner, "canvas allocate") + if err != nil { + return nil, err + } + if count <= 0 || count > MaxAllocateCount { + return nil, fmt.Errorf("canvas allocate count must be between 1 and %d", MaxAllocateCount) + } + var envelope responseEnvelope + if err := client.SendRequest(ctx, AllocatePath, allocateRequest{Count: count, Base: base()}, &envelope); err != nil { + return nil, fmt.Errorf("canvas allocate request failed: %w", err) + } + if err := envelope.validate("canvas allocate"); err != nil { + return nil, err + } + var data map[string]json.RawMessage + if err := json.Unmarshal(envelope.Data, &data); err != nil { + return nil, common.NewLogIDError(fmt.Sprintf("canvas allocate returned invalid data: %v", err), envelope.LogID) + } + idsRaw, ok := rawField(data, "ids", "IDs", "asset_ids") + if !ok { + return nil, common.NewLogIDError("canvas allocate response is missing data.ids", envelope.LogID) + } + var ids []string + if err := json.Unmarshal(idsRaw, &ids); err != nil { + return nil, common.NewLogIDError("canvas allocate data.ids must contain JSON strings", envelope.LogID) + } + if len(ids) != count { + return nil, common.NewLogIDError( + fmt.Sprintf("canvas allocate returned %d ids, want %d", len(ids), count), + envelope.LogID, + ) + } + seen := make(map[string]struct{}, len(ids)) + for index, id := range ids { + ids[index] = strings.TrimSpace(id) + if ids[index] == "" { + return nil, common.NewLogIDError(fmt.Sprintf("canvas allocate returned empty id at index %d", index), envelope.LogID) + } + if _, duplicate := seen[ids[index]]; duplicate { + return nil, common.NewLogIDError(fmt.Sprintf("canvas allocate returned duplicate id %q", ids[index]), envelope.LogID) + } + seen[ids[index]] = struct{}{} + } + return &AllocateResult{AssetIDs: ids, LogID: strings.TrimSpace(envelope.LogID)}, nil +} diff --git a/internal/canvas/create.go b/internal/canvas/create.go new file mode 100644 index 0000000..ee5ef3d --- /dev/null +++ b/internal/canvas/create.go @@ -0,0 +1,380 @@ +package canvas + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/url" + "strings" + "time" + + "github.com/Pippit-dev/pippit-cli/internal/common" +) + +const defaultCreationPollInterval = time.Second + +var terminalCreationStates = map[string]struct{}{ + "4": {}, "5": {}, "9": {}, "failed": {}, "canceled": {}, "hitl_interrupt": {}, +} + +type CreateOptions struct { + Title string + RequestID string + Wait bool + PollInterval time.Duration + WaitTimeout time.Duration +} + +type CreateResult struct { + RequestID string `json:"request_id"` + State string `json:"state"` + ProjectID string `json:"project_id"` + ThreadID string `json:"thread_id"` + RunID string `json:"run_id"` + CanvasAssetID string `json:"canvas_asset_id"` + OverviewPippitAssetID string `json:"overview_pippit_asset_id,omitempty"` + WebURL string `json:"web_url"` + LogID string `json:"log_id,omitempty"` + PollAttempts int `json:"poll_attempts,omitempty"` + Warning string `json:"warning,omitempty"` +} + +type CreationTerminalError struct { + State string +} + +func (e *CreationTerminalError) Error() string { + return fmt.Sprintf("canvas creation reached terminal state %q", strings.TrimSpace(e.State)) +} + +type createRequest struct { + Surface string `json:"surface"` + Title string `json:"title,omitempty"` + RequestID string `json:"request_id"` + Base map[string]any `json:"Base"` +} + +type createData struct { + State string `json:"state"` + ProjectID string `json:"project_id"` + ThreadID string `json:"thread_id"` + RunID string `json:"run_id"` + CanvasAssetID string `json:"canvas_asset_id"` + OverviewPippitAssetID string `json:"overview_pippit_asset_id"` + WebURL string `json:"web_url"` +} + +func Create(ctx context.Context, opts CreateOptions, runner *common.Runner) (*CreateResult, error) { + client, err := runnerClient(runner, "canvas create") + if err != nil { + return nil, err + } + if opts.PollInterval < 0 || opts.WaitTimeout < 0 { + return nil, fmt.Errorf("canvas create polling durations must not be negative") + } + requestID := strings.TrimSpace(opts.RequestID) + if requestID == "" { + return nil, fmt.Errorf("canvas create request_id is required") + } + if len([]rune(requestID)) > 128 { + return nil, fmt.Errorf("canvas create request_id must not exceed 128 characters") + } + title := strings.TrimSpace(opts.Title) + if len([]rune(title)) > 50 { + return nil, fmt.Errorf("canvas create title must not exceed 50 characters") + } + + var envelope responseEnvelope + err = client.SendRequest(ctx, CreatePath, createRequest{ + Surface: SurfaceNovel, + Title: title, + RequestID: requestID, + Base: base(), + }, &envelope) + if err != nil { + return nil, fmt.Errorf("canvas create request failed; outcome may be ambiguous, do not retry blindly: %w", err) + } + if err := envelope.validate("canvas create"); err != nil { + return nil, err + } + var data createData + if err := json.Unmarshal(envelope.Data, &data); err != nil { + return nil, common.NewLogIDError(fmt.Sprintf("canvas create returned invalid data: %v", err), envelope.LogID) + } + result := &CreateResult{ + RequestID: requestID, + State: strings.TrimSpace(data.State), + ProjectID: strings.TrimSpace(data.ProjectID), + ThreadID: strings.TrimSpace(data.ThreadID), + RunID: strings.TrimSpace(data.RunID), + CanvasAssetID: strings.TrimSpace(data.CanvasAssetID), + OverviewPippitAssetID: strings.TrimSpace(data.OverviewPippitAssetID), + WebURL: strings.TrimSpace(data.WebURL), + LogID: strings.TrimSpace(envelope.LogID), + } + if err := validateCreateResult(result); err != nil { + return nil, common.NewLogIDError(err.Error(), envelope.LogID) + } + if !opts.Wait { + return result, nil + } + if result.OverviewPippitAssetID != "" { + return finalizeReadyCanvas(ctx, result, runner) + } + + artifact, attempts, waitErr := waitForCreationArtifact(ctx, runner, result.ThreadID, result.RunID, opts.PollInterval, opts.WaitTimeout) + result.PollAttempts = attempts + if waitErr != nil { + result.Warning = waitErr.Error() + var terminal *CreationTerminalError + if errors.As(waitErr, &terminal) { + result.State = "failed" + return result, acceptedCreationError(result, waitErr) + } + // The create response was already accepted. A timeout or transient + // get_thread failure must preserve the operation IDs and must not make + // callers assume it is safe to create another project. + return result, nil + } + if artifact.CanvasAssetID != "" && artifact.CanvasAssetID != result.CanvasAssetID { + result.State = "failed" + result.Warning = fmt.Sprintf("canvas create artifact canvas_asset_id mismatch: got %q, want %q", artifact.CanvasAssetID, result.CanvasAssetID) + return result, acceptedCreationError(result, errors.New(result.Warning)) + } + result.OverviewPippitAssetID = artifact.OverviewPippitAssetID + return finalizeReadyCanvas(ctx, result, runner) +} + +func finalizeReadyCanvas(ctx context.Context, result *CreateResult, runner *common.Runner) (*CreateResult, error) { + // Preserve the confirmed overview locator even if the final root visibility + // check is temporarily unavailable. The state remains creating until that + // check succeeds, but callers can resume with the canonical project URL. + result.WebURL = readyCanvasURL(result.WebURL, result.ProjectID, result.OverviewPippitAssetID) + if _, err := queryAssets(ctx, []string{result.CanvasAssetID}, true, runner); err != nil { + result.State = StateCreating + result.Warning = fmt.Sprintf("canvas overview is complete but the root asset is not queryable yet: %v", err) + return result, nil + } + result.State = StateReady + return result, nil +} + +func acceptedCreationError(result *CreateResult, cause error) error { + return fmt.Errorf( + "%w; accepted canvas IDs: project_id=%s thread_id=%s run_id=%s canvas_asset_id=%s; do not create again blindly", + cause, result.ProjectID, result.ThreadID, result.RunID, result.CanvasAssetID, + ) +} + +func validateCreateResult(result *CreateResult) error { + if result == nil { + return fmt.Errorf("canvas create response is empty") + } + missing := make([]string, 0, 4) + for name, value := range map[string]string{ + "project_id": result.ProjectID, "thread_id": result.ThreadID, + "run_id": result.RunID, "canvas_asset_id": result.CanvasAssetID, + } { + if value == "" { + missing = append(missing, name) + } + } + if len(missing) != 0 { + return fmt.Errorf("canvas create response is missing %s", strings.Join(missing, ", ")) + } + if result.State == "" { + return fmt.Errorf("canvas create response is missing state") + } + if result.WebURL == "" { + return fmt.Errorf("canvas create response is missing web_url") + } + return nil +} + +type creationArtifact struct { + OverviewPippitAssetID string + CanvasAssetID string +} + +func waitForCreationArtifact( + ctx context.Context, + runner *common.Runner, + threadID, runID string, + pollInterval, waitTimeout time.Duration, +) (creationArtifact, int, error) { + if pollInterval <= 0 { + pollInterval = defaultCreationPollInterval + } + if waitTimeout <= 0 { + waitTimeout = 2 * time.Minute + } + waitCtx, cancel := context.WithTimeout(ctx, waitTimeout) + defer cancel() + attempts := 0 + for { + attempts++ + result, err := common.GetThread(waitCtx, &common.GetThreadOptions{ThreadID: threadID, RunID: runID}, runner) + if err != nil { + if waitCtx.Err() != nil { + return creationArtifact{}, attempts, creationWaitError(waitCtx.Err(), waitTimeout) + } + return creationArtifact{}, attempts, fmt.Errorf("poll canvas creation: %w", err) + } + run := findRunByID(result.RawData, runID) + if run != nil { + state := normalizeState(run["state"]) + artifact := findCreationArtifact(run) + if (state == "3" || state == "completed") && artifact.OverviewPippitAssetID != "" { + return artifact, attempts, nil + } + if _, terminal := terminalCreationStates[state]; terminal { + return creationArtifact{}, attempts, &CreationTerminalError{State: state} + } + } + + timer := time.NewTimer(pollInterval) + select { + case <-waitCtx.Done(): + timer.Stop() + return creationArtifact{}, attempts, creationWaitError(waitCtx.Err(), waitTimeout) + case <-timer.C: + } + } +} + +func creationWaitError(err error, timeout time.Duration) error { + if errors.Is(err, context.DeadlineExceeded) { + return fmt.Errorf("canvas creation artifact was not ready within %s", timeout) + } + return fmt.Errorf("canvas creation wait canceled: %w", err) +} + +func findRunByID(raw json.RawMessage, runID string) map[string]any { + var value any + if json.Unmarshal(raw, &value) != nil { + return nil + } + return walkForRun(value, runID, 0) +} + +func walkForRun(value any, runID string, depth int) map[string]any { + if depth > 14 { + return nil + } + switch typed := parseJSONValue(value).(type) { + case map[string]any: + if stringValue(firstValue(typed, "run_id", "runId")) == runID { + return typed + } + for _, child := range typed { + if found := walkForRun(child, runID, depth+1); found != nil { + return found + } + } + case []any: + for _, child := range typed { + if found := walkForRun(child, runID, depth+1); found != nil { + return found + } + } + } + return nil +} + +func findCreationArtifact(value any) creationArtifact { + artifact := creationArtifact{} + walkArtifact(parseJSONValue(value), 0, &artifact) + return artifact +} + +func walkArtifact(value any, depth int, artifact *creationArtifact) { + if depth > 14 || (artifact.OverviewPippitAssetID != "" && artifact.CanvasAssetID != "") { + return + } + switch typed := parseJSONValue(value).(type) { + case map[string]any: + if stringValue(firstValue(typed, "sub_type", "subType")) == overviewPartSubtype { + data, _ := parseJSONValue(typed["data"]).(map[string]any) + if data == nil { + data = map[string]any{} + } + artifact.OverviewPippitAssetID = firstNonEmpty( + stringValue(firstValue(data, "pippit_asset_id", "pippitAssetId")), + stringValue(firstValue(typed, "pippit_asset_id", "pippitAssetId")), + ) + artifact.CanvasAssetID = stringValue(firstValue(data, "canvas_asset_id", "canvasAssetId")) + } + for _, child := range typed { + walkArtifact(child, depth+1, artifact) + } + case []any: + for _, child := range typed { + walkArtifact(child, depth+1, artifact) + } + } +} + +func parseJSONValue(value any) any { + text, ok := value.(string) + if !ok { + return value + } + text = strings.TrimSpace(text) + if text == "" || (text[0] != '{' && text[0] != '[') { + return value + } + var parsed any + if json.Unmarshal([]byte(text), &parsed) == nil { + return parsed + } + return value +} + +func firstValue(values map[string]any, keys ...string) any { + for _, key := range keys { + if value, ok := values[key]; ok { + return value + } + } + return nil +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value = strings.TrimSpace(value); value != "" { + return value + } + } + return "" +} + +func stringValue(value any) string { + switch typed := value.(type) { + case string: + return strings.TrimSpace(typed) + case json.Number: + return typed.String() + case float64: + return fmt.Sprintf("%.0f", typed) + default: + return "" + } +} + +func normalizeState(value any) string { + return strings.ToLower(stringValue(value)) +} + +func readyCanvasURL(raw, projectID, overviewID string) string { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + parsed, _ = url.Parse("https://xyq.jianying.com/novel/detail/canvas") + } + query := parsed.Query() + query.Set("projectId", projectID) + query.Set("overviewPippitAssetId", overviewID) + query.Del("canvasId") + parsed.RawQuery = query.Encode() + return parsed.String() +} diff --git a/internal/canvas/get.go b/internal/canvas/get.go new file mode 100644 index 0000000..13473e5 --- /dev/null +++ b/internal/canvas/get.go @@ -0,0 +1,156 @@ +package canvas + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/Pippit-dev/pippit-cli/internal/common" +) + +type GetOptions struct { + AssetIDs []string +} + +type GetResult struct { + RequestedAssetIDs []string `json:"requested_asset_ids"` + Assets []json.RawMessage `json:"assets"` + LogID string `json:"log_id,omitempty"` +} + +type queryRequest struct { + PippitAssetIDs []string `json:"pippit_asset_ids"` + Base map[string]any `json:"Base"` +} + +func Get(ctx context.Context, opts GetOptions, runner *common.Runner) (*GetResult, error) { + assetIDs, err := normalizeAssetIDs(opts.AssetIDs) + if err != nil { + return nil, err + } + return queryAssets(ctx, assetIDs, true, runner) +} + +func queryAssets(ctx context.Context, assetIDs []string, requireAll bool, runner *common.Runner) (*GetResult, error) { + client, err := runnerClient(runner, "canvas get") + if err != nil { + return nil, err + } + var envelope responseEnvelope + if err := client.SendRequest(ctx, QueryPath, queryRequest{PippitAssetIDs: assetIDs, Base: base()}, &envelope); err != nil { + return nil, fmt.Errorf("canvas get request failed: %w", err) + } + if err := envelope.validate("canvas get"); err != nil { + return nil, err + } + assets, err := assetsFromData(envelope.Data) + if err != nil { + return nil, common.NewLogIDError(fmt.Sprintf("canvas get returned invalid data: %v", err), envelope.LogID) + } + + byID := make(map[string]json.RawMessage, len(assets)) + for _, asset := range assets { + assetID, err := assetIDFromRaw(asset) + if err != nil { + return nil, common.NewLogIDError(fmt.Sprintf("canvas get returned invalid asset: %v", err), envelope.LogID) + } + if _, duplicate := byID[assetID]; duplicate { + return nil, common.NewLogIDError(fmt.Sprintf("canvas get returned duplicate asset %q", assetID), envelope.LogID) + } + byID[assetID] = asset + } + + ordered := make([]json.RawMessage, 0, len(assetIDs)) + missing := make([]string, 0) + for _, assetID := range assetIDs { + asset, ok := byID[assetID] + if !ok { + missing = append(missing, assetID) + continue + } + ordered = append(ordered, asset) + } + if requireAll && len(missing) != 0 { + return nil, common.NewLogIDError( + fmt.Sprintf("canvas get did not return requested assets: %s", strings.Join(missing, ", ")), + envelope.LogID, + ) + } + return &GetResult{ + RequestedAssetIDs: append([]string(nil), assetIDs...), + Assets: ordered, + LogID: strings.TrimSpace(envelope.LogID), + }, nil +} + +func normalizeAssetIDs(values []string) ([]string, error) { + if len(values) == 0 { + return nil, fmt.Errorf("at least one canvas asset_id is required") + } + result := make([]string, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for index, value := range values { + value = strings.TrimSpace(value) + if value == "" { + return nil, fmt.Errorf("canvas asset_id at index %d is empty", index) + } + if _, duplicate := seen[value]; duplicate { + return nil, fmt.Errorf("canvas asset_id %q is duplicated", value) + } + seen[value] = struct{}{} + result = append(result, value) + } + return result, nil +} + +func assetsFromData(raw json.RawMessage) ([]json.RawMessage, error) { + var data map[string]json.RawMessage + if len(raw) == 0 || string(raw) == "null" { + return nil, fmt.Errorf("data is missing") + } + if err := json.Unmarshal(raw, &data); err != nil { + return nil, err + } + assetsRaw, ok := rawField(data, "Assets", "assets") + if !ok { + return nil, fmt.Errorf("data.assets is missing") + } + var assets []json.RawMessage + if err := json.Unmarshal(assetsRaw, &assets); err != nil { + return nil, fmt.Errorf("decode data.assets: %w", err) + } + if assets == nil { + assets = []json.RawMessage{} + } + return assets, nil +} + +func assetIDFromRaw(raw json.RawMessage) (string, error) { + var asset map[string]json.RawMessage + if err := json.Unmarshal(raw, &asset); err != nil { + return "", err + } + value, ok := rawField(asset, "PippitAssetID", "pippit_asset_id", "pippitAssetId") + if !ok { + return "", fmt.Errorf("asset is missing pippit_asset_id") + } + var assetID string + if err := json.Unmarshal(value, &assetID); err != nil { + return "", fmt.Errorf("pippit_asset_id must be a JSON string") + } + assetID = strings.TrimSpace(assetID) + if assetID == "" { + return "", fmt.Errorf("pippit_asset_id is empty") + } + return assetID, nil +} + +func rawField(values map[string]json.RawMessage, keys ...string) (json.RawMessage, bool) { + for _, key := range keys { + if value, ok := values[key]; ok { + return value, true + } + } + return nil, false +} diff --git a/internal/canvas/types.go b/internal/canvas/types.go new file mode 100644 index 0000000..6ffb5a3 --- /dev/null +++ b/internal/canvas/types.go @@ -0,0 +1,68 @@ +package canvas + +import ( + "encoding/json" + "fmt" + "net/url" + "strings" + + "github.com/Pippit-dev/pippit-cli/internal/common" +) + +const ( + CreatePath = "/api/biz/v1/skill/canvas/create" + QueryPath = "/api/biz/v1/skill/canvas/query" + AllocatePath = "/api/biz/v1/skill/canvas/batch_generate_asset_id" + ApplyPath = "/api/biz/v1/skill/canvas/batch_patch_asset" + UploadPath = "/api/biz/v1/skill/upload_file" +) + +const ( + SurfaceNovel = "novel" + StateCreating = "creating" + StateProcessing = "processing" + StateReady = "ready" + overviewPartSubtype = "biz/x_data_novel_script_overview" +) + +type responseEnvelope struct { + Ret string `json:"ret"` + Errmsg string `json:"errmsg"` + LogID string `json:"log_id"` + Data json.RawMessage `json:"data"` +} + +func (r responseEnvelope) validate(operation string) error { + if r.Ret == "0" { + return nil + } + message := strings.TrimSpace(r.Errmsg) + if message == "" { + message = "unknown error" + } + return common.NewLogIDError( + fmt.Sprintf("%s failed: ret=%q errmsg=%s", operation, r.Ret, message), + r.LogID, + ) +} + +func base() map[string]any { + return map[string]any{} +} + +func runnerClient(runner *common.Runner, operation string) (common.Client, error) { + if runner == nil || runner.Client == nil { + return nil, fmt.Errorf("%s client is missing", operation) + } + return runner.Client, nil +} + +func pathWithProjectID(path, projectID string) string { + projectID = strings.TrimSpace(projectID) + if projectID == "" { + return path + } + query := url.Values{} + query.Set("project_id", projectID) + return path + "?" + query.Encode() +} From 7f014fe267be4edc9843d6cd7945bf7759860e82 Mon Sep 17 00:00:00 2001 From: "xuyan.smackgg" Date: Tue, 11 Aug 2026 15:17:28 +0800 Subject: [PATCH 03/14] feat: add canvas apply and upload core Co-authored-by: Codex --- internal/canvas/apply.go | 203 +++++++++++++++ internal/canvas/canvas_test.go | 446 +++++++++++++++++++++++++++++++++ internal/canvas/upload.go | 250 ++++++++++++++++++ 3 files changed, 899 insertions(+) create mode 100644 internal/canvas/apply.go create mode 100644 internal/canvas/canvas_test.go create mode 100644 internal/canvas/upload.go diff --git a/internal/canvas/apply.go b/internal/canvas/apply.go new file mode 100644 index 0000000..de53a25 --- /dev/null +++ b/internal/canvas/apply.go @@ -0,0 +1,203 @@ +package canvas + +import ( + "context" + "encoding/json" + "fmt" + "regexp" + "strings" + + "github.com/Pippit-dev/pippit-cli/internal/common" +) + +var decimalIDPattern = regexp.MustCompile(`^[1-9][0-9]*$`) + +type ApplyOptions struct { + ProjectID string + Request ApplyRequest +} + +type ApplyRequest struct { + BatchID string `json:"batch_id"` + ClientID string `json:"client_id"` + RootPippitAssetID string `json:"root_pippit_asset_id,omitempty"` + Transactions []PatchTransaction `json:"transactions"` + Base map[string]any `json:"Base"` +} + +type PatchTransaction struct { + TransactionID string `json:"transaction_id"` + Intent string `json:"intent,omitempty"` + MergeKey string `json:"merge_key,omitempty"` + Attempt *int32 `json:"attempt,omitempty"` + EnqueuedAt *int64 `json:"enqueued_at,omitempty"` + Patches []PatchEntry `json:"patches"` +} + +type PatchEntry struct { + AssetID string `json:"asset_id"` + BaseAssetVersion *int64 `json:"base_asset_version,omitempty"` + AssetSourceType *int32 `json:"asset_source_type,omitempty"` + Op string `json:"op"` + Path string `json:"path"` + Value json.RawMessage `json:"value,omitempty"` +} + +type ApplyResult struct { + BatchID string `json:"batch_id"` + Results []PatchTransactionResult `json:"results"` + LogID string `json:"log_id,omitempty"` +} + +type PatchTransactionResult struct { + TransactionID string `json:"transaction_id"` + Status string `json:"status"` + AssetVersions map[string]int64 `json:"asset_versions,omitempty"` + BlockedByTransaction string `json:"blocked_by,omitempty"` + Error string `json:"error,omitempty"` +} + +type applyData struct { + Results []PatchTransactionResult `json:"results"` +} + +func Apply(ctx context.Context, opts ApplyOptions, runner *common.Runner) (*ApplyResult, error) { + client, err := runnerClient(runner, "canvas apply") + if err != nil { + return nil, err + } + projectID := strings.TrimSpace(opts.ProjectID) + if projectID != "" && !decimalIDPattern.MatchString(projectID) { + return nil, fmt.Errorf("canvas apply project_id must be a positive decimal JSON string") + } + request := opts.Request + request.Base = base() + if err := validateApplyRequest(&request); err != nil { + return nil, err + } + + var envelope responseEnvelope + if err := client.SendRequest(ctx, pathWithProjectID(ApplyPath, projectID), request, &envelope); err != nil { + return nil, fmt.Errorf("canvas apply request failed; outcome may be ambiguous, do not replay blindly: %w", err) + } + if err := envelope.validate("canvas apply"); err != nil { + return nil, err + } + var data applyData + if err := json.Unmarshal(envelope.Data, &data); err != nil { + return nil, common.NewLogIDError( + fmt.Sprintf("canvas apply returned invalid data: %v; query affected assets before retrying because outcome cannot be confirmed", err), + envelope.LogID, + ) + } + ordered, err := validateApplyResults(request.Transactions, data.Results) + if err != nil { + return nil, common.NewLogIDError( + fmt.Sprintf("%s; query affected assets before retrying because outcome cannot be confirmed", err), + envelope.LogID, + ) + } + return &ApplyResult{ + BatchID: request.BatchID, + Results: ordered, + LogID: strings.TrimSpace(envelope.LogID), + }, nil +} + +func validateApplyRequest(request *ApplyRequest) error { + if request == nil { + return fmt.Errorf("canvas apply request is required") + } + request.BatchID = strings.TrimSpace(request.BatchID) + request.ClientID = strings.TrimSpace(request.ClientID) + request.RootPippitAssetID = strings.TrimSpace(request.RootPippitAssetID) + if request.BatchID == "" { + return fmt.Errorf("canvas apply batch_id is required") + } + if request.ClientID == "" { + return fmt.Errorf("canvas apply client_id is required") + } + if len(request.Transactions) != 1 { + return fmt.Errorf("canvas apply requires exactly one transaction in this beta; put related patches in that transaction") + } + transactionIDs := make(map[string]struct{}, len(request.Transactions)) + for txIndex := range request.Transactions { + tx := &request.Transactions[txIndex] + tx.TransactionID = strings.TrimSpace(tx.TransactionID) + if tx.TransactionID == "" { + return fmt.Errorf("canvas apply transactions[%d].transaction_id is required", txIndex) + } + if _, duplicate := transactionIDs[tx.TransactionID]; duplicate { + return fmt.Errorf("canvas apply transaction_id %q is duplicated", tx.TransactionID) + } + transactionIDs[tx.TransactionID] = struct{}{} + if len(tx.Patches) == 0 { + return fmt.Errorf("canvas apply transaction %q has no patches", tx.TransactionID) + } + for patchIndex := range tx.Patches { + patch := &tx.Patches[patchIndex] + patch.AssetID = strings.TrimSpace(patch.AssetID) + patch.Op = strings.ToLower(strings.TrimSpace(patch.Op)) + if patch.AssetID == "" { + return fmt.Errorf("canvas apply transaction %q patch[%d].asset_id is required", tx.TransactionID, patchIndex) + } + switch patch.Op { + case "add", "replace": + if len(patch.Value) == 0 || !json.Valid(patch.Value) { + return fmt.Errorf("canvas apply transaction %q patch[%d].value must be valid JSON", tx.TransactionID, patchIndex) + } + case "remove": + if len(patch.Value) != 0 && !json.Valid(patch.Value) { + return fmt.Errorf("canvas apply transaction %q patch[%d].value must be valid JSON", tx.TransactionID, patchIndex) + } + default: + return fmt.Errorf("canvas apply transaction %q patch[%d].op must be add, replace, or remove", tx.TransactionID, patchIndex) + } + if patch.BaseAssetVersion != nil && *patch.BaseAssetVersion < 0 { + return fmt.Errorf("canvas apply transaction %q patch[%d].base_asset_version must not be negative", tx.TransactionID, patchIndex) + } + } + } + return nil +} + +func validateApplyResults(expected []PatchTransaction, actual []PatchTransactionResult) ([]PatchTransactionResult, error) { + byID := make(map[string]PatchTransactionResult, len(actual)) + for _, result := range actual { + result.TransactionID = strings.TrimSpace(result.TransactionID) + if result.TransactionID == "" { + return nil, fmt.Errorf("canvas apply returned a result without transaction_id") + } + if _, duplicate := byID[result.TransactionID]; duplicate { + return nil, fmt.Errorf("canvas apply returned duplicate result for transaction %q", result.TransactionID) + } + byID[result.TransactionID] = result + } + if len(byID) != len(expected) { + return nil, fmt.Errorf("canvas apply returned %d transaction results, want %d", len(byID), len(expected)) + } + ordered := make([]PatchTransactionResult, 0, len(expected)) + for _, transaction := range expected { + result, ok := byID[transaction.TransactionID] + if !ok { + return nil, fmt.Errorf("canvas apply omitted transaction result %q", transaction.TransactionID) + } + if strings.ToLower(strings.TrimSpace(result.Status)) != "ack" { + return nil, fmt.Errorf( + "canvas apply transaction %q was not acknowledged: status=%q blocked_by=%q error=%q", + transaction.TransactionID, result.Status, result.BlockedByTransaction, result.Error, + ) + } + for _, patch := range transaction.Patches { + version, ok := result.AssetVersions[patch.AssetID] + if !ok { + return nil, fmt.Errorf("canvas apply transaction %q omitted version for asset %q", transaction.TransactionID, patch.AssetID) + } + if version < 0 { + return nil, fmt.Errorf("canvas apply transaction %q returned negative version for asset %q", transaction.TransactionID, patch.AssetID) + } + } + ordered = append(ordered, result) + } + return ordered, nil +} diff --git a/internal/canvas/canvas_test.go b/internal/canvas/canvas_test.go new file mode 100644 index 0000000..60c3270 --- /dev/null +++ b/internal/canvas/canvas_test.go @@ -0,0 +1,446 @@ +package canvas + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Pippit-dev/pippit-cli/internal/common" +) + +type fakeClient struct { + send func(context.Context, string, any, any) error + multipart func(context.Context, string, map[string]string, common.MultipartFile, any) error +} + +func (f *fakeClient) SendRequest(ctx context.Context, path string, body any, out any) error { + if f.send == nil { + return fmt.Errorf("unexpected request to %s", path) + } + return f.send(ctx, path, body, out) +} + +func (f *fakeClient) SendRequestWithHeaders(ctx context.Context, path string, body any, _ map[string]string, out any) error { + return f.SendRequest(ctx, path, body, out) +} + +func (f *fakeClient) SendMultipartRequest( + ctx context.Context, + path string, + fields map[string]string, + file common.MultipartFile, + out any, +) error { + if f.multipart == nil { + return fmt.Errorf("unexpected multipart request to %s", path) + } + return f.multipart(ctx, path, fields, file, out) +} + +func runnerWithClient(client common.Client) *common.Runner { + return &common.Runner{Client: client} +} + +func decodeInto(out any, payload string) error { + return json.Unmarshal([]byte(payload), out) +} + +func requestJSON(t *testing.T, body any) map[string]any { + t.Helper() + payload, err := json.Marshal(body) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + var result map[string]any + if err := json.Unmarshal(payload, &result); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + return result +} + +func TestCreateReturnsRecoverableOperationIDs(t *testing.T) { + client := &fakeClient{send: func(_ context.Context, path string, body any, out any) error { + if path != CreatePath { + t.Fatalf("path = %q, want %q", path, CreatePath) + } + request := requestJSON(t, body) + if request["surface"] != SurfaceNovel || request["request_id"] != "request-1" { + t.Fatalf("request = %#v, want novel request", request) + } + if baseValue, ok := request["Base"].(map[string]any); !ok || len(baseValue) != 0 { + t.Fatalf("Base = %#v, want empty object", request["Base"]) + } + if _, exists := request["team_id"]; exists { + t.Fatalf("request = %#v, must not contain team context", request) + } + return decodeInto(out, `{"ret":"0","log_id":"log-create","data":{"state":"creating","project_id":"100","thread_id":"thread-1","run_id":"run-1","canvas_asset_id":"200","web_url":"https://xyq.jianying.com/novel/detail/canvas?projectId=100&canvasId=200"}}`) + }} + + result, err := Create(context.Background(), CreateOptions{Title: "A", RequestID: "request-1"}, runnerWithClient(client)) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + if result.RequestID != "request-1" || result.State != StateCreating || result.ProjectID != "100" || result.CanvasAssetID != "200" { + t.Fatalf("Create() = %#v, want recoverable creating IDs", result) + } +} + +func TestCreateWaitWithInlineOverviewStillRequiresQueryableRoot(t *testing.T) { + queryCalls := 0 + client := &fakeClient{send: func(_ context.Context, path string, _ any, out any) error { + switch path { + case CreatePath: + return decodeInto(out, `{"ret":"0","data":{"state":"creating","project_id":"100","thread_id":"thread-1","run_id":"run-1","canvas_asset_id":"200","overview_pippit_asset_id":"300","web_url":"https://xyq.jianying.com/novel/detail/canvas?projectId=100&canvasId=200"}}`) + case QueryPath: + queryCalls++ + return decodeInto(out, `{"ret":"0","data":{"Assets":[]}}`) + default: + return fmt.Errorf("unexpected path %s", path) + } + }} + result, err := Create(context.Background(), CreateOptions{RequestID: "request-1", Wait: true}, runnerWithClient(client)) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + if queryCalls != 1 || result.State != StateCreating || !strings.Contains(result.Warning, "root asset is not queryable yet") { + t.Fatalf("Create() = %#v, query calls=%d, want visibility-gated ready", result, queryCalls) + } +} + +func TestCreateTransportFailureExplainsAmbiguousOutcome(t *testing.T) { + client := &fakeClient{send: func(context.Context, string, any, any) error { + return errors.New("timeout") + }} + _, err := Create(context.Background(), CreateOptions{RequestID: "request-1"}, runnerWithClient(client)) + if err == nil || !strings.Contains(err.Error(), "outcome may be ambiguous, do not retry blindly") { + t.Fatalf("Create() error = %v, want ambiguous outcome guidance", err) + } +} + +func TestCreateWaitsForMachineArtifactWithoutV2(t *testing.T) { + getThreadCalls := 0 + client := &fakeClient{send: func(_ context.Context, path string, body any, out any) error { + switch path { + case CreatePath: + return decodeInto(out, `{"ret":"0","data":{"state":"creating","project_id":"100","thread_id":"thread-1","run_id":"run-1","canvas_asset_id":"200","web_url":"https://xyq.jianying.com/novel/detail/canvas?projectId=100&canvasId=200"}}`) + case "/api/biz/v1/skill/get_thread": + getThreadCalls++ + request := requestJSON(t, body) + if _, exists := request["version"]; exists { + t.Fatalf("get_thread request = %#v, must not request v2 readable text", request) + } + return decodeInto(out, `{"ret":"0","data":{"thread":{"run_list":[{"run_id":"run-1","state":3,"content":[{"sub_type":"biz/x_data_novel_script_overview","data":"{\"pippit_asset_id\":\"300\",\"canvas_asset_id\":\"200\"}"}] }]}}}`) + case QueryPath: + return decodeInto(out, `{"ret":"0","data":{"Assets":[{"PippitAssetID":"200"}]}}`) + default: + return fmt.Errorf("unexpected path %s", path) + } + }} + + result, err := Create(context.Background(), CreateOptions{ + RequestID: "request-1", + Wait: true, + PollInterval: time.Millisecond, + WaitTimeout: time.Second, + }, runnerWithClient(client)) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + if getThreadCalls != 1 || result.State != StateReady || result.OverviewPippitAssetID != "300" { + t.Fatalf("Create() = %#v, calls=%d, want ready artifact", result, getThreadCalls) + } + if strings.Contains(result.WebURL, "canvasId=") || !strings.Contains(result.WebURL, "overviewPippitAssetId=300") { + t.Fatalf("WebURL = %q, want canonical overview URL", result.WebURL) + } +} + +func TestCreateKeepsCreatingWhenRootAssetIsNotQueryable(t *testing.T) { + client := &fakeClient{send: func(_ context.Context, path string, _ any, out any) error { + switch path { + case CreatePath: + return decodeInto(out, `{"ret":"0","data":{"state":"creating","project_id":"100","thread_id":"thread-1","run_id":"run-1","canvas_asset_id":"200","web_url":"https://xyq.jianying.com/novel/detail/canvas?projectId=100&canvasId=200"}}`) + case "/api/biz/v1/skill/get_thread": + return decodeInto(out, `{"ret":"0","data":{"thread":{"run_list":[{"run_id":"run-1","state":3,"content":[{"sub_type":"biz/x_data_novel_script_overview","data":"{\"pippit_asset_id\":\"300\",\"canvas_asset_id\":\"200\"}"}]}]}}}`) + case QueryPath: + return decodeInto(out, `{"ret":"0","log_id":"query-log","data":{"Assets":[]}}`) + default: + return fmt.Errorf("unexpected path %s", path) + } + }} + result, err := Create(context.Background(), CreateOptions{ + RequestID: "request-1", Wait: true, PollInterval: time.Millisecond, WaitTimeout: time.Second, + }, runnerWithClient(client)) + if err != nil { + t.Fatalf("Create() error = %v, want accepted processing result", err) + } + if result.State != StateCreating || result.ProjectID != "100" || result.OverviewPippitAssetID != "300" || !strings.Contains(result.Warning, "root asset is not queryable yet") { + t.Fatalf("Create() = %#v, want creating state with query warning", result) + } + if strings.Contains(result.WebURL, "canvasId=") || !strings.Contains(result.WebURL, "overviewPippitAssetId=300") { + t.Fatalf("WebURL = %q, want preserved canonical overview URL", result.WebURL) + } +} + +func TestCreateWaitTimeoutPreservesAcceptedIDs(t *testing.T) { + client := &fakeClient{send: func(_ context.Context, path string, _ any, out any) error { + switch path { + case CreatePath: + return decodeInto(out, `{"ret":"0","data":{"state":"creating","project_id":"100","thread_id":"thread-1","run_id":"run-1","canvas_asset_id":"200","web_url":"https://xyq.jianying.com/novel/detail/canvas?projectId=100&canvasId=200"}}`) + case "/api/biz/v1/skill/get_thread": + return decodeInto(out, `{"ret":"0","data":{"thread":{"run_list":[{"run_id":"run-1","state":1}]}}}`) + default: + return fmt.Errorf("unexpected path %s", path) + } + }} + + result, err := Create(context.Background(), CreateOptions{ + RequestID: "request-1", + Wait: true, + PollInterval: 50 * time.Millisecond, + WaitTimeout: time.Millisecond, + }, runnerWithClient(client)) + if err != nil { + t.Fatalf("Create() error = %v, want accepted result with warning", err) + } + if result == nil || result.ProjectID != "100" || result.ThreadID != "thread-1" || result.RunID != "run-1" || result.CanvasAssetID != "200" { + t.Fatalf("Create() = %#v, want all accepted IDs", result) + } + if result.State != StateCreating || !strings.Contains(result.Warning, "was not ready") { + t.Fatalf("Create() = %#v, want creating state with timeout warning", result) + } +} + +func TestCreateTerminalFailureReturnsAcceptedIDsAndError(t *testing.T) { + client := &fakeClient{send: func(_ context.Context, path string, _ any, out any) error { + if path == CreatePath { + return decodeInto(out, `{"ret":"0","data":{"state":"creating","project_id":"100","thread_id":"thread-1","run_id":"run-1","canvas_asset_id":"200","web_url":"https://xyq.jianying.com/novel/detail/canvas?projectId=100&canvasId=200"}}`) + } + return decodeInto(out, `{"ret":"0","data":{"thread":{"run_list":[{"run_id":"run-1","state":4}]}}}`) + }} + result, err := Create(context.Background(), CreateOptions{ + RequestID: "request-1", Wait: true, PollInterval: time.Millisecond, WaitTimeout: time.Second, + }, runnerWithClient(client)) + if result == nil || result.ProjectID != "100" || result.State != "failed" { + t.Fatalf("Create() result = %#v, want failed result with accepted IDs", result) + } + if err == nil || !strings.Contains(err.Error(), "accepted canvas IDs") || !strings.Contains(err.Error(), "do not create again blindly") { + t.Fatalf("Create() error = %v, want accepted ID guidance", err) + } +} + +func TestGetRequiresEveryRequestedStringID(t *testing.T) { + client := &fakeClient{send: func(_ context.Context, path string, body any, out any) error { + if path != QueryPath { + t.Fatalf("path = %q, want query", path) + } + return decodeInto(out, `{"ret":"0","log_id":"log-query","data":{"Assets":[{"PippitAssetID":"2"},{"PippitAssetID":"1"}]}}`) + }} + result, err := Get(context.Background(), GetOptions{AssetIDs: []string{"1", "2"}}, runnerWithClient(client)) + if err != nil { + t.Fatalf("Get() error = %v", err) + } + firstID, _ := assetIDFromRaw(result.Assets[0]) + if firstID != "1" || result.LogID != "log-query" { + t.Fatalf("Get() = %#v, want request order and log ID", result) + } + + missingClient := &fakeClient{send: func(_ context.Context, _ string, _ any, out any) error { + return decodeInto(out, `{"ret":"0","log_id":"missing-log","data":{"Assets":[]}}`) + }} + _, err = Get(context.Background(), GetOptions{AssetIDs: []string{"1"}}, runnerWithClient(missingClient)) + if err == nil || !strings.Contains(err.Error(), "did not return requested assets: 1") || !strings.Contains(err.Error(), "missing-log") { + t.Fatalf("Get() error = %v, want strict missing asset error", err) + } +} + +func TestGetRejectsNumericAssetIDInResponse(t *testing.T) { + client := &fakeClient{send: func(_ context.Context, _ string, _ any, out any) error { + return decodeInto(out, `{"ret":"0","data":{"Assets":[{"PippitAssetID":123}]}}`) + }} + _, err := Get(context.Background(), GetOptions{AssetIDs: []string{"123"}}, runnerWithClient(client)) + if err == nil || !strings.Contains(err.Error(), "must be a JSON string") { + t.Fatalf("Get() error = %v, want string ID validation", err) + } +} + +func TestAllocateRequiresExactUniqueStringIDs(t *testing.T) { + client := &fakeClient{send: func(_ context.Context, path string, body any, out any) error { + if path != AllocatePath { + t.Fatalf("path = %q, want allocate", path) + } + request := requestJSON(t, body) + if request["count"] != float64(2) { + t.Fatalf("count = %#v, want 2", request["count"]) + } + return decodeInto(out, `{"ret":"0","data":{"ids":["10","11"]}}`) + }} + result, err := Allocate(context.Background(), 2, runnerWithClient(client)) + if err != nil || strings.Join(result.AssetIDs, ",") != "10,11" { + t.Fatalf("Allocate() = (%#v, %v), want two IDs", result, err) + } + + duplicate := &fakeClient{send: func(_ context.Context, _ string, _ any, out any) error { + return decodeInto(out, `{"ret":"0","data":{"ids":["10","10"]}}`) + }} + _, err = Allocate(context.Background(), 2, runnerWithClient(duplicate)) + if err == nil || !strings.Contains(err.Error(), "duplicate id") { + t.Fatalf("Allocate() error = %v, want duplicate rejection", err) + } +} + +func TestApplyRequiresAcknowledgementAndEveryAssetVersion(t *testing.T) { + version := int64(0) + request := ApplyRequest{ + BatchID: "batch-1", + ClientID: "client-1", + Transactions: []PatchTransaction{{ + TransactionID: "tx-1", + Patches: []PatchEntry{{AssetID: "asset-1", BaseAssetVersion: &version, Op: "add", Path: "", Value: json.RawMessage(`{"nodes":[]}`)}}, + }}, + } + client := &fakeClient{send: func(_ context.Context, path string, body any, out any) error { + if path != ApplyPath+"?project_id=123" { + t.Fatalf("path = %q, want project query", path) + } + payload := requestJSON(t, body) + if baseValue, ok := payload["Base"].(map[string]any); !ok || len(baseValue) != 0 { + t.Fatalf("Base = %#v, want empty object", payload["Base"]) + } + return decodeInto(out, `{"ret":"0","log_id":"log-apply","data":{"results":[{"transaction_id":"tx-1","status":"ack","asset_versions":{"asset-1":1}}]}}`) + }} + result, err := Apply(context.Background(), ApplyOptions{ProjectID: "123", Request: request}, runnerWithClient(client)) + if err != nil || result.Results[0].AssetVersions["asset-1"] != 1 { + t.Fatalf("Apply() = (%#v, %v), want ack", result, err) + } + + rejected := &fakeClient{send: func(_ context.Context, _ string, _ any, out any) error { + return decodeInto(out, `{"ret":"0","data":{"results":[{"transaction_id":"tx-1","status":"blocked","blocked_by":"tx-0"}]}}`) + }} + _, err = Apply(context.Background(), ApplyOptions{Request: request}, runnerWithClient(rejected)) + if err == nil || !strings.Contains(err.Error(), "was not acknowledged") { + t.Fatalf("Apply() error = %v, want status rejection", err) + } + + missingVersion := &fakeClient{send: func(_ context.Context, _ string, _ any, out any) error { + return decodeInto(out, `{"ret":"0","data":{"results":[{"transaction_id":"tx-1","status":"ack","asset_versions":{}}]}}`) + }} + _, err = Apply(context.Background(), ApplyOptions{Request: request}, runnerWithClient(missingVersion)) + if err == nil || !strings.Contains(err.Error(), "omitted version") { + t.Fatalf("Apply() error = %v, want missing version rejection", err) + } +} + +func TestApplyBetaRejectsMultipleTransactionsBeforeRequest(t *testing.T) { + requestCalls := 0 + client := &fakeClient{send: func(context.Context, string, any, any) error { + requestCalls++ + return nil + }} + request := ApplyRequest{ + BatchID: "batch-1", ClientID: "client-1", + Transactions: []PatchTransaction{ + {TransactionID: "tx-1", Patches: []PatchEntry{{AssetID: "asset-1", Op: "add", Path: "", Value: json.RawMessage(`{}`)}}}, + {TransactionID: "tx-2", Patches: []PatchEntry{{AssetID: "asset-2", Op: "add", Path: "", Value: json.RawMessage(`{}`)}}}, + }, + } + _, err := Apply(context.Background(), ApplyOptions{Request: request}, runnerWithClient(client)) + if err == nil || !strings.Contains(err.Error(), "exactly one transaction") { + t.Fatalf("Apply() error = %v, want single-transaction beta guard", err) + } + if requestCalls != 0 { + t.Fatalf("request calls = %d, want validation before side effects", requestCalls) + } +} + +func TestApplyTransportFailureWarnsAgainstBlindReplay(t *testing.T) { + request := ApplyRequest{ + BatchID: "batch-1", ClientID: "client-1", + Transactions: []PatchTransaction{{TransactionID: "tx-1", Patches: []PatchEntry{{ + AssetID: "asset-1", Op: "replace", Path: "", Value: json.RawMessage(`{}`), + }}}}, + } + client := &fakeClient{send: func(context.Context, string, any, any) error { return errors.New("timeout") }} + _, err := Apply(context.Background(), ApplyOptions{Request: request}, runnerWithClient(client)) + if err == nil || !strings.Contains(err.Error(), "do not replay blindly") { + t.Fatalf("Apply() error = %v, want replay warning", err) + } +} + +func TestUploadWaitsForQueryableAssetWithoutRequiringCover(t *testing.T) { + path := filepath.Join(t.TempDir(), "clip.mp4") + if err := os.WriteFile(path, []byte("video"), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + client := &fakeClient{ + multipart: func(_ context.Context, gotPath string, fields map[string]string, file common.MultipartFile, out any) error { + if gotPath != UploadPath || len(fields) != 0 { + t.Fatalf("multipart = (%q, %#v), want upload without auth fields", gotPath, fields) + } + if file.FieldName != "file" || file.ContentType != "video/mp4" { + t.Fatalf("file = %#v, want video multipart", file) + } + return decodeInto(out, `{"ret":"0","log_id":"upload-log","data":{"asset_id":"workspace-1","pippit_asset_id":"asset-1"}}`) + }, + send: func(_ context.Context, path string, _ any, out any) error { + if path != QueryPath { + t.Fatalf("path = %q, want query", path) + } + return decodeInto(out, `{"ret":"0","log_id":"query-log","data":{"Assets":[{"PippitAssetID":"asset-1","SourceID":"workspace-1","Video":{"DownloadUrl":"https://example.test/clip.mp4","VID":"vid-1"}}]}}`) + }, + } + result, err := Upload(context.Background(), UploadOptions{ + Path: path, PollInterval: time.Millisecond, WaitTimeout: time.Second, + }, runnerWithClient(client)) + if err != nil { + t.Fatalf("Upload() error = %v", err) + } + if result.State != StateReady || result.AssetID != "workspace-1" || result.PippitAssetID != "asset-1" || result.PollAttempts != 1 { + t.Fatalf("Upload() = %#v, want ready identifiers", result) + } + if result.Locator.DownloadURL == "" || result.Locator.CoverURL != "" { + t.Fatalf("Locator = %#v, want playable locator without cover requirement", result.Locator) + } +} + +func TestUploadWaitTimeoutPreservesDurableAssetID(t *testing.T) { + path := filepath.Join(t.TempDir(), "clip.mp4") + if err := os.WriteFile(path, []byte("video"), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + client := &fakeClient{ + multipart: func(_ context.Context, _ string, _ map[string]string, _ common.MultipartFile, out any) error { + return decodeInto(out, `{"ret":"0","log_id":"upload-log","data":{"asset_id":"workspace-1","pippit_asset_id":"asset-1"}}`) + }, + send: func(_ context.Context, _ string, _ any, out any) error { + return decodeInto(out, `{"ret":"0","data":{"Assets":[]}}`) + }, + } + result, err := Upload(context.Background(), UploadOptions{ + Path: path, PollInterval: 50 * time.Millisecond, WaitTimeout: time.Millisecond, + }, runnerWithClient(client)) + if err != nil { + t.Fatalf("Upload() error = %v, want accepted processing result", err) + } + if result.State != StateProcessing || result.PippitAssetID != "asset-1" || result.Locator.PippitAssetID != "asset-1" { + t.Fatalf("Upload() = %#v, want durable ID after wait timeout", result) + } + if !strings.Contains(result.Warning, "was not queryable") { + t.Fatalf("warning = %q, want visibility warning", result.Warning) + } +} + +func TestEnvelopeRetIsStrict(t *testing.T) { + client := &fakeClient{send: func(_ context.Context, _ string, _ any, out any) error { + return decodeInto(out, `{"errmsg":"missing ret","log_id":"strict-log","data":{"Assets":[]}}`) + }} + _, err := Get(context.Background(), GetOptions{AssetIDs: []string{"1"}}, runnerWithClient(client)) + if err == nil || !strings.Contains(err.Error(), `ret=""`) || !strings.Contains(err.Error(), "strict-log") { + t.Fatalf("Get() error = %v, want strict ret validation", err) + } +} diff --git a/internal/canvas/upload.go b/internal/canvas/upload.go new file mode 100644 index 0000000..482f271 --- /dev/null +++ b/internal/canvas/upload.go @@ -0,0 +1,250 @@ +package canvas + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "mime" + "os" + "path/filepath" + "strings" + "time" + + "github.com/Pippit-dev/pippit-cli/internal/common" +) + +const defaultUploadPollInterval = time.Second + +var uploadContentTypeFallbacks = map[string]string{ + ".jpeg": "image/jpeg", + ".jpg": "image/jpeg", + ".png": "image/png", + ".webp": "image/webp", + ".mp3": "audio/mpeg", + ".wav": "audio/wav", + ".mp4": "video/mp4", + ".mov": "video/quicktime", +} + +type UploadOptions struct { + Path string + PollInterval time.Duration + WaitTimeout time.Duration +} + +// AssetLocator reports the durable Pippit ID and optional observed media +// locations. URLs may be signed or short lived and must not be persisted into +// Canvas patches; callers should persist PippitAssetID instead. +type AssetLocator struct { + PippitAssetID string `json:"pippit_asset_id"` + SourceID string `json:"source_id,omitempty"` + DownloadURL string `json:"download_url,omitempty"` + InternalURL string `json:"internal_url,omitempty"` + CoverURL string `json:"cover_url,omitempty"` + VID string `json:"vid,omitempty"` +} + +type UploadResult struct { + State string `json:"state"` + AssetID string `json:"asset_id,omitempty"` + PippitAssetID string `json:"pippit_asset_id"` + Locator AssetLocator `json:"locator"` + Asset json.RawMessage `json:"asset,omitempty"` + LogID string `json:"log_id,omitempty"` + QueryLogID string `json:"query_log_id,omitempty"` + PollAttempts int `json:"poll_attempts"` + Warning string `json:"warning,omitempty"` +} + +func Upload(ctx context.Context, opts UploadOptions, runner *common.Runner) (*UploadResult, error) { + client, err := runnerClient(runner, "canvas upload") + if err != nil { + return nil, err + } + if opts.PollInterval < 0 || opts.WaitTimeout < 0 { + return nil, fmt.Errorf("canvas upload polling durations must not be negative") + } + path := strings.TrimSpace(opts.Path) + if path == "" { + return nil, fmt.Errorf("canvas upload path is required") + } + info, err := os.Stat(path) + if err != nil { + return nil, fmt.Errorf("inspect canvas upload file: %w", err) + } + if !info.Mode().IsRegular() { + return nil, fmt.Errorf("canvas upload path %q is not a regular file", path) + } + fileName := filepath.Base(path) + extension := strings.ToLower(filepath.Ext(fileName)) + contentType := mime.TypeByExtension(extension) + if contentType == "" { + contentType = uploadContentTypeFallbacks[extension] + } + if contentType == "" { + contentType = "application/octet-stream" + } + + var envelope responseEnvelope + if err := client.SendMultipartRequest(ctx, UploadPath, nil, common.MultipartFile{ + FieldName: "file", + Path: path, + FileName: fileName, + ContentType: contentType, + }, &envelope); err != nil { + return nil, fmt.Errorf("canvas upload request failed; outcome may be ambiguous, check assets before retrying: %w", err) + } + if err := envelope.validate("canvas upload"); err != nil { + return nil, err + } + assetID, pippitAssetID, err := parseUploadData(envelope.Data) + if err != nil { + return nil, common.NewLogIDError(fmt.Sprintf("canvas upload returned invalid data: %v", err), envelope.LogID) + } + result := &UploadResult{ + State: StateProcessing, + AssetID: assetID, + PippitAssetID: pippitAssetID, + Locator: AssetLocator{PippitAssetID: pippitAssetID}, + LogID: strings.TrimSpace(envelope.LogID), + } + + asset, queryLogID, attempts, err := waitForUploadedAsset(ctx, pippitAssetID, opts.PollInterval, opts.WaitTimeout, runner) + result.PollAttempts = attempts + if err != nil { + // The upload response already returned a durable Pippit asset ID. Keep + // it machine-readable so callers can resume querying without uploading + // the same bytes again. + result.Warning = err.Error() + return result, nil + } + result.State = StateReady + result.Locator = locatorFromAsset(asset, pippitAssetID) + result.Asset = asset + result.QueryLogID = queryLogID + return result, nil +} + +func parseUploadData(raw json.RawMessage) (string, string, error) { + var data map[string]json.RawMessage + if err := json.Unmarshal(raw, &data); err != nil { + return "", "", err + } + assetID, err := optionalStringField(data, "asset_id", "AssetId", "assetId") + if err != nil { + return "", "", err + } + pippitAssetID, err := optionalStringField(data, "pippit_asset_id", "PippitAssetID", "pippitAssetId") + if err != nil { + return "", "", err + } + if pippitAssetID == "" { + return "", "", fmt.Errorf("data.pippit_asset_id is missing") + } + return assetID, pippitAssetID, nil +} + +func optionalStringField(values map[string]json.RawMessage, keys ...string) (string, error) { + raw, ok := rawField(values, keys...) + if !ok || string(raw) == "null" { + return "", nil + } + var value string + if err := json.Unmarshal(raw, &value); err != nil { + return "", fmt.Errorf("%s must be a JSON string", keys[0]) + } + return strings.TrimSpace(value), nil +} + +func waitForUploadedAsset( + ctx context.Context, + assetID string, + pollInterval, waitTimeout time.Duration, + runner *common.Runner, +) (json.RawMessage, string, int, error) { + if pollInterval <= 0 { + pollInterval = defaultUploadPollInterval + } + if waitTimeout <= 0 { + waitTimeout = 2 * time.Minute + } + waitCtx, cancel := context.WithTimeout(ctx, waitTimeout) + defer cancel() + for attempts := 1; ; attempts++ { + result, err := queryAssets(waitCtx, []string{assetID}, false, runner) + if err != nil { + if waitCtx.Err() != nil { + return nil, "", attempts, uploadWaitError(waitCtx.Err(), assetID, waitTimeout) + } + return nil, "", attempts, fmt.Errorf("query uploaded canvas asset: %w", err) + } + if len(result.Assets) == 1 { + return result.Assets[0], result.LogID, attempts, nil + } + + timer := time.NewTimer(pollInterval) + select { + case <-waitCtx.Done(): + timer.Stop() + return nil, result.LogID, attempts, uploadWaitError(waitCtx.Err(), assetID, waitTimeout) + case <-timer.C: + } + } +} + +func uploadWaitError(err error, assetID string, timeout time.Duration) error { + if errors.Is(err, context.DeadlineExceeded) { + return fmt.Errorf("uploaded canvas asset %q was not queryable within %s", assetID, timeout) + } + return fmt.Errorf("wait for uploaded canvas asset %q canceled: %w", assetID, err) +} + +func locatorFromAsset(raw json.RawMessage, pippitAssetID string) AssetLocator { + var value any + _ = json.Unmarshal(raw, &value) + return AssetLocator{ + PippitAssetID: pippitAssetID, + SourceID: findFirstString(value, "SourceID", "source_id", "sourceId"), + DownloadURL: findFirstString(value, "DownloadUrl", "DownloadURL", "download_url", "downloadUrl"), + InternalURL: findFirstString(value, "InternalUrl", "InternalURL", "internal_url", "internalUrl"), + CoverURL: findFirstString(value, "CoverUrl", "CoverURL", "cover_url", "coverUrl"), + VID: findFirstString(value, "VID", "vid"), + } +} + +func findFirstString(value any, keys ...string) string { + keySet := make(map[string]struct{}, len(keys)) + for _, key := range keys { + keySet[key] = struct{}{} + } + return walkFirstString(value, keySet, 0) +} + +func walkFirstString(value any, keys map[string]struct{}, depth int) string { + if depth > 10 { + return "" + } + switch typed := value.(type) { + case map[string]any: + for key, candidate := range typed { + if _, ok := keys[key]; ok { + if text, ok := candidate.(string); ok && strings.TrimSpace(text) != "" { + return strings.TrimSpace(text) + } + } + } + for _, candidate := range typed { + if found := walkFirstString(candidate, keys, depth+1); found != "" { + return found + } + } + case []any: + for _, candidate := range typed { + if found := walkFirstString(candidate, keys, depth+1); found != "" { + return found + } + } + } + return "" +} From 11a1cf472aec2061659d33747c05d70cbf7de04d Mon Sep 17 00:00:00 2001 From: "xuyan.smackgg" Date: Wed, 12 Aug 2026 18:16:02 +0800 Subject: [PATCH 04/14] feat: expose personal canvas commands Co-authored-by: Codex --- cmd/canvas/canvas.go | 216 ++++++++++++++++++++++++++++++++++++++ cmd/canvas/canvas_test.go | 106 +++++++++++++++++++ cmd/root.go | 27 ++++- cmd/root_test.go | 100 ++++++++++++++++++ 4 files changed, 448 insertions(+), 1 deletion(-) create mode 100644 cmd/canvas/canvas.go create mode 100644 cmd/canvas/canvas_test.go create mode 100644 cmd/root_test.go diff --git a/cmd/canvas/canvas.go b/cmd/canvas/canvas.go new file mode 100644 index 0000000..5a86064 --- /dev/null +++ b/cmd/canvas/canvas.go @@ -0,0 +1,216 @@ +package canvas + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + canvascore "github.com/Pippit-dev/pippit-cli/internal/canvas" + "github.com/Pippit-dev/pippit-cli/internal/common" + "github.com/spf13/cobra" +) + +const maxApplyRequestBytes = 64 << 20 + +// NewCommand builds the provider-neutral personal Canvas command tree. +// Register it from cmd/root.go with: +// +// root.AddCommand(canvas.NewCommand(stdout, stderr, runner)) +func NewCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command { + cmd := &cobra.Command{ + Use: "canvas", + Short: "Create and operate personal novel canvases", + Args: cobra.NoArgs, + } + cmd.SetOut(stdout) + cmd.SetErr(stderr) + cmd.AddCommand(newCreateCommand(stdout, stderr, runner)) + cmd.AddCommand(newGetCommand(stdout, stderr, runner)) + cmd.AddCommand(newApplyCommand(stdout, stderr, runner)) + cmd.AddCommand(newUploadCommand(stdout, stderr, runner)) + return cmd +} + +func newCreateCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command { + var opts canvascore.CreateOptions + cmd := &cobra.Command{ + Use: "create", + Short: "Create a personal novel Canvas project", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if strings.TrimSpace(opts.RequestID) == "" { + requestID, err := newRequestID() + if err != nil { + return fmt.Errorf("generate canvas request ID: %w", err) + } + opts.RequestID = requestID + } + result, err := canvascore.Create(cmd.Context(), opts, runner) + if result != nil { + if writeErr := common.WriteJSON(stdout, result); writeErr != nil { + return writeErr + } + } + if err != nil { + logCanvasError("canvas create", err, map[string]string{"request_id": opts.RequestID}) + return err + } + return nil + }, + } + cmd.SetOut(stdout) + cmd.SetErr(stderr) + flags := cmd.Flags() + flags.StringVar(&opts.Title, "title", "", "project title (maximum 50 characters)") + flags.StringVar(&opts.RequestID, "request-id", "", "caller request ID; generated when omitted") + flags.BoolVar(&opts.Wait, "wait", false, "wait for the novel overview artifact") + flags.DurationVar(&opts.PollInterval, "poll-interval", time.Second, "artifact polling interval") + flags.DurationVar(&opts.WaitTimeout, "timeout", 2*time.Minute, "maximum artifact wait time") + return cmd +} + +func newGetCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command { + var assetIDs []string + cmd := &cobra.Command{ + Use: "get", + Short: "Get personal Canvas assets by ID", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + result, err := canvascore.Get(cmd.Context(), canvascore.GetOptions{AssetIDs: assetIDs}, runner) + if err != nil { + logCanvasError("canvas get", err, map[string]string{"asset_count": fmt.Sprint(len(assetIDs))}) + return err + } + return common.WriteJSON(stdout, result) + }, + } + cmd.SetOut(stdout) + cmd.SetErr(stderr) + cmd.Flags().StringArrayVar(&assetIDs, "asset-id", nil, "Pippit asset ID; repeat for multiple assets") + return cmd +} + +func newApplyCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command { + var filePath string + var projectID string + cmd := &cobra.Command{ + Use: "apply", + Short: "Apply one Canvas patch transaction", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + request, err := readApplyRequest(cmd.InOrStdin(), filePath) + if err != nil { + return err + } + result, err := canvascore.Apply(cmd.Context(), canvascore.ApplyOptions{ + ProjectID: projectID, + Request: request, + }, runner) + if err != nil { + logCanvasError("canvas apply", err, map[string]string{ + "batch_id": request.BatchID, + "project_id": projectID, + "transactions": fmt.Sprint(len(request.Transactions)), + }) + return err + } + return common.WriteJSON(stdout, result) + }, + } + cmd.SetOut(stdout) + cmd.SetErr(stderr) + flags := cmd.Flags() + flags.StringVar(&filePath, "file", "-", "BatchPatch JSON request file, or - for stdin") + flags.StringVar(&projectID, "project-id", "", "personal novel project ID as a decimal string") + return cmd +} + +func newUploadCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command { + var opts canvascore.UploadOptions + cmd := &cobra.Command{ + Use: "upload", + Short: "Upload a file to personal assets and wait until it is queryable", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + result, err := canvascore.Upload(cmd.Context(), opts, runner) + if err != nil { + logCanvasError("canvas upload", err, map[string]string{"file_name": filepath.Base(strings.TrimSpace(opts.Path))}) + return err + } + return common.WriteJSON(stdout, result) + }, + } + cmd.SetOut(stdout) + cmd.SetErr(stderr) + flags := cmd.Flags() + flags.StringVar(&opts.Path, "path", "", "local file path to upload") + flags.DurationVar(&opts.PollInterval, "poll-interval", time.Second, "asset visibility polling interval") + flags.DurationVar(&opts.WaitTimeout, "timeout", 2*time.Minute, "maximum asset visibility wait time") + return cmd +} + +func newRequestID() (string, error) { + random := make([]byte, 16) + if _, err := rand.Read(random); err != nil { + return "", err + } + return "pippit_cli_canvas_" + hex.EncodeToString(random), nil +} + +func readApplyRequest(stdin io.Reader, filePath string) (canvascore.ApplyRequest, error) { + filePath = strings.TrimSpace(filePath) + if filePath == "" { + return canvascore.ApplyRequest{}, fmt.Errorf("canvas apply --file must not be empty") + } + var reader io.Reader + var file *os.File + if filePath == "-" { + reader = stdin + } else { + var err error + file, err = os.Open(filePath) + if err != nil { + return canvascore.ApplyRequest{}, fmt.Errorf("open canvas apply request: %w", err) + } + defer file.Close() + reader = file + } + limited := io.LimitReader(reader, maxApplyRequestBytes+1) + payload, err := io.ReadAll(limited) + if err != nil { + return canvascore.ApplyRequest{}, fmt.Errorf("read canvas apply request: %w", err) + } + if len(payload) > maxApplyRequestBytes { + return canvascore.ApplyRequest{}, fmt.Errorf("canvas apply request exceeds %d bytes", maxApplyRequestBytes) + } + decoder := json.NewDecoder(strings.NewReader(string(payload))) + decoder.DisallowUnknownFields() + var request canvascore.ApplyRequest + if err := decoder.Decode(&request); err != nil { + return canvascore.ApplyRequest{}, fmt.Errorf("decode canvas apply request: %w", err) + } + if err := ensureJSONEOF(decoder); err != nil { + return canvascore.ApplyRequest{}, err + } + return request, nil +} + +func ensureJSONEOF(decoder *json.Decoder) error { + var trailing any + if err := decoder.Decode(&trailing); err == io.EOF { + return nil + } else if err != nil { + return fmt.Errorf("decode trailing canvas apply data: %w", err) + } + return fmt.Errorf("canvas apply request must contain exactly one JSON object") +} + +func logCanvasError(command string, err error, fields map[string]string) { + _ = common.AppendDailyErrorLog(command, err, fields) +} diff --git a/cmd/canvas/canvas_test.go b/cmd/canvas/canvas_test.go new file mode 100644 index 0000000..493002b --- /dev/null +++ b/cmd/canvas/canvas_test.go @@ -0,0 +1,106 @@ +package canvas + +import ( + "bytes" + "context" + "encoding/json" + "strings" + "testing" + + "github.com/Pippit-dev/pippit-cli/internal/common" +) + +type commandFakeClient struct { + response string + request map[string]any + handler func(string, any, any) error +} + +func (f *commandFakeClient) SendRequest(_ context.Context, path string, body any, out any) error { + payload, _ := json.Marshal(body) + _ = json.Unmarshal(payload, &f.request) + if f.handler != nil { + return f.handler(path, body, out) + } + return json.Unmarshal([]byte(f.response), out) +} + +func (f *commandFakeClient) SendRequestWithHeaders(ctx context.Context, path string, body any, _ map[string]string, out any) error { + return f.SendRequest(ctx, path, body, out) +} + +func (f *commandFakeClient) SendMultipartRequest(context.Context, string, map[string]string, common.MultipartFile, any) error { + return nil +} + +func TestCommandExposesOnlyProviderNeutralPublicVerbs(t *testing.T) { + cmd := NewCommand(&bytes.Buffer{}, &bytes.Buffer{}, &common.Runner{Client: &commandFakeClient{}}) + got := make([]string, 0, len(cmd.Commands())) + for _, child := range cmd.Commands() { + got = append(got, child.Name()) + } + if strings.Join(got, ",") != "apply,create,get,upload" { + t.Fatalf("commands = %v, want apply/create/get/upload", got) + } + for _, forbidden := range []string{"import", "bind", "team", "allocate"} { + if strings.Contains(strings.ToLower(cmd.CommandPath()+" "+cmd.Short+" "+strings.Join(got, " ")), forbidden) { + t.Fatalf("public command surface contains forbidden verb %q", forbidden) + } + } +} + +func TestCreateCommandPrintsOneMachineReadableJSONLine(t *testing.T) { + client := &commandFakeClient{response: `{"ret":"0","log_id":"log-1","data":{"state":"creating","project_id":"100","thread_id":"thread-1","run_id":"run-1","canvas_asset_id":"200","web_url":"https://xyq.jianying.com/novel/detail/canvas?projectId=100&canvasId=200"}}`} + var stdout, stderr bytes.Buffer + cmd := NewCommand(&stdout, &stderr, &common.Runner{Client: client}) + cmd.SetArgs([]string{"create", "--title", "Demo", "--request-id", "request-1"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if strings.Count(stdout.String(), "\n") != 1 { + t.Fatalf("stdout = %q, want one JSON line", stdout.String()) + } + var result map[string]any + if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &result); err != nil { + t.Fatalf("stdout is not JSON: %v", err) + } + if result["request_id"] != "request-1" || result["project_id"] != "100" || client.request["surface"] != "novel" { + t.Fatalf("result/request = (%#v, %#v), want personal novel create", result, client.request) + } +} + +func TestCreateCommandWaitTimeoutPrintsAcceptedIDs(t *testing.T) { + client := &commandFakeClient{handler: func(path string, _ any, out any) error { + response := `{"ret":"0","data":{"thread":{"run_list":[{"run_id":"run-1","state":1}]}}}` + if path == "/api/biz/v1/skill/canvas/create" { + response = `{"ret":"0","data":{"state":"creating","project_id":"100","thread_id":"thread-1","run_id":"run-1","canvas_asset_id":"200","web_url":"https://xyq.jianying.com/novel/detail/canvas?projectId=100&canvasId=200"}}` + } + return json.Unmarshal([]byte(response), out) + }} + var stdout, stderr bytes.Buffer + cmd := NewCommand(&stdout, &stderr, &common.Runner{Client: client}) + cmd.SetArgs([]string{ + "create", "--request-id", "request-1", "--wait", + "--poll-interval", "50ms", "--timeout", "1ms", + }) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v, want accepted create outcome", err) + } + var result map[string]any + if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &result); err != nil { + t.Fatalf("stdout = %q, want JSON: %v", stdout.String(), err) + } + if result["project_id"] != "100" || result["state"] != "creating" || result["warning"] == "" { + t.Fatalf("result = %#v, want accepted IDs and wait warning", result) + } +} + +func TestApplyRequestRejectsTeamOrImportExtensions(t *testing.T) { + for _, field := range []string{"team_id", "provider", "import_source"} { + payload := `{"batch_id":"b","client_id":"c","transactions":[],"` + field + `":"x"}` + _, err := readApplyRequest(strings.NewReader(payload), "-") + if err == nil || !strings.Contains(err.Error(), "unknown field") { + t.Fatalf("readApplyRequest(%s) error = %v, want unknown field rejection", field, err) + } + } +} diff --git a/cmd/root.go b/cmd/root.go index 1f2598c..1c4f654 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -7,6 +7,7 @@ import ( "strings" // authcmd "github.com/Pippit-dev/pippit-cli/cmd/auth" + canvascmd "github.com/Pippit-dev/pippit-cli/cmd/canvas" "github.com/Pippit-dev/pippit-cli/cmd/generate_image" "github.com/Pippit-dev/pippit-cli/cmd/generate_video" "github.com/Pippit-dev/pippit-cli/cmd/short_drama" @@ -25,7 +26,12 @@ func Execute() error { func NewRootCommand(stdout, stderr io.Writer) *cobra.Command { cfg := config.Load() - client := common.NewHTTPClient(cfg.BaseURL, cfg.HTTPTimeout, common.NewAccessKeyAuthorizer(cfg.AccessKey)) + client := common.NewHTTPClientWithPPEEnv( + cfg.BaseURL, + cfg.HTTPTimeout, + common.NewAccessKeyAuthorizer(cfg.AccessKey), + func() string { return cfg.PPEEnv }, + ) runner := common.NewRunner(cfg, client) return newRootCommand(stdout, stderr, runner) } @@ -43,7 +49,9 @@ func newRootCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Comm root.SetVersionTemplate("{{.Version}}\n") root.SetOut(stdout) root.SetErr(stderr) + configurePPEFlag(root, runner.Config) // root.AddCommand(authcmd.NewCommand(stdout, stderr, runner)) // temporarily disabled; auth is via access key injection + root.AddCommand(canvascmd.NewCommand(stdout, stderr, runner)) root.AddCommand(newDownloadResultCommand(stdout, stderr, runner)) root.AddCommand(newGetThreadCommand(stdout, stderr, runner)) root.AddCommand(newListThreadFileCommand(stdout, stderr, runner)) @@ -58,6 +66,23 @@ func newRootCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Comm return root } +func configurePPEFlag(root *cobra.Command, cfg *config.Config) { + root.PersistentFlags().StringVar( + &cfg.PPEEnv, + "ppe-env", + cfg.PPEEnv, + "route Pippit API requests to a PPE environment (for example, ppe_cli_canvas_ak)", + ) + root.PersistentPreRunE = func(_ *cobra.Command, _ []string) error { + ppeEnv, err := config.NormalizePPEEnv(cfg.PPEEnv) + if err != nil { + return err + } + cfg.PPEEnv = ppeEnv + return nil + } +} + func localizeFlagErrors(cmd *cobra.Command) { cmd.SetFlagErrorFunc(func(_ *cobra.Command, err error) error { return localizeFlagError(err) diff --git a/cmd/root_test.go b/cmd/root_test.go new file mode 100644 index 0000000..daf6c04 --- /dev/null +++ b/cmd/root_test.go @@ -0,0 +1,100 @@ +package cmd + +import ( + "bytes" + "strings" + "testing" + + "github.com/Pippit-dev/pippit-cli/internal/common" + "github.com/Pippit-dev/pippit-cli/internal/config" + "github.com/spf13/cobra" +) + +func TestPPEEnvFlagOverridesEnvironment(t *testing.T) { + t.Setenv(config.EnvPPEEnv, "ppe_from_env") + cfg, root, ran := newPPEFlagTestRoot(t) + root.SetArgs([]string{"ppe-probe", "--ppe-env", "ppe_from_flag"}) + + if err := root.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if !*ran { + t.Fatal("probe command did not run") + } + if cfg.PPEEnv != "ppe_from_flag" { + t.Fatalf("PPEEnv = %q, want flag value", cfg.PPEEnv) + } +} + +func TestPPEEnvUsesEnvironmentByDefault(t *testing.T) { + t.Setenv(config.EnvPPEEnv, " ppe_from_env ") + cfg, root, ran := newPPEFlagTestRoot(t) + root.SetArgs([]string{"ppe-probe"}) + + if err := root.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if !*ran { + t.Fatal("probe command did not run") + } + if cfg.PPEEnv != "ppe_from_env" { + t.Fatalf("PPEEnv = %q, want environment value", cfg.PPEEnv) + } +} + +func TestPPEEnvCanBeExplicitlyDisabledByFlag(t *testing.T) { + t.Setenv(config.EnvPPEEnv, "ppe_from_env") + cfg, root, ran := newPPEFlagTestRoot(t) + root.SetArgs([]string{"ppe-probe", "--ppe-env", ""}) + + if err := root.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if !*ran { + t.Fatal("probe command did not run") + } + if cfg.PPEEnv != "" { + t.Fatalf("PPEEnv = %q, want production", cfg.PPEEnv) + } +} + +func TestPPEEnvRejectsInvalidValueBeforeCommand(t *testing.T) { + t.Setenv(config.EnvPPEEnv, "production") + _, root, ran := newPPEFlagTestRoot(t) + root.SetArgs([]string{"ppe-probe"}) + + err := root.Execute() + if err == nil || !strings.Contains(err.Error(), "PPE 环境") { + t.Fatalf("Execute() error = %v, want invalid PPE error", err) + } + if *ran { + t.Fatal("probe command ran with invalid PPE environment") + } +} + +func TestRootHelpIncludesPPEFlag(t *testing.T) { + var stdout, stderr bytes.Buffer + root := NewRootCommand(&stdout, &stderr) + root.SetArgs([]string{"--help"}) + if err := root.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if !strings.Contains(stdout.String(), "--ppe-env") { + t.Fatalf("help does not include --ppe-env:\n%s", stdout.String()) + } +} + +func newPPEFlagTestRoot(t *testing.T) (*config.Config, *cobra.Command, *bool) { + t.Helper() + cfg := config.Load() + runner := common.NewRunner(cfg, nil) + root := newRootCommand(&bytes.Buffer{}, &bytes.Buffer{}, runner) + ran := false + root.AddCommand(&cobra.Command{ + Use: "ppe-probe", + Run: func(_ *cobra.Command, _ []string) { + ran = true + }, + }) + return cfg, root, &ran +} From 8e3529bc3b54017b03b27ab5a4b85107b98d6ca7 Mon Sep 17 00:00:00 2001 From: "xuyan.smackgg" Date: Tue, 11 Aug 2026 22:47:18 +0800 Subject: [PATCH 05/14] feat(auth): support runtime access key updates Co-authored-by: Codex --- cmd/root.go | 18 +++++-- cmd/root_test.go | 31 ++++++++++++ internal/common/access_key.go | 27 ++++++++--- internal/common/access_key_test.go | 77 ++++++++++++++++++++++++++++++ 4 files changed, 143 insertions(+), 10 deletions(-) create mode 100644 internal/common/access_key_test.go diff --git a/cmd/root.go b/cmd/root.go index 1c4f654..47365cb 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -26,14 +26,24 @@ func Execute() error { func NewRootCommand(stdout, stderr io.Writer) *cobra.Command { cfg := config.Load() - client := common.NewHTTPClientWithPPEEnv( + runner := newRootRunner(cfg) + return newRootCommand(stdout, stderr, runner) +} + +func newRootRunner(cfg *config.Config) *common.Runner { + runner := common.NewRunner(cfg, nil) + runner.Client = common.NewHTTPClientWithPPEEnv( cfg.BaseURL, cfg.HTTPTimeout, - common.NewAccessKeyAuthorizer(cfg.AccessKey), + common.NewAccessKeyProviderAuthorizer(func() string { + if runner.Config == nil { + return "" + } + return runner.Config.AccessKey + }), func() string { return cfg.PPEEnv }, ) - runner := common.NewRunner(cfg, client) - return newRootCommand(stdout, stderr, runner) + return runner } func newRootCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command { diff --git a/cmd/root_test.go b/cmd/root_test.go index daf6c04..4ba96bc 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -2,14 +2,45 @@ package cmd import ( "bytes" + "context" + "net/http" + "net/http/httptest" "strings" "testing" + "time" "github.com/Pippit-dev/pippit-cli/internal/common" "github.com/Pippit-dev/pippit-cli/internal/config" "github.com/spf13/cobra" ) +func TestRootRunnerReadsUpdatedAccessKeyForEveryRequest(t *testing.T) { + received := make([]string, 0, 2) + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + received = append(received, request.Header.Get("Authorization")) + writer.Header().Set("Content-Type", "application/json") + _, _ = writer.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + + cfg := config.Load() + cfg.BaseURL = server.URL + cfg.HTTPTimeout = time.Second + cfg.AccessKey = "first-key" + runner := newRootRunner(cfg) + + for _, accessKey := range []string{"first-key", "second-key"} { + runner.Config.AccessKey = accessKey + var response map[string]any + if err := runner.Client.SendRequest(context.Background(), "/probe", map[string]any{}, &response); err != nil { + t.Fatalf("SendRequest(%q) error = %v", accessKey, err) + } + } + if got, want := strings.Join(received, ","), "Bearer first-key,Bearer second-key"; got != want { + t.Fatalf("Authorization headers = %q, want %q", got, want) + } +} + func TestPPEEnvFlagOverridesEnvironment(t *testing.T) { t.Setenv(config.EnvPPEEnv, "ppe_from_env") cfg, root, ran := newPPEFlagTestRoot(t) diff --git a/internal/common/access_key.go b/internal/common/access_key.go index 8b4a337..042693a 100644 --- a/internal/common/access_key.go +++ b/internal/common/access_key.go @@ -10,22 +10,37 @@ import ( ) type accessKeyAuthorizer struct { - accessKey string + accessKey func() string } func NewAccessKeyAuthorizer(accessKey string) RequestAuthorizer { - return &accessKeyAuthorizer{accessKey: strings.TrimSpace(accessKey)} + trimmed := strings.TrimSpace(accessKey) + return NewAccessKeyProviderAuthorizer(func() string { return trimmed }) +} + +// NewAccessKeyProviderAuthorizer resolves the Access Key immediately before +// each request. This lets interactive commands update their in-memory runtime +// configuration without rebuilding the shared HTTP client. +func NewAccessKeyProviderAuthorizer(accessKey func() string) RequestAuthorizer { + if accessKey == nil { + accessKey = func() string { return "" } + } + return &accessKeyAuthorizer{accessKey: accessKey} } func (a *accessKeyAuthorizer) Inject(ctx context.Context, req *http.Request) error { if err := ctx.Err(); err != nil { return err } - if a.accessKey == "" && req.Method == http.MethodPost { - return fmt.Errorf("%s 缺失. 请前往小云雀官网个人设置页创建 Access Key,地址:https://xyq.jianying.com/home?tab_name=home\n配置后重试:\n export %s=\"\"", config.EnvXYQAccessKey, config.EnvXYQAccessKey) + accessKey := "" + if a != nil && a.accessKey != nil { + accessKey = strings.TrimSpace(a.accessKey()) + } + if accessKey == "" && req.Method == http.MethodPost { + return fmt.Errorf("%s 缺失;请前往小云雀官网个人设置页创建 Access Key,地址:https://xyq.jianying.com/home?tab_name=home\n配置后重试:\n export %s=\"\"", config.EnvXYQAccessKey, config.EnvXYQAccessKey) } - if a.accessKey != "" { - req.Header.Set("Authorization", "Bearer "+a.accessKey) + if accessKey != "" { + req.Header.Set("Authorization", "Bearer "+accessKey) } return nil } diff --git a/internal/common/access_key_test.go b/internal/common/access_key_test.go new file mode 100644 index 0000000..24a94b5 --- /dev/null +++ b/internal/common/access_key_test.go @@ -0,0 +1,77 @@ +package common + +import ( + "context" + "net/http" + "strings" + "testing" +) + +func TestAccessKeyProviderAuthorizerReadsLatestValue(t *testing.T) { + accessKey := " first-key " + authorizer := NewAccessKeyProviderAuthorizer(func() string { return accessKey }) + + first := newAccessKeyTestRequest(t, http.MethodPost) + if err := authorizer.Inject(context.Background(), first); err != nil { + t.Fatalf("Inject(first) error = %v", err) + } + if got := first.Header.Get("Authorization"); got != "Bearer first-key" { + t.Fatalf("first Authorization = %q, want latest first key", got) + } + + accessKey = "second-key" + second := newAccessKeyTestRequest(t, http.MethodPost) + if err := authorizer.Inject(context.Background(), second); err != nil { + t.Fatalf("Inject(second) error = %v", err) + } + if got := second.Header.Get("Authorization"); got != "Bearer second-key" { + t.Fatalf("second Authorization = %q, want updated key", got) + } +} + +func TestAccessKeyProviderAuthorizerRejectsMissingKeyWithoutLeakingPriorValue(t *testing.T) { + accessKey := "prior-secret" + authorizer := NewAccessKeyProviderAuthorizer(func() string { return accessKey }) + accessKey = " " + + err := authorizer.Inject(context.Background(), newAccessKeyTestRequest(t, http.MethodPost)) + if err == nil || !strings.Contains(err.Error(), "XYQ_ACCESS_KEY 缺失") { + t.Fatalf("Inject() error = %v, want missing Access Key guidance", err) + } + if strings.Contains(err.Error(), "prior-secret") { + t.Fatalf("Inject() error leaks a previous Access Key: %v", err) + } +} + +func TestAccessKeyProviderAuthorizerAllowsUnauthenticatedRead(t *testing.T) { + authorizer := NewAccessKeyProviderAuthorizer(nil) + request := newAccessKeyTestRequest(t, http.MethodGet) + + if err := authorizer.Inject(context.Background(), request); err != nil { + t.Fatalf("Inject() error = %v", err) + } + if got := request.Header.Get("Authorization"); got != "" { + t.Fatalf("Authorization = %q, want empty", got) + } +} + +func TestAccessKeyAuthorizerKeepsConstantAPIBehavior(t *testing.T) { + authorizer := NewAccessKeyAuthorizer(" constant-key ") + request := newAccessKeyTestRequest(t, http.MethodPost) + + if err := authorizer.Inject(context.Background(), request); err != nil { + t.Fatalf("Inject() error = %v", err) + } + if got := request.Header.Get("Authorization"); got != "Bearer constant-key" { + t.Fatalf("Authorization = %q, want trimmed constant key", got) + } +} + +func newAccessKeyTestRequest(t *testing.T, method string) *http.Request { + t.Helper() + request, err := http.NewRequest(method, "https://example.test/api", nil) + if err != nil { + t.Fatalf("NewRequest() error = %v", err) + } + return request +} From c04408c4b59c4a6bf141c563d9d77df8df40d7da Mon Sep 17 00:00:00 2001 From: "xuyan.smackgg" Date: Wed, 12 Aug 2026 07:08:58 +0800 Subject: [PATCH 06/14] feat(auth): add secure CLI credential storage Co-authored-by: Codex --- go.mod | 8 +- go.sum | 11 +- internal/auth/browser_darwin.go | 14 ++ internal/auth/browser_env.go | 35 ++++ internal/auth/browser_linux.go | 14 ++ internal/auth/browser_windows.go | 14 ++ internal/auth/identity.go | 92 +++++++++ internal/auth/store.go | 300 ++++++++++++++++++++++++++++ internal/auth/store_file_unix.go | 176 ++++++++++++++++ internal/auth/store_file_windows.go | 11 + internal/auth/types.go | 77 +++++++ 11 files changed, 747 insertions(+), 5 deletions(-) create mode 100644 internal/auth/browser_darwin.go create mode 100644 internal/auth/browser_env.go create mode 100644 internal/auth/browser_linux.go create mode 100644 internal/auth/browser_windows.go create mode 100644 internal/auth/identity.go create mode 100644 internal/auth/store.go create mode 100644 internal/auth/store_file_unix.go create mode 100644 internal/auth/store_file_windows.go create mode 100644 internal/auth/types.go diff --git a/go.mod b/go.mod index a9f5bb5..b38d9f7 100644 --- a/go.mod +++ b/go.mod @@ -1,21 +1,23 @@ module github.com/Pippit-dev/pippit-cli -go 1.23 +go 1.23.0 require ( github.com/bytedance/sonic v1.15.1 github.com/spf13/cobra v1.8.1 + github.com/zalando/go-keyring v0.2.8 + golang.org/x/sys v0.33.0 ) require ( github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic/loader v0.5.1 // indirect github.com/cloudwego/base64x v0.1.6 // indirect + github.com/danieljoos/wincred v1.2.3 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/klauspost/cpuid/v2 v2.2.9 // indirect github.com/spf13/pflag v1.0.5 // indirect - github.com/stretchr/testify v1.11.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect - golang.org/x/sys v0.27.0 // indirect ) diff --git a/go.sum b/go.sum index 607aa5b..7d157a4 100644 --- a/go.sum +++ b/go.sum @@ -7,9 +7,13 @@ github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCc github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= +github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= @@ -24,6 +28,7 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -33,10 +38,12 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= +github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= -golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= -golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/auth/browser_darwin.go b/internal/auth/browser_darwin.go new file mode 100644 index 0000000..a64fab1 --- /dev/null +++ b/internal/auth/browser_darwin.go @@ -0,0 +1,14 @@ +//go:build darwin + +package auth + +import ( + "os" + "os/exec" +) + +func OpenBrowser(rawURL string) error { + command := exec.Command("open", rawURL) + command.Env = SanitizedBrowserEnv(os.Environ()) + return command.Start() +} diff --git a/internal/auth/browser_env.go b/internal/auth/browser_env.go new file mode 100644 index 0000000..dde9c41 --- /dev/null +++ b/internal/auth/browser_env.go @@ -0,0 +1,35 @@ +package auth + +import "strings" + +// SanitizedBrowserEnv removes Pippit/XYQ credential variables before spawning +// a browser helper. The login URL carries its own one-time binding and does not +// need any CLI credential from the child process environment. +func SanitizedBrowserEnv(environment []string) []string { + result := make([]string, 0, len(environment)) + for _, entry := range environment { + name, _, found := strings.Cut(entry, "=") + if !found || isCredentialEnvName(name) { + continue + } + result = append(result, entry) + } + return result +} + +func isCredentialEnvName(name string) bool { + upper := strings.ToUpper(strings.TrimSpace(name)) + if !strings.HasPrefix(upper, "PIPPIT_") && !strings.HasPrefix(upper, "XYQ_") { + return false + } + if strings.Contains(upper, "ACCESS_KEY") || strings.Contains(upper, "TOKEN") || strings.Contains(upper, "SECRET") { + return true + } + parts := strings.Split(upper, "_") + for _, part := range parts { + if part == "AK" { + return true + } + } + return false +} diff --git a/internal/auth/browser_linux.go b/internal/auth/browser_linux.go new file mode 100644 index 0000000..f147b2c --- /dev/null +++ b/internal/auth/browser_linux.go @@ -0,0 +1,14 @@ +//go:build linux + +package auth + +import ( + "os" + "os/exec" +) + +func OpenBrowser(rawURL string) error { + command := exec.Command("xdg-open", rawURL) + command.Env = SanitizedBrowserEnv(os.Environ()) + return command.Start() +} diff --git a/internal/auth/browser_windows.go b/internal/auth/browser_windows.go new file mode 100644 index 0000000..c685d88 --- /dev/null +++ b/internal/auth/browser_windows.go @@ -0,0 +1,14 @@ +//go:build windows + +package auth + +import ( + "os" + "os/exec" +) + +func OpenBrowser(rawURL string) error { + command := exec.Command("rundll32", "url.dll,FileProtocolHandler", rawURL) + command.Env = SanitizedBrowserEnv(os.Environ()) + return command.Start() +} diff --git a/internal/auth/identity.go b/internal/auth/identity.go new file mode 100644 index 0000000..4882800 --- /dev/null +++ b/internal/auth/identity.go @@ -0,0 +1,92 @@ +package auth + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "io" + "strings" +) + +func randomEncoded(reader io.Reader, size int) (string, error) { + if reader == nil { + reader = rand.Reader + } + value := make([]byte, size) + if _, err := io.ReadFull(reader, value); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(value), nil +} + +func randomTempName() (string, error) { + value, err := randomEncoded(rand.Reader, 18) + if err != nil { + return "", err + } + return ".credential-" + value + ".tmp", nil +} + +func validDeviceID(deviceID string) bool { + decoded, err := base64.RawURLEncoding.DecodeString(deviceID) + return err == nil && len(decoded) == deviceIDBytes && + constantTimeEqual(deviceID, base64.RawURLEncoding.EncodeToString(decoded)) +} + +func tokenNameForDevice(deviceID string) string { + digest := sha256.Sum256([]byte(deviceID)) + return "pippit-tool-cli-" + base64.RawURLEncoding.EncodeToString(digest[:16]) +} + +func credentialScope(uid, deviceID string) string { + digest := sha256.Sum256([]byte(strings.TrimSpace(uid))) + return "pippit-tool-cli:user:" + base64.RawURLEncoding.EncodeToString(digest[:16]) + ":device:" + deviceID +} + +func legacyCredentialScope(deviceID string) string { + return "pippit-tool-cli:device:" + deviceID +} + +func constantTimeEqual(left, right string) bool { + leftHash := sha256.Sum256([]byte(left)) + rightHash := sha256.Sum256([]byte(right)) + return subtle.ConstantTimeCompare(leftHash[:], rightHash[:]) == 1 +} + +func newIdentity(reader io.Reader) (*Credential, error) { + deviceID, err := randomEncoded(reader, deviceIDBytes) + if err != nil { + return nil, errors.New("生成本机登录设备标识失败") + } + return &Credential{ + Version: credentialVersion, + DeviceID: deviceID, + TokenName: tokenNameForDevice(deviceID), + }, nil +} + +func identityOnly(credential *Credential) *Credential { + if credential == nil { + return nil + } + return &Credential{ + Version: credential.Version, + DeviceID: credential.DeviceID, + TokenName: credential.TokenName, + // TokenID is not an authentication secret. Keeping this exact remote + // reference lets a later login reuse or rotate the same device token + // without consuming another per-account AK slot. + TokenID: credential.TokenID, + } +} + +func redactedOperationError(operation string) error { + operation = strings.TrimSpace(operation) + if operation == "" { + operation = "授权操作" + } + return fmt.Errorf("%s失败,请稍后重试", operation) +} diff --git a/internal/auth/store.go b/internal/auth/store.go new file mode 100644 index 0000000..970310b --- /dev/null +++ b/internal/auth/store.go @@ -0,0 +1,300 @@ +package auth + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/zalando/go-keyring" +) + +const ( + defaultKeyringAccount = "browser-login" + credentialFileName = "browser-credential.json" + maxCredentialBytes = 64 << 10 +) + +type keyringBackend interface { + Get(service, user string) (string, error) + Set(service, user, password string) error + Delete(service, user string) error +} + +type systemKeyring struct{} + +func (systemKeyring) Get(service, user string) (string, error) { + return keyring.Get(service, user) +} + +func (systemKeyring) Set(service, user, password string) error { + return keyring.Set(service, user, password) +} + +func (systemKeyring) Delete(service, user string) error { + return keyring.Delete(service, user) +} + +type keyringCredentialStore struct { + backend keyringBackend + service string + account string +} + +func (s *keyringCredentialStore) Load(ctx context.Context) (*Credential, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + value, err := s.backend.Get(s.service, s.account) + if errors.Is(err, keyring.ErrNotFound) { + return nil, ErrCredentialNotFound + } + if err != nil { + return nil, fmt.Errorf("读取系统钥匙串失败: %w", ErrSecureStore) + } + credential, err := decodeCredential([]byte(value)) + if err != nil { + return nil, err + } + return credential, nil +} + +func (s *keyringCredentialStore) Save(ctx context.Context, credential *Credential) error { + if err := ctx.Err(); err != nil { + return err + } + payload, err := encodeCredential(credential) + if err != nil { + return err + } + if err := s.backend.Set(s.service, s.account, string(payload)); err != nil { + return fmt.Errorf("写入系统钥匙串失败: %w", ErrSecureStore) + } + return nil +} + +func (s *keyringCredentialStore) Delete(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + err := s.backend.Delete(s.service, s.account) + if errors.Is(err, keyring.ErrNotFound) { + return ErrCredentialNotFound + } + if err != nil { + return fmt.Errorf("删除系统钥匙串凭证失败: %w", ErrSecureStore) + } + return nil +} + +type resilientCredentialStore struct { + primary CredentialStore + fallback CredentialStore +} + +type storedCredential struct { + Version int `json:"version"` + DeviceID string `json:"device_id"` + CredentialScope string `json:"credential_scope"` + TokenName string `json:"token_name"` + AccessKey string `json:"access_key,omitempty"` + TokenID string `json:"token_id,omitempty"` + UID string `json:"uid,omitempty"` + ExpiredAt int64 `json:"expired_at,omitempty"` +} + +// NewDefaultCredentialStore prefers the operating system keyring. Unix uses +// a private no-follow file as a fallback; Windows deliberately has no file +// fallback because an equivalent ACL guarantee is not provided here. +func NewDefaultCredentialStore(serviceName string) CredentialStore { + serviceName = strings.TrimSpace(serviceName) + if serviceName == "" { + serviceName = "pippit-cli" + } + primary := &keyringCredentialStore{ + backend: systemKeyring{}, + service: serviceName, + account: defaultKeyringAccount, + } + return &resilientCredentialStore{ + primary: primary, + fallback: newPlatformFallbackStore(), + } +} + +func (s *resilientCredentialStore) Load(ctx context.Context) (*Credential, error) { + credential, primaryErr := s.primary.Load(ctx) + if primaryErr == nil { + return credential, nil + } + if err := ctx.Err(); err != nil { + return nil, err + } + if !errors.Is(primaryErr, ErrCredentialNotFound) && !errors.Is(primaryErr, ErrSecureStore) { + // A decodable-but-invalid primary record may indicate corruption or + // tampering. Never mask it with an older fallback credential. + return nil, primaryErr + } + if s.fallback == nil { + if errors.Is(primaryErr, ErrCredentialNotFound) { + return nil, ErrCredentialNotFound + } + return nil, primaryErr + } + credential, fallbackErr := s.fallback.Load(ctx) + if fallbackErr == nil { + return credential, nil + } + if errors.Is(primaryErr, ErrCredentialNotFound) && errors.Is(fallbackErr, ErrCredentialNotFound) { + return nil, ErrCredentialNotFound + } + if !errors.Is(fallbackErr, ErrCredentialNotFound) { + return nil, fallbackErr + } + return nil, primaryErr +} + +func (s *resilientCredentialStore) Save(ctx context.Context, credential *Credential) error { + primaryErr := s.primary.Save(ctx, credential) + if primaryErr == nil { + if s.fallback != nil { + if err := ignoreNotFound(s.fallback.Delete(ctx)); err != nil { + return fmt.Errorf("系统钥匙串已更新,但清理旧的备用凭证失败: %w", err) + } + } + return nil + } + if err := ctx.Err(); err != nil { + return err + } + if !errors.Is(primaryErr, ErrCredentialNotFound) && !errors.Is(primaryErr, ErrSecureStore) { + return primaryErr + } + if s.fallback == nil { + return primaryErr + } + if err := s.fallback.Save(ctx, credential); err != nil { + return err + } + return nil +} + +func (s *resilientCredentialStore) Delete(ctx context.Context) error { + primaryErr := ignoreNotFound(s.primary.Delete(ctx)) + var fallbackErr error + if s.fallback != nil { + fallbackErr = ignoreNotFound(s.fallback.Delete(ctx)) + } + if primaryErr != nil { + return primaryErr + } + return fallbackErr +} + +func ignoreNotFound(err error) error { + if errors.Is(err, ErrCredentialNotFound) { + return nil + } + return err +} + +func encodeCredential(credential *Credential) ([]byte, error) { + if err := validateCredential(credential); err != nil { + return nil, err + } + payload, err := json.Marshal(storedCredential{ + Version: credential.Version, + DeviceID: credential.DeviceID, + CredentialScope: credential.CredentialScope, + TokenName: credential.TokenName, + AccessKey: credential.AccessKey, + TokenID: credential.TokenID, + UID: credential.UID, + ExpiredAt: credential.ExpiredAt, + }) + if err != nil { + return nil, errors.New("编码本机登录凭证失败") + } + return payload, nil +} + +func decodeCredential(payload []byte) (*Credential, error) { + if len(payload) == 0 || len(payload) > maxCredentialBytes { + return nil, errors.New("本机登录凭证格式无效") + } + record := &storedCredential{} + if err := json.Unmarshal(payload, record); err != nil { + return nil, errors.New("本机登录凭证格式无效") + } + credential := &Credential{ + Version: record.Version, + DeviceID: record.DeviceID, + CredentialScope: record.CredentialScope, + TokenName: record.TokenName, + AccessKey: record.AccessKey, + TokenID: record.TokenID, + UID: record.UID, + ExpiredAt: record.ExpiredAt, + } + if err := validateCredential(credential); err != nil { + return nil, err + } + return credential, nil +} + +func defaultCredentialPath() string { + dir, err := os.UserConfigDir() + if err != nil || strings.TrimSpace(dir) == "" { + return "" + } + return filepath.Join(dir, "pippit-cli", credentialFileName) +} + +type unavailableCredentialStore struct{} + +func (unavailableCredentialStore) Load(context.Context) (*Credential, error) { + return nil, ErrSecureStore +} + +func (unavailableCredentialStore) Save(context.Context, *Credential) error { + return ErrSecureStore +} + +func (unavailableCredentialStore) Delete(context.Context) error { + return ErrSecureStore +} + +func validateCredential(credential *Credential) error { + if credential == nil { + return errors.New("本机登录凭证不能为空") + } + if credential.Version != credentialVersion { + return errors.New("本机登录凭证版本不受支持") + } + if !validDeviceID(credential.DeviceID) { + return errors.New("本机登录设备标识无效") + } + if !constantTimeEqual(credential.TokenName, tokenNameForDevice(credential.DeviceID)) { + return errors.New("本机登录凭证作用域无效") + } + if credential.AccessKey == "" { + if strings.TrimSpace(credential.TokenID) != credential.TokenID || credential.UID != "" || credential.ExpiredAt != 0 || + (credential.CredentialScope != "" && !constantTimeEqual(credential.CredentialScope, legacyCredentialScope(credential.DeviceID))) { + return errors.New("本机登录凭证不完整") + } + return nil + } + if strings.TrimSpace(credential.AccessKey) != credential.AccessKey || + credential.TokenID == "" || credential.UID == "" || credential.ExpiredAt <= 0 { + return errors.New("本机登录凭证不完整") + } + expectedScope := credentialScope(credential.UID, credential.DeviceID) + if !constantTimeEqual(credential.CredentialScope, expectedScope) && + !constantTimeEqual(credential.CredentialScope, legacyCredentialScope(credential.DeviceID)) { + return errors.New("本机登录凭证作用域无效") + } + return nil +} diff --git a/internal/auth/store_file_unix.go b/internal/auth/store_file_unix.go new file mode 100644 index 0000000..275621c --- /dev/null +++ b/internal/auth/store_file_unix.go @@ -0,0 +1,176 @@ +//go:build !windows + +package auth + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "golang.org/x/sys/unix" +) + +type fileCredentialStore struct { + path string +} + +func NewFileCredentialStore(path string) CredentialStore { + return &fileCredentialStore{path: filepath.Clean(path)} +} + +func newPlatformFallbackStore() CredentialStore { + path := defaultCredentialPath() + if path == "" { + return nil + } + return NewFileCredentialStore(path) +} + +func (s *fileCredentialStore) Load(ctx context.Context) (*Credential, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + dirFD, name, err := s.openPrivateDirectory(false) + if errors.Is(err, os.ErrNotExist) { + return nil, ErrCredentialNotFound + } + if err != nil { + return nil, err + } + defer unix.Close(dirFD) + + fd, err := unix.Openat(dirFD, name, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) + if errors.Is(err, unix.ENOENT) { + return nil, ErrCredentialNotFound + } + if err != nil { + return nil, fmt.Errorf("安全打开本机登录凭证失败: %w", ErrSecureStore) + } + file := os.NewFile(uintptr(fd), name) + defer file.Close() + if err := verifyPrivateRegularFile(fd); err != nil { + return nil, err + } + payload, err := io.ReadAll(io.LimitReader(file, maxCredentialBytes+1)) + if err != nil { + return nil, fmt.Errorf("读取本机登录凭证失败: %w", ErrSecureStore) + } + return decodeCredential(payload) +} + +func (s *fileCredentialStore) Save(ctx context.Context, credential *Credential) error { + if err := ctx.Err(); err != nil { + return err + } + payload, err := encodeCredential(credential) + if err != nil { + return err + } + dirFD, name, err := s.openPrivateDirectory(true) + if err != nil { + return err + } + defer unix.Close(dirFD) + + tempName, err := randomTempName() + if err != nil { + return errors.New("创建本机登录凭证临时文件失败") + } + fd, err := unix.Openat(dirFD, tempName, unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0o600) + if err != nil { + return fmt.Errorf("创建本机登录凭证临时文件失败: %w", ErrSecureStore) + } + cleanup := true + defer func() { + if cleanup { + _ = unix.Unlinkat(dirFD, tempName, 0) + } + }() + file := os.NewFile(uintptr(fd), tempName) + if _, err := file.Write(payload); err != nil { + _ = file.Close() + return fmt.Errorf("写入本机登录凭证失败: %w", ErrSecureStore) + } + if err := file.Sync(); err != nil { + _ = file.Close() + return fmt.Errorf("同步本机登录凭证失败: %w", ErrSecureStore) + } + if err := file.Close(); err != nil { + return fmt.Errorf("关闭本机登录凭证失败: %w", ErrSecureStore) + } + if err := unix.Renameat(dirFD, tempName, dirFD, name); err != nil { + return fmt.Errorf("原子保存本机登录凭证失败: %w", ErrSecureStore) + } + cleanup = false + if err := unix.Fsync(dirFD); err != nil { + return fmt.Errorf("同步本机凭证目录失败: %w", ErrSecureStore) + } + return nil +} + +func (s *fileCredentialStore) Delete(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + dirFD, name, err := s.openPrivateDirectory(false) + if errors.Is(err, os.ErrNotExist) { + return ErrCredentialNotFound + } + if err != nil { + return err + } + defer unix.Close(dirFD) + if err := unix.Unlinkat(dirFD, name, 0); errors.Is(err, unix.ENOENT) { + return ErrCredentialNotFound + } else if err != nil { + return fmt.Errorf("删除本机登录凭证失败: %w", ErrSecureStore) + } + if err := unix.Fsync(dirFD); err != nil { + return fmt.Errorf("同步本机凭证目录失败: %w", ErrSecureStore) + } + return nil +} + +func (s *fileCredentialStore) openPrivateDirectory(create bool) (int, string, error) { + if s == nil || strings.TrimSpace(s.path) == "" || filepath.Base(s.path) == "." { + return -1, "", ErrSecureStore + } + dir := filepath.Dir(s.path) + if create { + if err := os.MkdirAll(dir, 0o700); err != nil { + return -1, "", fmt.Errorf("创建本机凭证目录失败: %w", ErrSecureStore) + } + } + info, err := os.Lstat(dir) + if err != nil { + return -1, "", err + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() || info.Mode().Perm()&0o077 != 0 { + return -1, "", fmt.Errorf("本机凭证目录必须是权限 0700 的真实目录: %w", ErrSecureStore) + } + dirFD, err := unix.Open(dir, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_DIRECTORY|unix.O_NOFOLLOW, 0) + if err != nil { + return -1, "", fmt.Errorf("安全打开本机凭证目录失败: %w", ErrSecureStore) + } + var stat unix.Stat_t + if err := unix.Fstat(dirFD, &stat); err != nil || stat.Uid != uint32(os.Geteuid()) { + unix.Close(dirFD) + return -1, "", fmt.Errorf("本机凭证目录所有者无效: %w", ErrSecureStore) + } + return dirFD, filepath.Base(s.path), nil +} + +func verifyPrivateRegularFile(fd int) error { + var stat unix.Stat_t + if err := unix.Fstat(fd, &stat); err != nil { + return fmt.Errorf("检查本机登录凭证失败: %w", ErrSecureStore) + } + if stat.Uid != uint32(os.Geteuid()) || stat.Mode&unix.S_IFMT != unix.S_IFREG || stat.Mode&0o077 != 0 { + return fmt.Errorf("本机登录凭证必须是当前用户拥有的权限 0600 文件: %w", ErrSecureStore) + } + return nil +} diff --git a/internal/auth/store_file_windows.go b/internal/auth/store_file_windows.go new file mode 100644 index 0000000..84143bf --- /dev/null +++ b/internal/auth/store_file_windows.go @@ -0,0 +1,11 @@ +//go:build windows + +package auth + +func NewFileCredentialStore(string) CredentialStore { + return unavailableCredentialStore{} +} + +func newPlatformFallbackStore() CredentialStore { + return nil +} diff --git a/internal/auth/types.go b/internal/auth/types.go new file mode 100644 index 0000000..8786e3f --- /dev/null +++ b/internal/auth/types.go @@ -0,0 +1,77 @@ +package auth + +import ( + "context" + "errors" + "io" + "time" +) + +const ( + loginExportPath = "/cli/login-export" + callbackPath = "/xyq/callback/save_session" + exchangeGrantPath = "/api/web/v1/auth/exchange_cli_login_grant" + queryAccessKeyPath = "/api/biz/v1/user/query_ak" + generateAccessKeyPath = "/api/biz/v1/user/generate_ak" + deleteAccessKeyPath = "/api/biz/v1/user/delete_ak" + loginSource = "pippit-tool-cli" + credentialVersion = 1 + deviceIDBytes = 32 + randomBindingBytes = 32 + + DefaultLoginTimeout = 5 * time.Minute + DefaultCredentialLifetime = 365 * 24 * time.Hour +) + +var ( + ErrCredentialNotFound = errors.New("未找到本机小云雀 CLI 登录凭证") + ErrCredentialExpired = errors.New("本机小云雀 CLI 登录凭证已过期") + ErrSecureStore = errors.New("安全凭证存储不可用") + ErrCredentialAccountMismatch = errors.New("网页授权账号与当前任务账号不一致") + ErrRemoteRevokeUnsupported = errors.New("当前版本不支持在 CLI 中安全撤销远程 Access Key") +) + +// Credential is the dedicated, device-scoped Access Key managed by this CLI. +// AccessKey is secret and must never be printed, logged, or written to journals. +type Credential struct { + Version int `json:"version"` + DeviceID string `json:"device_id"` + CredentialScope string `json:"credential_scope"` + TokenName string `json:"token_name"` + AccessKey string `json:"-"` + TokenID string `json:"token_id,omitempty"` + UID string `json:"uid,omitempty"` + ExpiredAt int64 `json:"expired_at,omitempty"` +} + +// CredentialStore persists a credential without exposing its serialized form. +type CredentialStore interface { + Load(context.Context) (*Credential, error) + Save(context.Context, *Credential) error + Delete(context.Context) error +} + +type LoginOptions struct { + // OpenURL should open the URL without logging it. When nil, the platform's + // standard browser opener is used with credential-bearing env vars removed. + OpenURL func(string) error + Progress io.Writer + Timeout time.Duration + // ForceRefresh rotates every remote token owned by this device identity + // before provisioning a replacement. It is used after an explicit 401 so + // a successful browser login can never return the just-rejected AK again. + ForceRefresh bool + // ExpectedCredentialScope binds reauthentication to the UID and device that + // started a durable operation. A different browser account fails before any + // Access Key is deleted or generated. + ExpectedCredentialScope string +} + +type Status struct { + LoggedIn bool `json:"logged_in"` + Source string `json:"source,omitempty"` + UID string `json:"uid,omitempty"` + TokenID string `json:"token_id,omitempty"` + CredentialScope string `json:"credential_scope,omitempty"` + ExpiresAt time.Time `json:"expires_at,omitempty"` +} From 62611e8d478a5af3090de873e1d6a30df41fa110 Mon Sep 17 00:00:00 2001 From: "xuyan.smackgg" Date: Wed, 12 Aug 2026 07:11:05 +0800 Subject: [PATCH 07/14] feat(auth): harden the browser callback flow Co-authored-by: Codex --- internal/auth/loopback.go | 243 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 internal/auth/loopback.go diff --git a/internal/auth/loopback.go b/internal/auth/loopback.go new file mode 100644 index 0000000..ac34136 --- /dev/null +++ b/internal/auth/loopback.go @@ -0,0 +1,243 @@ +package auth + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "mime" + "net" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +const maxCallbackBodyBytes = 64 << 10 + +type loginGrantPayload struct { + Type string `json:"type"` + Grant string `json:"grant"` + RandomSecretKey string `json:"random_secret_key"` + ExpireAt int64 `json:"expire_at,omitempty"` + Source string `json:"source"` + CallbackURL string `json:"callback_url"` +} + +type browserFlow struct { + loginURL string + callbackURL string + secret string + state string + source string + origin string + listener net.Listener + server *http.Server + payload chan loginGrantPayload + serveErr chan error + closeOnce sync.Once +} + +func startBrowserFlow(authBaseURL *url.URL, randomReader io.Reader) (*browserFlow, error) { + secret, err := randomEncoded(randomReader, randomBindingBytes) + if err != nil { + return nil, errors.New("生成网页授权绑定信息失败") + } + state, err := randomEncoded(randomReader, randomBindingBytes) + if err != nil { + return nil, errors.New("生成网页授权状态失败") + } + listener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + return nil, errors.New("启动本机网页授权回调失败") + } + + callback := &url.URL{ + Scheme: "http", + Host: listener.Addr().String(), + Path: callbackPath, + } + callbackQuery := callback.Query() + callbackQuery.Set("state", state) + callback.RawQuery = callbackQuery.Encode() + + loginURL := *authBaseURL + loginURL.Path = loginExportPath + loginURL.RawPath = "" + loginURL.RawQuery = "" + loginURL.Fragment = "" + query := loginURL.Query() + query.Set("callback", callback.String()) + query.Set("random_secret_key", secret) + query.Set("source", loginSource) + loginURL.RawQuery = query.Encode() + + flow := &browserFlow{ + loginURL: loginURL.String(), + callbackURL: callback.String(), + secret: secret, + state: state, + source: loginSource, + origin: originOf(authBaseURL), + listener: listener, + payload: make(chan loginGrantPayload, 1), + serveErr: make(chan error, 1), + } + mux := http.NewServeMux() + mux.HandleFunc(callbackPath, flow.handleCallback) + flow.server = &http.Server{ + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + IdleTimeout: 15 * time.Second, + } + go func() { + err := flow.server.Serve(listener) + if err != nil && !errors.Is(err, http.ErrServerClosed) { + flow.serveErr <- err + } + close(flow.serveErr) + }() + return flow, nil +} + +func (f *browserFlow) wait(ctx context.Context) (loginGrantPayload, error) { + select { + case payload := <-f.payload: + return payload, nil + case err, open := <-f.serveErr: + if open && err != nil { + return loginGrantPayload{}, errors.New("本机网页授权回调异常退出") + } + return loginGrantPayload{}, errors.New("本机网页授权回调已关闭") + case <-ctx.Done(): + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return loginGrantPayload{}, errors.New("等待网页授权超时,请重新登录") + } + return loginGrantPayload{}, ctx.Err() + } +} + +func (f *browserFlow) close() { + f.closeOnce.Do(func() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + _ = f.server.Shutdown(ctx) + _ = f.listener.Close() + }) +} + +func (f *browserFlow) handleCallback(writer http.ResponseWriter, request *http.Request) { + if !f.validRequestTarget(request) { + http.Error(writer, "invalid callback target", http.StatusBadRequest) + return + } + if !constantTimeEqual(request.Header.Get("Origin"), f.origin) { + http.Error(writer, "origin not allowed", http.StatusForbidden) + return + } + f.setCORSHeaders(writer.Header()) + + if request.Method == http.MethodOptions { + if !strings.EqualFold(strings.TrimSpace(request.Header.Get("Access-Control-Request-Method")), http.MethodPost) || + !allowsContentTypeHeader(request.Header.Get("Access-Control-Request-Headers")) { + http.Error(writer, "invalid preflight", http.StatusBadRequest) + return + } + writer.WriteHeader(http.StatusNoContent) + return + } + if request.Method != http.MethodPost { + writer.Header().Set("Allow", "OPTIONS, POST") + http.Error(writer, "method not allowed", http.StatusMethodNotAllowed) + return + } + mediaType, _, err := mime.ParseMediaType(request.Header.Get("Content-Type")) + if err != nil || !strings.EqualFold(mediaType, "application/json") { + http.Error(writer, "content type must be application/json", http.StatusUnsupportedMediaType) + return + } + + reader := http.MaxBytesReader(writer, request.Body, maxCallbackBodyBytes) + decoder := json.NewDecoder(reader) + decoder.DisallowUnknownFields() + payload := loginGrantPayload{} + if err := decoder.Decode(&payload); err != nil { + http.Error(writer, "invalid callback payload", http.StatusBadRequest) + return + } + if err := ensureJSONEOF(decoder); err != nil { + http.Error(writer, "invalid callback payload", http.StatusBadRequest) + return + } + if payload.Type != "login_grant" || strings.TrimSpace(payload.Grant) == "" || + !constantTimeEqual(payload.RandomSecretKey, f.secret) || + !constantTimeEqual(payload.Source, f.source) || + !constantTimeEqual(payload.CallbackURL, f.callbackURL) { + http.Error(writer, "callback binding mismatch", http.StatusBadRequest) + return + } + select { + case f.payload <- payload: + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write([]byte(`{"ok":true}`)) + default: + http.Error(writer, "callback already received", http.StatusConflict) + } +} + +func (f *browserFlow) validRequestTarget(request *http.Request) bool { + if request.URL.Path != callbackPath || !constantTimeEqual(request.Host, strings.TrimPrefix(f.callbackURLHost(), "//")) { + return false + } + query := request.URL.Query() + states, ok := query["state"] + return ok && len(query) == 1 && len(states) == 1 && constantTimeEqual(states[0], f.state) +} + +func (f *browserFlow) callbackURLHost() string { + parsed, err := url.Parse(f.callbackURL) + if err != nil { + return "" + } + return parsed.Host +} + +func (f *browserFlow) setCORSHeaders(header http.Header) { + header.Set("Access-Control-Allow-Origin", f.origin) + header.Set("Access-Control-Allow-Methods", "POST") + header.Set("Access-Control-Allow-Headers", "Content-Type") + header.Set("Access-Control-Allow-Private-Network", "true") + header.Add("Vary", "Origin") + header.Add("Vary", "Access-Control-Request-Method") + header.Add("Vary", "Access-Control-Request-Headers") +} + +func allowsContentTypeHeader(value string) bool { + for _, part := range strings.Split(value, ",") { + if strings.EqualFold(strings.TrimSpace(part), "content-type") { + return true + } + } + return false +} + +func ensureJSONEOF(decoder *json.Decoder) error { + var trailing any + err := decoder.Decode(&trailing) + if errors.Is(err, io.EOF) { + return nil + } + if err == nil { + return errors.New("unexpected trailing JSON") + } + return err +} + +func originOf(value *url.URL) string { + return fmt.Sprintf("%s://%s", strings.ToLower(value.Scheme), value.Host) +} From 235bf32c304a8d9b287067910f34eb37c6b6cf4e Mon Sep 17 00:00:00 2001 From: "xuyan.smackgg" Date: Wed, 12 Aug 2026 18:12:47 +0800 Subject: [PATCH 08/14] feat(auth): receive managed keys from browser login Co-authored-by: Codex --- internal/auth/identity.go | 43 ++-- internal/auth/loopback.go | 143 ++++++++--- internal/auth/manager.go | 497 +++++++++++++++++++++++++++----------- internal/auth/store.go | 25 +- internal/auth/types.go | 29 +-- 5 files changed, 510 insertions(+), 227 deletions(-) diff --git a/internal/auth/identity.go b/internal/auth/identity.go index 4882800..2f17a76 100644 --- a/internal/auth/identity.go +++ b/internal/auth/identity.go @@ -6,7 +6,6 @@ import ( "crypto/subtle" "encoding/base64" "errors" - "fmt" "io" "strings" ) @@ -36,14 +35,23 @@ func validDeviceID(deviceID string) bool { constantTimeEqual(deviceID, base64.RawURLEncoding.EncodeToString(decoded)) } -func tokenNameForDevice(deviceID string) string { - digest := sha256.Sum256([]byte(deviceID)) - return "pippit-tool-cli-" + base64.RawURLEncoding.EncodeToString(digest[:16]) +func credentialScope(uid, deviceID string) string { + return "pippit-tool-cli:user:" + accountBinding(uid) + ":device:" + deviceID } -func credentialScope(uid, deviceID string) string { +func accountBinding(uid string) string { digest := sha256.Sum256([]byte(strings.TrimSpace(uid))) - return "pippit-tool-cli:user:" + base64.RawURLEncoding.EncodeToString(digest[:16]) + ":device:" + deviceID + return base64.RawURLEncoding.EncodeToString(digest[:16]) +} + +func accountBindingFromCredentialScope(scope, deviceID string) (string, bool) { + prefix := "pippit-tool-cli:user:" + suffix := ":device:" + deviceID + if !strings.HasPrefix(scope, prefix) || !strings.HasSuffix(scope, suffix) { + return "", false + } + binding := strings.TrimSuffix(strings.TrimPrefix(scope, prefix), suffix) + return binding, validAccountBinding(binding) } func legacyCredentialScope(deviceID string) string { @@ -62,9 +70,8 @@ func newIdentity(reader io.Reader) (*Credential, error) { return nil, errors.New("生成本机登录设备标识失败") } return &Credential{ - Version: credentialVersion, - DeviceID: deviceID, - TokenName: tokenNameForDevice(deviceID), + Version: credentialVersion, + DeviceID: deviceID, }, nil } @@ -73,20 +80,10 @@ func identityOnly(credential *Credential) *Credential { return nil } return &Credential{ - Version: credential.Version, - DeviceID: credential.DeviceID, - TokenName: credential.TokenName, - // TokenID is not an authentication secret. Keeping this exact remote - // reference lets a later login reuse or rotate the same device token - // without consuming another per-account AK slot. + Version: credential.Version, + DeviceID: credential.DeviceID, + // TokenID is a non-secret exact selector used by the Web page to reuse the + // same remote token after logout instead of consuming another AK slot. TokenID: credential.TokenID, } } - -func redactedOperationError(operation string) error { - operation = strings.TrimSpace(operation) - if operation == "" { - operation = "授权操作" - } - return fmt.Errorf("%s失败,请稍后重试", operation) -} diff --git a/internal/auth/loopback.go b/internal/auth/loopback.go index ac34136..5312ec9 100644 --- a/internal/auth/loopback.go +++ b/internal/auth/loopback.go @@ -2,6 +2,7 @@ package auth import ( "context" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -17,30 +18,50 @@ import ( const maxCallbackBodyBytes = 64 << 10 -type loginGrantPayload struct { +const maxInjectedCallbackQueryValueBytes = 4 << 10 + +type accessKeyPayload struct { Type string `json:"type"` - Grant string `json:"grant"` + AccessKey string `json:"access_key"` + UID string `json:"uid"` + TokenID string `json:"token_id"` + ExpiredAt int64 `json:"expired_at"` RandomSecretKey string `json:"random_secret_key"` - ExpireAt int64 `json:"expire_at,omitempty"` Source string `json:"source"` CallbackURL string `json:"callback_url"` } type browserFlow struct { - loginURL string - callbackURL string - secret string - state string - source string - origin string - listener net.Listener - server *http.Server - payload chan loginGrantPayload - serveErr chan error - closeOnce sync.Once -} - -func startBrowserFlow(authBaseURL *url.URL, randomReader io.Reader) (*browserFlow, error) { + loginURL string + callbackURL string + secret string + state string + source string + origin string + listener net.Listener + server *http.Server + payload chan accessKeyPayload + serveErr chan error + callbackOnce sync.Once + closeOnce sync.Once +} + +func startBrowserFlow(authBaseURL *url.URL, randomReader io.Reader, deviceID, tokenID, expectedAccount string, forceRefresh bool) (*browserFlow, error) { + if !validDeviceID(deviceID) { + return nil, errors.New("本机登录设备标识无效") + } + if tokenID != "" && !validTokenID(tokenID) { + return nil, errors.New("本机 CLI 凭证编号无效") + } + if forceRefresh && tokenID == "" { + return nil, errors.New("无法确认需要轮换的本机 CLI 凭证编号") + } + if expectedAccount != "" && !validAccountBinding(expectedAccount) { + return nil, errors.New("本机登录账号绑定无效") + } + if forceRefresh && expectedAccount == "" { + return nil, errors.New("无法确认需要轮换的本机 CLI 登录账号") + } secret, err := randomEncoded(randomReader, randomBindingBytes) if err != nil { return nil, errors.New("生成网页授权绑定信息失败") @@ -64,7 +85,7 @@ func startBrowserFlow(authBaseURL *url.URL, randomReader io.Reader) (*browserFlo callback.RawQuery = callbackQuery.Encode() loginURL := *authBaseURL - loginURL.Path = loginExportPath + loginURL.Path = loginPagePath loginURL.RawPath = "" loginURL.RawQuery = "" loginURL.Fragment = "" @@ -72,6 +93,16 @@ func startBrowserFlow(authBaseURL *url.URL, randomReader io.Reader) (*browserFlo query.Set("callback", callback.String()) query.Set("random_secret_key", secret) query.Set("source", loginSource) + query.Set("device_id", deviceID) + if tokenID != "" { + query.Set("token_id", tokenID) + } + if expectedAccount != "" { + query.Set("expected_account", expectedAccount) + } + if forceRefresh { + query.Set("force", "1") + } loginURL.RawQuery = query.Encode() flow := &browserFlow{ @@ -82,7 +113,7 @@ func startBrowserFlow(authBaseURL *url.URL, randomReader io.Reader) (*browserFlo source: loginSource, origin: originOf(authBaseURL), listener: listener, - payload: make(chan loginGrantPayload, 1), + payload: make(chan accessKeyPayload, 1), serveErr: make(chan error, 1), } mux := http.NewServeMux() @@ -104,20 +135,20 @@ func startBrowserFlow(authBaseURL *url.URL, randomReader io.Reader) (*browserFlo return flow, nil } -func (f *browserFlow) wait(ctx context.Context) (loginGrantPayload, error) { +func (f *browserFlow) wait(ctx context.Context) (accessKeyPayload, error) { select { case payload := <-f.payload: return payload, nil case err, open := <-f.serveErr: if open && err != nil { - return loginGrantPayload{}, errors.New("本机网页授权回调异常退出") + return accessKeyPayload{}, errors.New("本机网页授权回调异常退出") } - return loginGrantPayload{}, errors.New("本机网页授权回调已关闭") + return accessKeyPayload{}, errors.New("本机网页授权回调已关闭") case <-ctx.Done(): if errors.Is(ctx.Err(), context.DeadlineExceeded) { - return loginGrantPayload{}, errors.New("等待网页授权超时,请重新登录") + return accessKeyPayload{}, errors.New("等待网页授权超时,请重新登录") } - return loginGrantPayload{}, ctx.Err() + return accessKeyPayload{}, ctx.Err() } } @@ -164,7 +195,7 @@ func (f *browserFlow) handleCallback(writer http.ResponseWriter, request *http.R reader := http.MaxBytesReader(writer, request.Body, maxCallbackBodyBytes) decoder := json.NewDecoder(reader) decoder.DisallowUnknownFields() - payload := loginGrantPayload{} + payload := accessKeyPayload{} if err := decoder.Decode(&payload); err != nil { http.Error(writer, "invalid callback payload", http.StatusBadRequest) return @@ -173,21 +204,51 @@ func (f *browserFlow) handleCallback(writer http.ResponseWriter, request *http.R http.Error(writer, "invalid callback payload", http.StatusBadRequest) return } - if payload.Type != "login_grant" || strings.TrimSpace(payload.Grant) == "" || + if payload.Type != "access_key" || !validCallbackValue(payload.AccessKey, 4096) || + !validCallbackValue(payload.UID, 256) || !validTokenID(payload.TokenID) || payload.ExpiredAt <= 0 || !constantTimeEqual(payload.RandomSecretKey, f.secret) || !constantTimeEqual(payload.Source, f.source) || !constantTimeEqual(payload.CallbackURL, f.callbackURL) { http.Error(writer, "callback binding mismatch", http.StatusBadRequest) return } - select { - case f.payload <- payload: + accepted := false + f.callbackOnce.Do(func() { + f.payload <- payload + accepted = true + }) + if accepted { writer.Header().Set("Content-Type", "application/json") writer.WriteHeader(http.StatusOK) _, _ = writer.Write([]byte(`{"ok":true}`)) - default: - http.Error(writer, "callback already received", http.StatusConflict) + return + } + http.Error(writer, "callback already received", http.StatusConflict) +} + +func validCallbackValue(value string, maxLength int) bool { + return value != "" && len(value) <= maxLength && strings.TrimSpace(value) == value +} + +func validTokenID(value string) bool { + if len(value) == 0 || len(value) > 128 { + return false } + for index := range len(value) { + character := value[index] + if (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') || + (character >= '0' && character <= '9') || strings.ContainsRune(":._-", rune(character)) { + continue + } + return false + } + return true +} + +func validAccountBinding(value string) bool { + decoded, err := base64.RawURLEncoding.DecodeString(value) + return err == nil && len(decoded) == 16 && len(value) == 22 && + constantTimeEqual(value, base64.RawURLEncoding.EncodeToString(decoded)) } func (f *browserFlow) validRequestTarget(request *http.Request) bool { @@ -196,7 +257,27 @@ func (f *browserFlow) validRequestTarget(request *http.Request) bool { } query := request.URL.Query() states, ok := query["state"] - return ok && len(query) == 1 && len(states) == 1 && constantTimeEqual(states[0], f.state) + if !ok || len(states) != 1 || !constantTimeEqual(states[0], f.state) { + return false + } + for key, values := range query { + if key == "state" { + continue + } + // The site's security runtime appends these transport-only query + // parameters to cross-origin requests. They are not part of the + // callback binding: state, Origin, callback_url, and the body secret + // remain exact and independently verified below. + if key != "a_bogus" && key != "msToken" || len(values) != 1 || + !validInjectedCallbackQueryValue(values[0]) { + return false + } + } + return true +} + +func validInjectedCallbackQueryValue(value string) bool { + return value != "" && len(value) <= maxInjectedCallbackQueryValueBytes && strings.TrimSpace(value) == value } func (f *browserFlow) callbackURLHost() string { diff --git a/internal/auth/manager.go b/internal/auth/manager.go index 71eaede..5bf0e8f 100644 --- a/internal/auth/manager.go +++ b/internal/auth/manager.go @@ -1,146 +1,355 @@ package auth -//import ( -// "context" -// "errors" -// "fmt" -// "net/http" -// "sync" -// "time" -// -// "code.byted.org/passport/auth_client/go/authsdk" -// -// "github.com/Pippit-dev/pippit-cli/internal/config" -//) -// -//type Authorizer interface { -// Refresh(ctx context.Context, ensureTTL time.Duration) error -// Inject(ctx context.Context, req *http.Request) error -// NewLoginFlow(ctx context.Context) (*LoginFlow, error) -// CheckLogin(ctx context.Context, deviceCode string) (*State, error) -// State(ctx context.Context) (*State, error) -// Logout(ctx context.Context) error -//} -// -//type OAuthManager struct { -// cfg *config.Config -// mu sync.Mutex -// client *authsdk.Client -//} -// -//type LoginFlow struct { -// DeviceCode string `json:"device_code"` -// UserCode string `json:"user_code"` -// VerificationURI string `json:"verification_uri"` -//} -// -//type State struct { -// LoggedIn bool `json:"logged_in"` -// ExpiresAt time.Time `json:"expires_at,omitempty"` -//} -// -//func NewManager(cfg *config.Config) *OAuthManager { -// return &OAuthManager{cfg: cfg} -//} -// -//func (m *OAuthManager) NewLoginFlow(ctx context.Context) (*LoginFlow, error) { -// client, err := m.clientInstance() -// if err != nil { -// return nil, err -// } -// flow, err := client.Authenticator().NewLoginFlow(ctx) -// if err != nil { -// return nil, err -// } -// return &LoginFlow{ -// DeviceCode: flow.DeviceCode, -// UserCode: flow.UserCode, -// VerificationURI: flow.VerificationURI, -// }, nil -//} -// -//func (m *OAuthManager) CheckLogin(ctx context.Context, deviceCode string) (*State, error) { -// client, err := m.clientInstance() -// if err != nil { -// return nil, err -// } -// state, err := client.Authenticator().CheckLogin(ctx, deviceCode) -// if err != nil { -// return nil, err -// } -// return authState(state), nil -//} -// -//func (m *OAuthManager) State(ctx context.Context) (*State, error) { -// client, err := m.clientInstance() -// if err != nil { -// return nil, err -// } -// state, err := client.Authorizer().State(ctx) -// if err != nil { -// return nil, err -// } -// return authState(state), nil -//} -// -//func (m *OAuthManager) Refresh(ctx context.Context, ensureTTL time.Duration) error { -// client, err := m.clientInstance() -// if err != nil { -// return err -// } -// _, err = client.Authorizer().Refresh(ctx, ensureTTL) -// return err -//} -// -//func (m *OAuthManager) Inject(ctx context.Context, req *http.Request) error { -// client, err := m.clientInstance() -// if err != nil { -// return err -// } -// return client.Authorizer().Inject(ctx, req) -//} -// -//func (m *OAuthManager) Logout(ctx context.Context) error { -// client, err := m.clientInstance() -// if err != nil { -// return err -// } -// return client.Authorizer().Logout(ctx) -//} -// -//func IsLoginPending(err error) bool { -// return errors.Is(err, authsdk.ErrLoginPending) -//} -// -//func (m *OAuthManager) clientInstance() (*authsdk.Client, error) { -// m.mu.Lock() -// defer m.mu.Unlock() -// if m.client != nil { -// return m.client, nil -// } -// if m.cfg == nil || m.cfg.OAuth == nil { -// return nil, errors.New("oauth config is required") -// } -// oauth := m.cfg.OAuth -// client, err := authsdk.NewClient(authsdk.Config{ -// ClientKey: oauth.ClientKey, -// BaseURL: oauth.BaseURL, -// StoreServiceName: oauth.StoreServiceName, -// Scopes: oauth.Scopes, -// }) -// if err != nil { -// return nil, fmt.Errorf("initialize oauth client: %w", err) -// } -// m.client = client -// return client, nil -//} -// -//func authState(state *authsdk.AuthStateView) *State { -// if state == nil { -// return &State{} -// } -// return &State{ -// LoggedIn: state.LoggedIn, -// ExpiresAt: state.ExpiresAt, -// } -//} +import ( + "context" + "crypto/rand" + "errors" + "fmt" + "io" + "net/url" + "strings" + "sync" + "time" + + "github.com/Pippit-dev/pippit-cli/internal/config" +) + +type Manager struct { + cfg *config.Config + store CredentialStore + authBaseURL *url.URL + random io.Reader + now func() time.Time + credentialMu sync.Mutex + cachedCredential *Credential + credentialCacheLoaded bool +} + +type ManagerOption func(*Manager) + +func WithCredentialStore(store CredentialStore) ManagerOption { + return func(manager *Manager) { + if store != nil { + manager.store = store + } + } +} + +func withRandomReaderForTest(reader io.Reader) ManagerOption { + return func(manager *Manager) { + if reader != nil { + manager.random = reader + } + } +} + +func withClockForTest(now func() time.Time) ManagerOption { + return func(manager *Manager) { + if now != nil { + manager.now = now + } + } +} + +// NewManager always uses the canonical production login page. cfg.BaseURL and +// cfg.PPEEnv intentionally do not affect browser login; PPE routing applies +// only to business requests after the page has returned an Access Key. +func NewManager(cfg *config.Config, options ...ManagerOption) *Manager { + serviceName := config.DefaultAuthStoreServiceName + authBaseURL, _ := url.Parse(config.DefaultBaseURL) + manager := &Manager{ + cfg: cfg, + store: NewDefaultCredentialStore(serviceName), + authBaseURL: authBaseURL, + random: rand.Reader, + now: time.Now, + } + for _, option := range options { + if option != nil { + option(manager) + } + } + return manager +} + +func (m *Manager) ResolveAccessKey(ctx context.Context) (string, error) { + if m != nil && m.cfg != nil { + if accessKey := strings.TrimSpace(m.cfg.AccessKey); accessKey != "" { + return accessKey, nil + } + } + credential, err := m.loadCredential(ctx) + if err != nil { + return "", err + } + if credential.AccessKey == "" { + return "", ErrCredentialNotFound + } + if credential.ExpiredAt <= m.now().Add(m.ensureTTL()).Unix() { + return "", ErrCredentialExpired + } + return credential.AccessKey, nil +} + +func (m *Manager) Login(ctx context.Context, options LoginOptions) (*Credential, error) { + if err := m.validate(); err != nil { + return nil, err + } + identity, err := m.ensureIdentity(ctx) + if err != nil { + return nil, err + } + forceRefresh := options.ForceRefresh && strings.TrimSpace(identity.TokenID) != "" + expectedAccount := "" + if identity.UID != "" { + expectedAccount = accountBinding(identity.UID) + } + if expectedScope := strings.TrimSpace(options.ExpectedCredentialScope); expectedScope != "" { + scopeAccount, ok := accountBindingFromCredentialScope(expectedScope, identity.DeviceID) + if !ok || (expectedAccount != "" && !constantTimeEqual(expectedAccount, scopeAccount)) { + return nil, ErrCredentialAccountMismatch + } + expectedAccount = scopeAccount + } + flow, err := startBrowserFlow( + m.authBaseURL, m.random, identity.DeviceID, identity.TokenID, expectedAccount, forceRefresh, + ) + if err != nil { + return nil, err + } + defer flow.close() + + writeProgress(options.Progress, "正在打开小云雀网页授权…") + opener := options.OpenURL + if opener == nil { + opener = OpenBrowser + } + if err := opener(flow.loginURL); err != nil { + return nil, errors.New("无法自动打开浏览器,请检查系统默认浏览器设置后重试") + } + writeProgress(options.Progress, "请在浏览器中完成登录和授权,CLI 会自动继续…") + + timeout := options.Timeout + if timeout <= 0 { + timeout = DefaultLoginTimeout + } + waitCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + payload, err := flow.wait(waitCtx) + if err != nil { + return nil, err + } + writeProgress(options.Progress, "网页授权已完成,正在安全保存本机 CLI 凭证…") + credential := credentialFromCallback(identity, payload) + if credential.ExpiredAt <= m.now().Add(m.ensureTTL()).Unix() { + return nil, ErrCredentialExpired + } + if expected := strings.TrimSpace(options.ExpectedCredentialScope); expected != "" && + !constantTimeEqual(expected, credential.CredentialScope) { + return nil, ErrCredentialAccountMismatch + } + if forceRefresh && + ((identity.AccessKey != "" && constantTimeEqual(identity.AccessKey, credential.AccessKey)) || + constantTimeEqual(identity.TokenID, credential.TokenID)) { + return nil, errors.New("网页授权未轮换已失效的 CLI Access Key,已拒绝继续使用旧凭证") + } + if err := validateCredential(credential); err != nil { + return nil, errors.New("网页返回的 CLI 凭证格式无效") + } + if err := m.saveCredential(waitCtx, credential); err != nil { + return nil, err + } + writeProgress(options.Progress, "小云雀 CLI 登录成功。") + return cloneCredential(credential), nil +} + +func credentialFromCallback(identity *Credential, payload accessKeyPayload) *Credential { + credential := cloneCredential(identity) + credential.AccessKey = payload.AccessKey + credential.TokenID = payload.TokenID + credential.UID = payload.UID + credential.ExpiredAt = payload.ExpiredAt + credential.CredentialScope = credentialScope(payload.UID, identity.DeviceID) + return credential +} + +func (m *Manager) Status(ctx context.Context) (*Status, error) { + if m != nil && m.cfg != nil && strings.TrimSpace(m.cfg.AccessKey) != "" { + return &Status{LoggedIn: true, Source: "environment"}, nil + } + credential, err := m.loadCredential(ctx) + if errors.Is(err, ErrCredentialNotFound) { + return &Status{}, nil + } + if err != nil { + return nil, err + } + status := &Status{ + Source: "browser", + UID: credential.UID, + TokenID: credential.TokenID, + CredentialScope: credential.CredentialScope, + } + if credential.ExpiredAt > 0 { + status.ExpiresAt = time.Unix(credential.ExpiredAt, 0) + } + status.LoggedIn = credential.AccessKey != "" && credential.ExpiredAt > m.now().Add(m.ensureTTL()).Unix() + return status, nil +} + +func (m *Manager) Logout(ctx context.Context, revoke bool) error { + if revoke { + // The current AK management endpoint requires a browser/team session and + // cannot be safely called using the Access Key that would be revoked. + return ErrRemoteRevokeUnsupported + } + credential, err := m.loadCredential(ctx) + if errors.Is(err, ErrCredentialNotFound) { + m.clearCredentialCache() + return nil + } + if err != nil { + return err + } + // Keep the non-secret device identity so a later login can safely reuse the + // same remote token instead of consuming another per-user AK slot. Logout + // only clears the local secret and account binding; it does not revoke the + // remote Access Key. + if err := m.saveCredential(ctx, identityOnly(credential)); err != nil { + return err + } + m.clearCredentialCache() + return nil +} + +func (m *Manager) CredentialScope(ctx context.Context) (string, error) { + credential, err := m.loadCredential(ctx) + if err != nil { + return "", err + } + if credential.AccessKey == "" || strings.TrimSpace(credential.UID) == "" { + return "", ErrCredentialNotFound + } + return credentialScope(credential.UID, credential.DeviceID), nil +} + +func (m *Manager) ensureIdentity(ctx context.Context) (*Credential, error) { + credential, err := m.loadCredential(ctx) + if err == nil { + return credential, nil + } + if !errors.Is(err, ErrCredentialNotFound) { + return nil, err + } + identity, err := newIdentity(m.random) + if err != nil { + return nil, err + } + if err := m.saveCredential(ctx, identity); err != nil { + return nil, err + } + return identity, nil +} + +func (m *Manager) loadCredential(ctx context.Context) (*Credential, error) { + if err := m.validate(); err != nil { + return nil, err + } + m.credentialMu.Lock() + defer m.credentialMu.Unlock() + if m.credentialCacheLoaded { + if m.cachedCredential == nil { + return nil, ErrCredentialNotFound + } + return cloneCredential(m.cachedCredential), nil + } + credential, err := m.store.Load(ctx) + if err != nil { + if errors.Is(err, ErrCredentialNotFound) { + m.credentialCacheLoaded = true + m.cachedCredential = nil + } + return nil, err + } + credential = normalizeCredential(credential) + m.cachedCredential = cloneCredential(credential) + m.credentialCacheLoaded = true + return cloneCredential(credential), nil +} + +func (m *Manager) saveCredential(ctx context.Context, credential *Credential) error { + if err := m.validate(); err != nil { + return err + } + credential = normalizeCredential(credential) + if err := m.store.Save(ctx, credential); err != nil { + m.clearCredentialCache() + return err + } + m.credentialMu.Lock() + m.cachedCredential = cloneCredential(credential) + m.credentialCacheLoaded = true + m.credentialMu.Unlock() + return nil +} + +func (m *Manager) clearCredentialCache() { + if m == nil { + return + } + m.credentialMu.Lock() + m.cachedCredential = nil + m.credentialCacheLoaded = false + m.credentialMu.Unlock() +} + +func normalizeCredential(credential *Credential) *Credential { + credential = cloneCredential(credential) + if credential == nil { + return nil + } + if credential.AccessKey == "" { + credential.CredentialScope = "" + return credential + } + credential.CredentialScope = credentialScope(credential.UID, credential.DeviceID) + return credential +} + +func (m *Manager) validate() error { + if m == nil || m.store == nil || m.authBaseURL == nil || m.random == nil || m.now == nil { + return errors.New("小云雀 CLI 授权管理器未正确初始化") + } + if m.authBaseURL.Scheme != "https" && !(m.authBaseURL.Scheme == "http" && isLoopbackHost(m.authBaseURL.Hostname())) { + return errors.New("小云雀授权地址必须使用 HTTPS") + } + if m.authBaseURL.Host == "" || m.authBaseURL.RawQuery != "" || m.authBaseURL.Fragment != "" { + return errors.New("小云雀授权地址无效") + } + return nil +} + +func (m *Manager) ensureTTL() time.Duration { + if m != nil && m.cfg != nil && m.cfg.AuthTTL > 0 { + return m.cfg.AuthTTL + } + return config.DefaultAuthTTL +} + +func isLoopbackHost(host string) bool { + return host == "127.0.0.1" || strings.EqualFold(host, "localhost") || host == "::1" +} + +func writeProgress(writer io.Writer, message string) { + if writer != nil { + _, _ = fmt.Fprintln(writer, message) + } +} + +func cloneCredential(credential *Credential) *Credential { + if credential == nil { + return nil + } + copy := *credential + return © +} diff --git a/internal/auth/store.go b/internal/auth/store.go index 970310b..d892745 100644 --- a/internal/auth/store.go +++ b/internal/auth/store.go @@ -99,7 +99,9 @@ type storedCredential struct { Version int `json:"version"` DeviceID string `json:"device_id"` CredentialScope string `json:"credential_scope"` - TokenName string `json:"token_name"` + // LegacyTokenName is decoded only for compatibility with credentials written + // by the first browser-auth beta. New records intentionally omit it. + LegacyTokenName string `json:"token_name,omitempty"` AccessKey string `json:"access_key,omitempty"` TokenID string `json:"token_id,omitempty"` UID string `json:"uid,omitempty"` @@ -148,7 +150,10 @@ func (s *resilientCredentialStore) Load(ctx context.Context) (*Credential, error if fallbackErr == nil { return credential, nil } - if errors.Is(primaryErr, ErrCredentialNotFound) && errors.Is(fallbackErr, ErrCredentialNotFound) { + if errors.Is(fallbackErr, ErrCredentialNotFound) { + // An available, empty fallback is the active store when the primary + // keyring is unavailable. Report a fresh login state so Manager can + // create the device identity and Save can persist it to that fallback. return nil, ErrCredentialNotFound } if !errors.Is(fallbackErr, ErrCredentialNotFound) { @@ -209,7 +214,6 @@ func encodeCredential(credential *Credential) ([]byte, error) { Version: credential.Version, DeviceID: credential.DeviceID, CredentialScope: credential.CredentialScope, - TokenName: credential.TokenName, AccessKey: credential.AccessKey, TokenID: credential.TokenID, UID: credential.UID, @@ -233,7 +237,6 @@ func decodeCredential(payload []byte) (*Credential, error) { Version: record.Version, DeviceID: record.DeviceID, CredentialScope: record.CredentialScope, - TokenName: record.TokenName, AccessKey: record.AccessKey, TokenID: record.TokenID, UID: record.UID, @@ -277,18 +280,18 @@ func validateCredential(credential *Credential) error { if !validDeviceID(credential.DeviceID) { return errors.New("本机登录设备标识无效") } - if !constantTimeEqual(credential.TokenName, tokenNameForDevice(credential.DeviceID)) { - return errors.New("本机登录凭证作用域无效") - } if credential.AccessKey == "" { - if strings.TrimSpace(credential.TokenID) != credential.TokenID || credential.UID != "" || credential.ExpiredAt != 0 || - (credential.CredentialScope != "" && !constantTimeEqual(credential.CredentialScope, legacyCredentialScope(credential.DeviceID))) { + // TokenID is non-secret. Keeping it lets an explicit force login select + // exactly this device token without guessing by display name. + if (credential.TokenID != "" && !validTokenID(credential.TokenID)) || credential.UID != "" || + credential.ExpiredAt != 0 || credential.CredentialScope != "" { return errors.New("本机登录凭证不完整") } return nil } - if strings.TrimSpace(credential.AccessKey) != credential.AccessKey || - credential.TokenID == "" || credential.UID == "" || credential.ExpiredAt <= 0 { + if strings.TrimSpace(credential.AccessKey) != credential.AccessKey || len(credential.AccessKey) > 4096 || + !validTokenID(credential.TokenID) || + credential.UID == "" || len(credential.UID) > 256 || strings.TrimSpace(credential.UID) != credential.UID || credential.ExpiredAt <= 0 { return errors.New("本机登录凭证不完整") } expectedScope := credentialScope(credential.UID, credential.DeviceID) diff --git a/internal/auth/types.go b/internal/auth/types.go index 8786e3f..47c7ae1 100644 --- a/internal/auth/types.go +++ b/internal/auth/types.go @@ -8,19 +8,14 @@ import ( ) const ( - loginExportPath = "/cli/login-export" - callbackPath = "/xyq/callback/save_session" - exchangeGrantPath = "/api/web/v1/auth/exchange_cli_login_grant" - queryAccessKeyPath = "/api/biz/v1/user/query_ak" - generateAccessKeyPath = "/api/biz/v1/user/generate_ak" - deleteAccessKeyPath = "/api/biz/v1/user/delete_ak" - loginSource = "pippit-tool-cli" - credentialVersion = 1 - deviceIDBytes = 32 - randomBindingBytes = 32 + loginPagePath = "/cli/pippit-tool-login" + callbackPath = "/pippit-tool/callback" + loginSource = "pippit-tool-cli" + credentialVersion = 1 + deviceIDBytes = 32 + randomBindingBytes = 32 - DefaultLoginTimeout = 5 * time.Minute - DefaultCredentialLifetime = 365 * 24 * time.Hour + DefaultLoginTimeout = 5 * time.Minute ) var ( @@ -37,7 +32,6 @@ type Credential struct { Version int `json:"version"` DeviceID string `json:"device_id"` CredentialScope string `json:"credential_scope"` - TokenName string `json:"token_name"` AccessKey string `json:"-"` TokenID string `json:"token_id,omitempty"` UID string `json:"uid,omitempty"` @@ -57,13 +51,12 @@ type LoginOptions struct { OpenURL func(string) error Progress io.Writer Timeout time.Duration - // ForceRefresh rotates every remote token owned by this device identity - // before provisioning a replacement. It is used after an explicit 401 so - // a successful browser login can never return the just-rejected AK again. + // ForceRefresh asks the browser page to rotate this device's rejected AK. + // The CLI never calls QueryAk, DeleteAk, or GenerateAk itself. ForceRefresh bool // ExpectedCredentialScope binds reauthentication to the UID and device that - // started a durable operation. A different browser account fails before any - // Access Key is deleted or generated. + // started a durable operation. A different browser account fails before the + // returned Access Key is saved. ExpectedCredentialScope string } From 8bb8406ff58b0def14540501537356358860f052 Mon Sep 17 00:00:00 2001 From: "xuyan.smackgg" Date: Wed, 12 Aug 2026 18:13:06 +0800 Subject: [PATCH 09/14] test(auth): cover secure browser credential delivery Co-authored-by: Codex --- internal/auth/auth_test.go | 747 +++++++++++++++++++++++++++++++++++++ 1 file changed, 747 insertions(+) create mode 100644 internal/auth/auth_test.go diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..e4a2945 --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,747 @@ +package auth + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + "time" + + "github.com/Pippit-dev/pippit-cli/internal/config" +) + +type memoryCredentialStore struct { + mu sync.Mutex + credential *Credential + loadErr error + saveErr error + deleteErr error + loads int + saves int + deletes int +} + +func (s *memoryCredentialStore) Load(ctx context.Context) (*Credential, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + s.mu.Lock() + defer s.mu.Unlock() + s.loads++ + if s.loadErr != nil { + return nil, s.loadErr + } + if s.credential == nil { + return nil, ErrCredentialNotFound + } + return cloneCredential(s.credential), nil +} + +func (s *memoryCredentialStore) Save(ctx context.Context, credential *Credential) error { + if err := ctx.Err(); err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + s.saves++ + if s.saveErr != nil { + return s.saveErr + } + s.credential = cloneCredential(credential) + return nil +} + +func (s *memoryCredentialStore) Delete(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + s.deletes++ + if s.deleteErr != nil { + return s.deleteErr + } + if s.credential == nil { + return ErrCredentialNotFound + } + s.credential = nil + return nil +} + +func TestBrowserFlowUsesDedicatedPageAndStrictBoundCallback(t *testing.T) { + authURL, _ := url.Parse(config.DefaultBaseURL) + deviceID, _ := randomEncoded(bytes.NewReader(bytes.Repeat([]byte{0x21}, deviceIDBytes)), deviceIDBytes) + flow, err := startBrowserFlow( + authURL, bytes.NewReader(bytes.Repeat([]byte{0x42}, 2*randomBindingBytes)), + deviceID, "saved-token-id", accountBinding("saved-user"), true, + ) + if err != nil { + t.Fatal(err) + } + defer flow.close() + + loginURL, err := url.Parse(flow.loginURL) + if err != nil { + t.Fatal(err) + } + if loginURL.Scheme+"://"+loginURL.Host != config.DefaultBaseURL || loginURL.Path != loginPagePath { + t.Fatalf("login URL target = %s://%s%s", loginURL.Scheme, loginURL.Host, loginURL.Path) + } + query := loginURL.Query() + if query.Get("source") != loginSource || query.Get("device_id") != deviceID || query.Get("force") != "1" || + query.Get("token_id") != "saved-token-id" || query.Get("expected_account") != accountBinding("saved-user") { + t.Fatalf("login URL public binding is incomplete: %v", query) + } + for _, field := range []string{"random_secret_key"} { + decoded, decodeErr := base64.RawURLEncoding.DecodeString(query.Get(field)) + if decodeErr != nil || len(decoded) != randomBindingBytes || len(query.Get(field)) != 43 { + t.Fatalf("%s is not canonical 32-byte Base64URL", field) + } + } + callbackURL := query.Get("callback") + parsedCallback, err := url.Parse(callbackURL) + if err != nil { + t.Fatal(err) + } + state := parsedCallback.Query().Get("state") + decodedState, decodeErr := base64.RawURLEncoding.DecodeString(state) + if parsedCallback.Scheme != "http" || parsedCallback.Hostname() != "127.0.0.1" || parsedCallback.Path != callbackPath || + decodeErr != nil || len(decodedState) != randomBindingBytes || len(state) != 43 { + t.Fatalf("callback is not canonical: %q", callbackURL) + } + + payload := accessKeyPayload{ + Type: "access_key", + AccessKey: "callback-ak-secret", + UID: "12345", + TokenID: "token-id", + ExpiredAt: time.Now().Add(time.Hour).Unix(), + RandomSecretKey: query.Get("random_secret_key"), + Source: loginSource, + CallbackURL: callbackURL, + } + if status := sendCallback(t, callbackURL, "https://evil.example", payload); status != http.StatusForbidden { + t.Fatalf("wrong origin status = %d", status) + } + wrongSecret := payload + wrongSecret.RandomSecretKey = "wrong-secret" + if status := sendCallback(t, callbackURL, config.DefaultBaseURL, wrongSecret); status != http.StatusBadRequest { + t.Fatalf("wrong secret status = %d", status) + } + if status := postRawCallback(t, callbackURL, config.DefaultBaseURL, `{"type":"access_key","unknown":true}`); status != http.StatusBadRequest { + t.Fatalf("unknown field status = %d", status) + } + if status := sendCallback(t, callbackURL+"&state=duplicate", config.DefaultBaseURL, payload); status != http.StatusBadRequest { + t.Fatalf("duplicate state status = %d", status) + } + if status := sendCallback(t, callbackURL+"&unexpected=value", config.DefaultBaseURL, payload); status != http.StatusBadRequest { + t.Fatalf("unexpected transport query status = %d", status) + } + if status := sendPreflight(t, callbackURL, config.DefaultBaseURL); status != http.StatusNoContent { + t.Fatalf("preflight status = %d", status) + } + securityRuntimeCallback := callbackURL + "&a_bogus=signed-request&msToken=transport-token" + if status := sendCallback(t, securityRuntimeCallback, config.DefaultBaseURL, payload); status != http.StatusOK { + t.Fatalf("security-runtime callback status = %d", status) + } + got, err := flow.wait(context.Background()) + if err != nil || got.AccessKey != payload.AccessKey || got.UID != payload.UID || got.TokenID != payload.TokenID { + t.Fatalf("callback payload = %#v, %v", credentialPayloadWithoutSecret(got), err) + } + if status := sendCallback(t, callbackURL, config.DefaultBaseURL, payload); status != http.StatusConflict { + t.Fatalf("replayed callback status = %d", status) + } +} + +func TestManagerLoginStoresPageIssuedCredentialWithoutServerExchange(t *testing.T) { + fixedNow := time.Unix(1_800_000_000, 0) + store := &memoryCredentialStore{} + cfg := config.Load() + cfg.AccessKey = "" + manager := NewManager( + cfg, + WithCredentialStore(store), + withRandomReaderForTest(bytes.NewReader(bytes.Repeat([]byte{0x31}, deviceIDBytes+2*randomBindingBytes))), + withClockForTest(func() time.Time { return fixedNow }), + ) + var progress bytes.Buffer + var callbackSecret string + credential, err := manager.Login(context.Background(), LoginOptions{ + Progress: &progress, + OpenURL: func(rawURL string) error { + loginURL, parseErr := url.Parse(rawURL) + if parseErr != nil { + return parseErr + } + if loginURL.Path != loginPagePath || loginURL.Query().Get("force") != "" { + return fmt.Errorf("unexpected login URL") + } + callbackSecret = loginURL.Query().Get("random_secret_key") + payload := accessKeyPayload{ + Type: "access_key", + AccessKey: "page-issued-ak", + UID: "user-100", + TokenID: "ak-id-100", + ExpiredAt: fixedNow.Add(time.Hour).Unix(), + RandomSecretKey: callbackSecret, + Source: loginSource, + CallbackURL: loginURL.Query().Get("callback"), + } + if status := sendCallback(t, payload.CallbackURL, config.DefaultBaseURL, payload); status != http.StatusOK { + return fmt.Errorf("callback status %d", status) + } + return nil + }, + }) + if err != nil { + t.Fatal(err) + } + if credential.AccessKey != "page-issued-ak" || credential.UID != "user-100" || credential.TokenID != "ak-id-100" || + credential.CredentialScope != credentialScope("user-100", credential.DeviceID) { + t.Fatalf("credential = %#v", credentialWithoutSecret(credential)) + } + store.mu.Lock() + stored := cloneCredential(store.credential) + store.mu.Unlock() + if stored == nil || stored.AccessKey != credential.AccessKey { + t.Fatalf("stored credential = %#v", credentialWithoutSecret(stored)) + } + for _, secret := range []string{credential.AccessKey, callbackSecret} { + if strings.Contains(progress.String(), secret) { + t.Fatalf("progress leaked a secret: %q", progress.String()) + } + } +} + +func TestManagerReauthenticationPinsAccountAndRequiresRotation(t *testing.T) { + fixedNow := time.Unix(1_800_000_000, 0) + old, _ := newIdentity(bytes.NewReader(bytes.Repeat([]byte{0x33}, deviceIDBytes))) + old.AccessKey = "rejected-ak" + old.TokenID = "rejected-id" + old.UID = "account-a" + old.ExpiredAt = fixedNow.Add(time.Hour).Unix() + old.CredentialScope = credentialScope(old.UID, old.DeviceID) + + t.Run("different account is not saved", func(t *testing.T) { + store := &memoryCredentialStore{credential: cloneCredential(old)} + manager := NewManager(config.Load(), WithCredentialStore(store), withClockForTest(func() time.Time { return fixedNow })) + _, err := manager.Login(context.Background(), LoginOptions{ + ForceRefresh: true, + ExpectedCredentialScope: old.CredentialScope, + OpenURL: callbackOpener(t, fixedNow.Add(time.Hour), "account-b", "new-id", "new-ak", true), + }) + if !errors.Is(err, ErrCredentialAccountMismatch) { + t.Fatalf("mismatch error = %v", err) + } + if store.credential.AccessKey != old.AccessKey || store.saves != 0 { + t.Fatal("account mismatch overwrote the stored credential") + } + }) + + t.Run("same rejected token is refused", func(t *testing.T) { + store := &memoryCredentialStore{credential: cloneCredential(old)} + manager := NewManager(config.Load(), WithCredentialStore(store), withClockForTest(func() time.Time { return fixedNow })) + _, err := manager.Login(context.Background(), LoginOptions{ + ForceRefresh: true, + ExpectedCredentialScope: old.CredentialScope, + OpenURL: callbackOpener(t, fixedNow.Add(time.Hour), old.UID, old.TokenID, old.AccessKey, true), + }) + if err == nil || !strings.Contains(err.Error(), "未轮换") { + t.Fatalf("same-token force login error = %v", err) + } + if strings.Contains(err.Error(), old.AccessKey) { + t.Fatal("force-login error leaked the rejected Access Key") + } + }) + + t.Run("rotated token is accepted", func(t *testing.T) { + store := &memoryCredentialStore{credential: cloneCredential(old)} + manager := NewManager(config.Load(), WithCredentialStore(store), withClockForTest(func() time.Time { return fixedNow })) + got, err := manager.Login(context.Background(), LoginOptions{ + ForceRefresh: true, + ExpectedCredentialScope: old.CredentialScope, + OpenURL: callbackOpener(t, fixedNow.Add(time.Hour), old.UID, "rotated-id", "rotated-ak", true), + }) + if err != nil || got.AccessKey != "rotated-ak" || got.CredentialScope != old.CredentialScope { + t.Fatalf("rotated credential = %#v, %v", credentialWithoutSecret(got), err) + } + }) +} + +func TestBrowserFlowRequiresExpectedAccountForForceAndNeverUsesRawUID(t *testing.T) { + authURL, _ := url.Parse(config.DefaultBaseURL) + deviceID, _ := randomEncoded(bytes.NewReader(bytes.Repeat([]byte{0x22}, deviceIDBytes)), deviceIDBytes) + if _, err := startBrowserFlow( + authURL, bytes.NewReader(bytes.Repeat([]byte{0x42}, 2*randomBindingBytes)), + deviceID, "saved-token-id", "", true, + ); err == nil || !strings.Contains(err.Error(), "登录账号") { + t.Fatalf("force flow without account binding error = %v", err) + } + + const rawUID = "raw-user-id-must-not-be-in-url" + flow, err := startBrowserFlow( + authURL, bytes.NewReader(bytes.Repeat([]byte{0x43}, 2*randomBindingBytes)), + deviceID, "saved-token-id", accountBinding(rawUID), true, + ) + if err != nil { + t.Fatal(err) + } + defer flow.close() + if strings.Contains(flow.loginURL, rawUID) { + t.Fatal("login URL exposed raw UID") + } + parsed, err := url.Parse(flow.loginURL) + if err != nil { + t.Fatal(err) + } + if got := parsed.Query().Get("expected_account"); got != accountBinding(rawUID) || !validAccountBinding(got) { + t.Fatalf("expected_account = %q", got) + } +} + +func TestBrowserFlowSendsStoredTokenIDOnNormalLogin(t *testing.T) { + authURL, _ := url.Parse(config.DefaultBaseURL) + deviceID, _ := randomEncoded(bytes.NewReader(bytes.Repeat([]byte{0x23}, deviceIDBytes)), deviceIDBytes) + flow, err := startBrowserFlow( + authURL, bytes.NewReader(bytes.Repeat([]byte{0x44}, 2*randomBindingBytes)), + deviceID, "existing-token-id", accountBinding("existing-user"), false, + ) + if err != nil { + t.Fatal(err) + } + defer flow.close() + parsed, err := url.Parse(flow.loginURL) + if err != nil { + t.Fatal(err) + } + if parsed.Query().Get("token_id") != "existing-token-id" || parsed.Query().Get("force") != "" { + t.Fatalf("normal login query = %v", parsed.Query()) + } +} + +func TestBrowserFlowAllowsLogoutIdentityTokenWithoutAccountBinding(t *testing.T) { + authURL, _ := url.Parse(config.DefaultBaseURL) + deviceID, _ := randomEncoded(bytes.NewReader(bytes.Repeat([]byte{0x24}, deviceIDBytes)), deviceIDBytes) + flow, err := startBrowserFlow( + authURL, bytes.NewReader(bytes.Repeat([]byte{0x45}, 2*randomBindingBytes)), + deviceID, "existing-token-id", "", false, + ) + if err != nil { + t.Fatal(err) + } + defer flow.close() + parsed, err := url.Parse(flow.loginURL) + if err != nil { + t.Fatal(err) + } + if parsed.Query().Get("token_id") != "existing-token-id" || parsed.Query().Get("expected_account") != "" || + parsed.Query().Get("force") != "" { + t.Fatalf("logout relogin query = %v", parsed.Query()) + } +} + +func TestManagerRejectsExpiredPageCredential(t *testing.T) { + fixedNow := time.Unix(1_800_000_000, 0) + store := &memoryCredentialStore{} + manager := NewManager(config.Load(), WithCredentialStore(store), withClockForTest(func() time.Time { return fixedNow })) + _, err := manager.Login(context.Background(), LoginOptions{ + OpenURL: callbackOpener(t, fixedNow.Add(-time.Hour), "user", "id", "ak", false), + }) + if !errors.Is(err, ErrCredentialExpired) { + t.Fatalf("expired callback error = %v", err) + } + // The fresh device identity is safe to retain, but the expired AK must not be saved. + if store.credential == nil || store.credential.AccessKey != "" { + t.Fatalf("expired callback was saved: %#v", credentialWithoutSecret(store.credential)) + } +} + +func TestManagerDerivesExpectedAccountFromDurableScope(t *testing.T) { + fixedNow := time.Unix(1_800_000_000, 0) + identity, _ := newIdentity(bytes.NewReader(bytes.Repeat([]byte{0x25}, deviceIDBytes))) + identity.TokenID = "saved-token-id" + store := &memoryCredentialStore{credential: identity} + manager := NewManager(config.Load(), WithCredentialStore(store), withClockForTest(func() time.Time { return fixedNow })) + expectedScope := credentialScope("durable-user", identity.DeviceID) + _, err := manager.Login(context.Background(), LoginOptions{ + ForceRefresh: true, + ExpectedCredentialScope: expectedScope, + OpenURL: func(rawURL string) error { + loginURL, parseErr := url.Parse(rawURL) + if parseErr != nil { + return parseErr + } + if got := loginURL.Query().Get("expected_account"); got != accountBinding("durable-user") || + loginURL.Query().Get("force") != "1" || loginURL.Query().Get("token_id") != identity.TokenID || + strings.Contains(rawURL, "durable-user") { + return fmt.Errorf("durable reauth binding is incomplete") + } + payload := accessKeyPayload{ + Type: "access_key", + AccessKey: "new-ak", + UID: "durable-user", + TokenID: "rotated-token-id", + ExpiredAt: fixedNow.Add(time.Hour).Unix(), + RandomSecretKey: loginURL.Query().Get("random_secret_key"), + Source: loginSource, + CallbackURL: loginURL.Query().Get("callback"), + } + if status := sendCallback(t, payload.CallbackURL, config.DefaultBaseURL, payload); status != http.StatusOK { + return fmt.Errorf("callback status %d", status) + } + return nil + }, + }) + if err != nil { + t.Fatal(err) + } +} + +func TestResolveStatusLogoutAndAuthOrigin(t *testing.T) { + fixedNow := time.Unix(1_800_000_000, 0) + credential, _ := newIdentity(bytes.NewReader(bytes.Repeat([]byte{3}, deviceIDBytes))) + credential.AccessKey = "stored-ak" + credential.TokenID = "stored-id" + credential.UID = "789" + credential.CredentialScope = credentialScope(credential.UID, credential.DeviceID) + credential.ExpiredAt = fixedNow.Add(time.Hour).Unix() + store := &memoryCredentialStore{credential: credential} + cfg := config.Load() + cfg.AccessKey = " env-ak " + cfg.BaseURL = "https://untrusted.invalid" + cfg.PPEEnv = "ppe_untrusted" + manager := NewManager(cfg, WithCredentialStore(store), withClockForTest(func() time.Time { return fixedNow })) + if manager.authBaseURL.String() != config.DefaultBaseURL { + t.Fatalf("browser login followed runtime PPE/base URL: %q", manager.authBaseURL) + } + if key, err := manager.ResolveAccessKey(context.Background()); err != nil || key != "env-ak" { + t.Fatalf("environment precedence = %q, %v", key, err) + } + status, err := manager.Status(context.Background()) + if err != nil || !status.LoggedIn || status.Source != "environment" { + t.Fatalf("environment status = %#v, %v", status, err) + } + + cfg.AccessKey = "" + if key, err := manager.ResolveAccessKey(context.Background()); err != nil || key != credential.AccessKey { + t.Fatalf("stored resolution = %q, %v", key, err) + } + if scope, err := manager.CredentialScope(context.Background()); err != nil || scope != credential.CredentialScope { + t.Fatalf("credential scope = %q, %v", scope, err) + } + if err := manager.Logout(context.Background(), true); !errors.Is(err, ErrRemoteRevokeUnsupported) { + t.Fatalf("remote revoke error = %v", err) + } + if err := manager.Logout(context.Background(), false); err != nil { + t.Fatal(err) + } + if store.credential == nil || store.credential.DeviceID != credential.DeviceID || store.credential.AccessKey != "" || + store.credential.UID != "" || store.credential.TokenID != credential.TokenID { + t.Fatalf("logout did not retain only device identity: %#v", credentialWithoutSecret(store.credential)) + } + if _, err := manager.ResolveAccessKey(context.Background()); !errors.Is(err, ErrCredentialNotFound) { + t.Fatalf("post-logout resolution = %v", err) + } +} + +func TestDecodeCredentialAcceptsLegacyTokenName(t *testing.T) { + deviceID, _ := randomEncoded(bytes.NewReader(bytes.Repeat([]byte{0x44}, deviceIDBytes)), deviceIDBytes) + uid := "legacy-user" + payload, err := json.Marshal(map[string]any{ + "version": credentialVersion, + "device_id": deviceID, + "credential_scope": credentialScope(uid, deviceID), + "token_name": "pippit-tool-cli-legacy-name", + "access_key": "legacy-ak", + "token_id": "legacy-id", + "uid": uid, + "expired_at": time.Now().Add(time.Hour).Unix(), + }) + if err != nil { + t.Fatal(err) + } + credential, err := decodeCredential(payload) + if err != nil || credential.AccessKey != "legacy-ak" || credential.DeviceID != deviceID { + t.Fatalf("legacy credential = %#v, %v", credentialWithoutSecret(credential), err) + } + encoded, err := encodeCredential(credential) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(encoded, []byte("token_name")) { + t.Fatalf("new credential encoding retained legacy field: %s", encoded) + } +} + +func TestManagerNormalizesLegacyDeviceScopeOnLoad(t *testing.T) { + credential, _ := newIdentity(bytes.NewReader(bytes.Repeat([]byte{0x45}, deviceIDBytes))) + credential.AccessKey = "legacy-ak" + credential.TokenID = "legacy-id" + credential.UID = "legacy-user" + credential.ExpiredAt = time.Now().Add(time.Hour).Unix() + credential.CredentialScope = legacyCredentialScope(credential.DeviceID) + payload, err := encodeCredential(credential) + if err != nil { + t.Fatal(err) + } + decoded, err := decodeCredential(payload) + if err != nil { + t.Fatal(err) + } + store := &memoryCredentialStore{credential: decoded} + manager := NewManager(config.Load(), WithCredentialStore(store)) + got, err := manager.loadCredential(context.Background()) + if err != nil { + t.Fatal(err) + } + if got.CredentialScope != credentialScope(credential.UID, credential.DeviceID) { + t.Fatalf("legacy scope was not normalized: %q", got.CredentialScope) + } +} + +func TestManagerCredentialCacheIsConcurrent(t *testing.T) { + fixedNow := time.Unix(1_800_000_000, 0) + credential, _ := newIdentity(bytes.NewReader(bytes.Repeat([]byte{3}, deviceIDBytes))) + credential.AccessKey = "stored-ak" + credential.TokenID = "stored-id" + credential.UID = "789" + credential.CredentialScope = credentialScope(credential.UID, credential.DeviceID) + credential.ExpiredAt = fixedNow.Add(time.Hour).Unix() + store := &memoryCredentialStore{credential: credential} + cfg := config.Load() + cfg.AccessKey = "" + manager := NewManager(cfg, WithCredentialStore(store), withClockForTest(func() time.Time { return fixedNow })) + + const callers = 24 + start := make(chan struct{}) + errCh := make(chan error, callers) + var group sync.WaitGroup + for index := 0; index < callers; index++ { + group.Add(1) + go func() { + defer group.Done() + <-start + key, err := manager.ResolveAccessKey(context.Background()) + if err == nil && key != credential.AccessKey { + err = fmt.Errorf("unexpected access key") + } + errCh <- err + }() + } + close(start) + group.Wait() + close(errCh) + for err := range errCh { + if err != nil { + t.Fatal(err) + } + } + if store.loads != 1 { + t.Fatalf("credential store loads = %d, want 1", store.loads) + } +} + +func TestResilientStoreFallbackBoundaries(t *testing.T) { + identity, _ := newIdentity(bytes.NewReader(bytes.Repeat([]byte{4}, deviceIDBytes))) + primary := &memoryCredentialStore{loadErr: ErrSecureStore, saveErr: ErrSecureStore, deleteErr: ErrSecureStore} + fallback := &memoryCredentialStore{} + store := &resilientCredentialStore{primary: primary, fallback: fallback} + if err := store.Save(context.Background(), identity); err != nil { + t.Fatal(err) + } + loaded, err := store.Load(context.Background()) + if err != nil || loaded.DeviceID != identity.DeviceID { + t.Fatalf("fallback load = %#v, %v", credentialWithoutSecret(loaded), err) + } + if err := store.Delete(context.Background()); !errors.Is(err, ErrSecureStore) { + t.Fatalf("primary delete failure was hidden: %v", err) + } + + corrupt := errors.New("corrupt primary credential") + primary = &memoryCredentialStore{loadErr: corrupt} + fallback = &memoryCredentialStore{credential: identity} + store = &resilientCredentialStore{primary: primary, fallback: fallback} + if _, err := store.Load(context.Background()); !errors.Is(err, corrupt) || fallback.loads != 0 { + t.Fatalf("corrupt primary was masked: err=%v fallback-loads=%d", err, fallback.loads) + } +} + +func TestFileCredentialStoreIsPrivateAtomicAndNoFollow(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows intentionally uses the system keyring without a file fallback") + } + directory := filepath.Join(t.TempDir(), "auth") + path := filepath.Join(directory, credentialFileName) + store := NewFileCredentialStore(path) + credential, _ := newIdentity(bytes.NewReader(bytes.Repeat([]byte{5}, deviceIDBytes))) + credential.AccessKey = "file-ak" + credential.TokenID = "file-id" + credential.UID = "100" + credential.CredentialScope = credentialScope(credential.UID, credential.DeviceID) + credential.ExpiredAt = time.Now().Add(time.Hour).Unix() + if err := store.Save(context.Background(), credential); err != nil { + t.Fatal(err) + } + info, err := os.Stat(path) + if err != nil || info.Mode().Perm() != 0o600 { + t.Fatalf("credential file mode = %v, %v", info, err) + } + loaded, err := store.Load(context.Background()) + if err != nil || loaded.AccessKey != credential.AccessKey { + t.Fatalf("loaded credential = %#v, %v", credentialWithoutSecret(loaded), err) + } + + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + target := filepath.Join(t.TempDir(), "target") + if err := os.WriteFile(target, []byte("must-not-change"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, path); err != nil { + t.Fatal(err) + } + if _, err := store.Load(context.Background()); !errors.Is(err, ErrSecureStore) { + t.Fatalf("symlink load error = %v", err) + } + if err := store.Save(context.Background(), credential); err != nil { + t.Fatal(err) + } + targetData, _ := os.ReadFile(target) + if string(targetData) != "must-not-change" { + t.Fatal("atomic save followed the symlink target") + } +} + +func TestSanitizedBrowserEnv(t *testing.T) { + input := []string{ + "PATH=/usr/bin", + "XYQ_ACCESS_KEY=secret", + "PIPPIT_TOKEN=secret", + "PIPPIT_CLI_AK=secret", + "PIPPIT_CLI_PPE_ENV=ppe_safe", + "OTHER_TOKEN=unrelated", + } + joined := strings.Join(SanitizedBrowserEnv(input), "\n") + for _, forbidden := range []string{"XYQ_ACCESS_KEY", "PIPPIT_TOKEN", "PIPPIT_CLI_AK"} { + if strings.Contains(joined, forbidden) { + t.Fatalf("browser env retained %s", forbidden) + } + } + for _, wanted := range []string{"PATH=/usr/bin", "PIPPIT_CLI_PPE_ENV=ppe_safe", "OTHER_TOKEN=unrelated"} { + if !strings.Contains(joined, wanted) { + t.Fatalf("browser env removed %s", wanted) + } + } +} + +func callbackOpener(t *testing.T, expiry time.Time, uid, tokenID, accessKey string, wantForce bool) func(string) error { + t.Helper() + return func(rawURL string) error { + loginURL, err := url.Parse(rawURL) + if err != nil { + return err + } + if (loginURL.Query().Get("force") == "1") != wantForce { + return fmt.Errorf("force query mismatch") + } + if wantForce && loginURL.Query().Get("token_id") == "" { + return fmt.Errorf("force login omitted token_id") + } + if wantForce && loginURL.Query().Get("expected_account") == "" { + return fmt.Errorf("force login omitted expected_account") + } + payload := accessKeyPayload{ + Type: "access_key", + AccessKey: accessKey, + UID: uid, + TokenID: tokenID, + ExpiredAt: expiry.Unix(), + RandomSecretKey: loginURL.Query().Get("random_secret_key"), + Source: loginSource, + CallbackURL: loginURL.Query().Get("callback"), + } + if status := sendCallback(t, payload.CallbackURL, config.DefaultBaseURL, payload); status != http.StatusOK { + return fmt.Errorf("callback status %d", status) + } + return nil + } +} + +func sendCallback(t *testing.T, callbackURL, origin string, payload accessKeyPayload) int { + t.Helper() + body, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + return postRawCallback(t, callbackURL, origin, string(body)) +} + +func postRawCallback(t *testing.T, callbackURL, origin, body string) int { + t.Helper() + request, err := http.NewRequest(http.MethodPost, callbackURL, strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Origin", origin) + request.Header.Set("Content-Type", "application/json") + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + _, _ = io.Copy(io.Discard, response.Body) + return response.StatusCode +} + +func sendPreflight(t *testing.T, callbackURL, origin string) int { + t.Helper() + request, err := http.NewRequest(http.MethodOptions, callbackURL, nil) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Origin", origin) + request.Header.Set("Access-Control-Request-Method", "POST") + request.Header.Set("Access-Control-Request-Headers", "Content-Type") + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + return response.StatusCode +} + +func credentialWithoutSecret(credential *Credential) any { + if credential == nil { + return nil + } + return struct { + Version int + DeviceID string + CredentialScope string + TokenID string + UID string + ExpiredAt int64 + }{credential.Version, credential.DeviceID, credential.CredentialScope, credential.TokenID, credential.UID, credential.ExpiredAt} +} + +func credentialPayloadWithoutSecret(payload accessKeyPayload) any { + return struct { + Type string + UID string + TokenID string + ExpiredAt int64 + Source string + }{payload.Type, payload.UID, payload.TokenID, payload.ExpiredAt, payload.Source} +} From 5008fd4e40ecbe15af902f54391724c7cc84e85a Mon Sep 17 00:00:00 2001 From: "xuyan.smackgg" Date: Wed, 12 Aug 2026 07:20:20 +0800 Subject: [PATCH 10/14] feat(cli): add browser login commands Co-authored-by: Codex --- cmd/auth/auth.go | 278 ++++++++++++++--------------- cmd/auth/auth_test.go | 125 +++++++++++++ cmd/root.go | 19 +- cmd/root_test.go | 11 ++ cmd/short_drama_test.go | 9 +- internal/common/access_key.go | 27 ++- internal/common/access_key_test.go | 38 +++- internal/common/runner.go | 15 ++ internal/config/config.go | 20 --- internal/config/config_test.go | 18 -- 10 files changed, 363 insertions(+), 197 deletions(-) create mode 100644 cmd/auth/auth_test.go diff --git a/cmd/auth/auth.go b/cmd/auth/auth.go index 6cdc1c5..5d6883f 100644 --- a/cmd/auth/auth.go +++ b/cmd/auth/auth.go @@ -1,144 +1,138 @@ package authcmd -//import ( -// "fmt" -// "io" -// "strings" -// "time" -// -// "github.com/Pippit-dev/pippit-cli/internal/auth" -// "github.com/Pippit-dev/pippit-cli/internal/common" -// "github.com/bytedance/sonic" -// "github.com/spf13/cobra" -//) -// -//type checkResult struct { -// Pending bool `json:"pending"` -// State any `json:"state,omitempty"` -//} -// -//func NewCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command { -// cmd := &cobra.Command{ -// Use: "auth", -// Short: "Manage Pippit OAuth login state", -// } -// cmd.SetOut(stdout) -// cmd.SetErr(stderr) -// cmd.AddCommand(newLoginCommand(stdout, stderr, runner)) -// cmd.AddCommand(newCheckCommand(stdout, stderr, runner)) -// cmd.AddCommand(newStatusCommand(stdout, stderr, runner)) -// cmd.AddCommand(newLogoutCommand(stdout, stderr, runner)) -// return cmd -//} -// -//func newLoginCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command { -// cmd := &cobra.Command{ -// Use: "login", -// Short: "Start an OAuth device login flow", -// Args: cobra.NoArgs, -// RunE: func(cmd *cobra.Command, _ []string) error { -// if runner == nil || runner.AuthAuthorizer == nil { -// return fmt.Errorf("auth manager is required") -// } -// flow, err := runner.AuthAuthorizer.NewLoginFlow(cmd.Context()) -// if err != nil { -// return err -// } -// return writeJSON(stdout, flow) -// }, -// } -// cmd.SetOut(stdout) -// cmd.SetErr(stderr) -// return cmd -//} -// -//func newCheckCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command { -// var deviceCode string -// cmd := &cobra.Command{ -// Use: "check", -// Short: "Check whether an OAuth device login has completed", -// Args: cobra.NoArgs, -// RunE: func(cmd *cobra.Command, _ []string) error { -// deviceCode = strings.TrimSpace(deviceCode) -// if deviceCode == "" { -// return fmt.Errorf("--device-code is required") -// } -// if runner == nil || runner.AuthAuthorizer == nil { -// return fmt.Errorf("auth manager is required") -// } -// state, err := runner.AuthAuthorizer.CheckLogin(cmd.Context(), deviceCode) -// if auth.IsLoginPending(err) { -// return writeJSON(stdout, checkResult{Pending: true}) -// } -// if err != nil { -// return err -// } -// v := map[string]any{ -// "logged_in": state.LoggedIn, -// "expires_at": state.ExpiresAt.Format(time.RFC3339), -// } -// return writeJSON(stdout, checkResult{State: v}) -// }, -// } -// cmd.SetOut(stdout) -// cmd.SetErr(stderr) -// cmd.Flags().StringVar(&deviceCode, "device-code", "", "device code returned by auth login") -// return cmd -//} -// -//func newStatusCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command { -// cmd := &cobra.Command{ -// Use: "status", -// Short: "Show current OAuth login state", -// Args: cobra.NoArgs, -// RunE: func(cmd *cobra.Command, _ []string) error { -// if runner == nil || runner.AuthAuthorizer == nil { -// return fmt.Errorf("auth manager is required") -// } -// state, err := runner.AuthAuthorizer.State(cmd.Context()) -// if err != nil { -// return err -// } -// if !state.LoggedIn { -// return fmt.Errorf("not logged in") -// } -// v := map[string]any{ -// "logged_in": state.LoggedIn, -// "expires_at": state.ExpiresAt.Format(time.RFC3339), -// } -// return writeJSON(stdout, v) -// }, -// } -// cmd.SetOut(stdout) -// cmd.SetErr(stderr) -// return cmd -//} -// -//func newLogoutCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command { -// cmd := &cobra.Command{ -// Use: "logout", -// Short: "Clear local OAuth login state", -// Args: cobra.NoArgs, -// RunE: func(cmd *cobra.Command, _ []string) error { -// if runner == nil || runner.AuthAuthorizer == nil { -// return fmt.Errorf("auth manager is required") -// } -// if err := runner.AuthAuthorizer.Logout(cmd.Context()); err != nil { -// return err -// } -// return writeJSON(stdout, map[string]bool{"logged_out": true}) -// }, -// } -// cmd.SetOut(stdout) -// cmd.SetErr(stderr) -// return cmd -//} -// -//func writeJSON(w io.Writer, v any) error { -// data, err := sonic.Marshal(v) -// if err != nil { -// return err -// } -// _, err = fmt.Fprintln(w, string(data)) -// return err -//} +import ( + "encoding/json" + "errors" + "fmt" + "io" + "strings" + "time" + + internal_auth "github.com/Pippit-dev/pippit-cli/internal/auth" + "github.com/Pippit-dev/pippit-cli/internal/common" + "github.com/spf13/cobra" +) + +type loginResult struct { + LoggedIn bool `json:"logged_in"` + Source string `json:"source"` + UID string `json:"uid,omitempty"` + CredentialScope string `json:"credential_scope,omitempty"` + ExpiresAt string `json:"expires_at,omitempty"` +} + +type logoutResult struct { + LoggedOut bool `json:"logged_out"` + EnvironmentStillActive bool `json:"environment_still_active,omitempty"` + RemoteCredentialPreserved bool `json:"remote_credential_preserved"` +} + +// NewLoginCommand creates the top-level `pippit-tool-cli login` command. +func NewLoginCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command { + var forceRefresh bool + command := &cobra.Command{ + Use: "login", + Short: "通过浏览器登录小云雀 CLI", + Args: cobra.NoArgs, + RunE: func(command *cobra.Command, _ []string) error { + manager, err := requireAuthManager(runner) + if err != nil { + return err + } + credential, err := manager.Login(command.Context(), internal_auth.LoginOptions{ + Progress: stderr, ForceRefresh: forceRefresh, + }) + if err != nil { + return err + } + if runner.Config != nil && strings.TrimSpace(runner.Config.AccessKey) != "" { + _, _ = fmt.Fprintln(stderr, "提示:当前进程设置了 XYQ_ACCESS_KEY,它会继续优先于刚保存的浏览器登录凭证。") + } + return writeJSON(stdout, loginResult{ + LoggedIn: true, + Source: "browser", + UID: credential.UID, + CredentialScope: credential.CredentialScope, + ExpiresAt: time.Unix(credential.ExpiredAt, 0).Format(time.RFC3339), + }) + }, + } + command.SetOut(stdout) + command.SetErr(stderr) + command.Flags().BoolVar(&forceRefresh, "force", false, "强制轮换当前设备的 CLI Access Key(仅在旧密钥被拒绝时使用)") + return command +} + +// NewStatusCommand creates the top-level `pippit-tool-cli status` command. +func NewStatusCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command { + command := &cobra.Command{ + Use: "status", + Short: "查看小云雀 CLI 登录状态", + Args: cobra.NoArgs, + RunE: func(command *cobra.Command, _ []string) error { + manager, err := requireAuthManager(runner) + if err != nil { + return err + } + status, err := manager.Status(command.Context()) + if err != nil { + return err + } + result := loginResult{LoggedIn: status.LoggedIn, Source: status.Source, UID: status.UID, CredentialScope: status.CredentialScope} + if !status.ExpiresAt.IsZero() { + result.ExpiresAt = status.ExpiresAt.Format(time.RFC3339) + } + return writeJSON(stdout, result) + }, + } + command.SetOut(stdout) + command.SetErr(stderr) + return command +} + +// NewLogoutCommand clears only the browser-managed local credential. An +// explicit XYQ_ACCESS_KEY belongs to the caller's environment and is never +// modified or revoked by this command. +func NewLogoutCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command { + command := &cobra.Command{ + Use: "logout", + Short: "清除本机小云雀 CLI 登录(不撤销远程 Access Key)", + Args: cobra.NoArgs, + RunE: func(command *cobra.Command, _ []string) error { + manager, err := requireAuthManager(runner) + if err != nil { + return err + } + if err := manager.Logout(command.Context(), false); err != nil && !errors.Is(err, internal_auth.ErrCredentialNotFound) { + return err + } + environmentActive := runner.Config != nil && strings.TrimSpace(runner.Config.AccessKey) != "" + if environmentActive { + _, _ = fmt.Fprintln(stderr, "提示:XYQ_ACCESS_KEY 仍由当前 shell 提供,CLI 无法替你清除该环境变量。") + } + _, _ = fmt.Fprintln(stderr, "已清除本机登录密钥;远程 Access Key 未撤销,并保留非秘密设备标识供下次登录安全复用。") + return writeJSON(stdout, logoutResult{ + LoggedOut: true, EnvironmentStillActive: environmentActive, RemoteCredentialPreserved: true, + }) + }, + } + command.SetOut(stdout) + command.SetErr(stderr) + return command +} + +func requireAuthManager(runner *common.Runner) (common.AuthManager, error) { + if runner == nil || runner.Auth == nil { + return nil, fmt.Errorf("小云雀 CLI 浏览器授权尚未配置") + } + return runner.Auth, nil +} + +func writeJSON(writer io.Writer, value any) error { + payload, err := json.Marshal(value) + if err != nil { + return fmt.Errorf("编码登录结果失败: %w", err) + } + _, err = fmt.Fprintln(writer, string(payload)) + return err +} diff --git a/cmd/auth/auth_test.go b/cmd/auth/auth_test.go new file mode 100644 index 0000000..65f8c94 --- /dev/null +++ b/cmd/auth/auth_test.go @@ -0,0 +1,125 @@ +package authcmd + +import ( + "bytes" + "context" + "encoding/json" + "io" + "strings" + "testing" + "time" + + internal_auth "github.com/Pippit-dev/pippit-cli/internal/auth" + "github.com/Pippit-dev/pippit-cli/internal/common" + "github.com/Pippit-dev/pippit-cli/internal/config" +) + +type fakeAuthManager struct { + credential *internal_auth.Credential + status *internal_auth.Status + loginCalls int + loginOptions []internal_auth.LoginOptions + logoutCalls int +} + +func (manager *fakeAuthManager) ResolveAccessKey(context.Context) (string, error) { + if manager.credential == nil { + return "", internal_auth.ErrCredentialNotFound + } + return manager.credential.AccessKey, nil +} + +func (manager *fakeAuthManager) Login(_ context.Context, options internal_auth.LoginOptions) (*internal_auth.Credential, error) { + manager.loginCalls++ + manager.loginOptions = append(manager.loginOptions, options) + if options.Progress != nil { + _, _ = options.Progress.Write([]byte("正在完成浏览器授权…\n")) + } + return manager.credential, nil +} + +func TestLoginCommandCanRequestSafeCredentialRotation(t *testing.T) { + manager := &fakeAuthManager{credential: &internal_auth.Credential{ + UID: "123", CredentialScope: "account-device-scope", ExpiredAt: time.Now().Add(time.Hour).Unix(), + }} + command := NewLoginCommand(io.Discard, io.Discard, &common.Runner{Config: &config.Config{}, Auth: manager}) + command.SetArgs([]string{"--force"}) + if err := command.Execute(); err != nil { + t.Fatal(err) + } + if len(manager.loginOptions) != 1 || !manager.loginOptions[0].ForceRefresh { + t.Fatalf("login options = %#v, want forced refresh", manager.loginOptions) + } +} + +func (manager *fakeAuthManager) Status(context.Context) (*internal_auth.Status, error) { + return manager.status, nil +} + +func (manager *fakeAuthManager) Logout(context.Context, bool) error { + manager.logoutCalls++ + return nil +} + +func (manager *fakeAuthManager) CredentialScope(context.Context) (string, error) { + return "device-scope", nil +} + +func TestLoginCommandPrintsMetadataWithoutAccessKey(t *testing.T) { + const accessKey = "must-never-be-printed" + expiresAt := time.Now().Add(time.Hour).Unix() + manager := &fakeAuthManager{credential: &internal_auth.Credential{ + AccessKey: accessKey, UID: "123", CredentialScope: "device-scope", ExpiredAt: expiresAt, + }} + var stdout, stderr bytes.Buffer + command := NewLoginCommand(&stdout, &stderr, &common.Runner{Config: &config.Config{}, Auth: manager}) + command.SetArgs(nil) + if err := command.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if manager.loginCalls != 1 || !strings.Contains(stderr.String(), "浏览器授权") { + t.Fatalf("login calls/stderr = %d/%q", manager.loginCalls, stderr.String()) + } + if strings.Contains(stdout.String(), accessKey) || strings.Contains(stderr.String(), accessKey) { + t.Fatalf("command output leaked Access Key: stdout=%q stderr=%q", stdout.String(), stderr.String()) + } + var result loginResult + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatal(err) + } + if !result.LoggedIn || result.Source != "browser" || result.UID != "123" || result.CredentialScope != "device-scope" { + t.Fatalf("login result = %#v", result) + } +} + +func TestStatusAndLogoutDoNotModifyExplicitEnvironmentCredential(t *testing.T) { + manager := &fakeAuthManager{status: &internal_auth.Status{LoggedIn: true, Source: "environment"}} + runner := &common.Runner{Config: &config.Config{AccessKey: "explicit-ci-key"}, Auth: manager} + var statusOut, statusErr bytes.Buffer + status := NewStatusCommand(&statusOut, &statusErr, runner) + if err := status.Execute(); err != nil { + t.Fatal(err) + } + if strings.Contains(statusOut.String(), runner.Config.AccessKey) { + t.Fatal("status output leaked explicit Access Key") + } + + var logoutOut, logoutErr bytes.Buffer + logout := NewLogoutCommand(&logoutOut, &logoutErr, runner) + if err := logout.Execute(); err != nil { + t.Fatal(err) + } + if manager.logoutCalls != 1 || runner.Config.AccessKey != "explicit-ci-key" { + t.Fatalf("logout calls/key = %d/%q", manager.logoutCalls, runner.Config.AccessKey) + } + if !strings.Contains(logoutErr.String(), "无法替你清除") || strings.Contains(logoutErr.String(), runner.Config.AccessKey) { + t.Fatalf("logout stderr = %q", logoutErr.String()) + } + var result logoutResult + if err := json.Unmarshal(logoutOut.Bytes(), &result); err != nil { + t.Fatal(err) + } + if !result.LoggedOut || !result.RemoteCredentialPreserved || !strings.Contains(logoutErr.String(), "远程 Access Key 未撤销") { + t.Fatalf("logout result/stderr = %#v/%q", result, logoutErr.String()) + } +} diff --git a/cmd/root.go b/cmd/root.go index 47365cb..6c7e17f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,18 +1,20 @@ package cmd import ( + "context" "fmt" "io" "os" "strings" - // authcmd "github.com/Pippit-dev/pippit-cli/cmd/auth" + authcmd "github.com/Pippit-dev/pippit-cli/cmd/auth" canvascmd "github.com/Pippit-dev/pippit-cli/cmd/canvas" "github.com/Pippit-dev/pippit-cli/cmd/generate_image" "github.com/Pippit-dev/pippit-cli/cmd/generate_video" "github.com/Pippit-dev/pippit-cli/cmd/short_drama" updatecmd "github.com/Pippit-dev/pippit-cli/cmd/update" "github.com/Pippit-dev/pippit-cli/cmd/video_tool" + internal_auth "github.com/Pippit-dev/pippit-cli/internal/auth" "github.com/Pippit-dev/pippit-cli/internal/common" "github.com/Pippit-dev/pippit-cli/internal/config" "github.com/Pippit-dev/pippit-cli/internal/version" @@ -32,14 +34,15 @@ func NewRootCommand(stdout, stderr io.Writer) *cobra.Command { func newRootRunner(cfg *config.Config) *common.Runner { runner := common.NewRunner(cfg, nil) + runner.Auth = internal_auth.NewManager(cfg) runner.Client = common.NewHTTPClientWithPPEEnv( cfg.BaseURL, cfg.HTTPTimeout, - common.NewAccessKeyProviderAuthorizer(func() string { - if runner.Config == nil { - return "" + common.NewAccessKeyContextProviderAuthorizer(func(ctx context.Context) (string, error) { + if runner.Auth == nil { + return "", nil } - return runner.Config.AccessKey + return runner.Auth.ResolveAccessKey(ctx) }), func() string { return cfg.PPEEnv }, ) @@ -60,7 +63,9 @@ func newRootCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Comm root.SetOut(stdout) root.SetErr(stderr) configurePPEFlag(root, runner.Config) - // root.AddCommand(authcmd.NewCommand(stdout, stderr, runner)) // temporarily disabled; auth is via access key injection + root.AddCommand(authcmd.NewLoginCommand(stdout, stderr, runner)) + root.AddCommand(authcmd.NewStatusCommand(stdout, stderr, runner)) + root.AddCommand(authcmd.NewLogoutCommand(stdout, stderr, runner)) root.AddCommand(canvascmd.NewCommand(stdout, stderr, runner)) root.AddCommand(newDownloadResultCommand(stdout, stderr, runner)) root.AddCommand(newGetThreadCommand(stdout, stderr, runner)) @@ -81,7 +86,7 @@ func configurePPEFlag(root *cobra.Command, cfg *config.Config) { &cfg.PPEEnv, "ppe-env", cfg.PPEEnv, - "route Pippit API requests to a PPE environment (for example, ppe_cli_canvas_ak)", + "将小云雀业务 API 路由到 PPE(例如 ppe_cli_canvas_ak;网页登录始终使用生产身份域)", ) root.PersistentPreRunE = func(_ *cobra.Command, _ []string) error { ppeEnv, err := config.NormalizePPEEnv(cfg.PPEEnv) diff --git a/cmd/root_test.go b/cmd/root_test.go index 4ba96bc..0a4d220 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -115,6 +115,17 @@ func TestRootHelpIncludesPPEFlag(t *testing.T) { } } +func TestRootRegistersTopLevelBrowserAuthCommands(t *testing.T) { + var stdout, stderr bytes.Buffer + root := NewRootCommand(&stdout, &stderr) + for _, name := range []string{"login", "status", "logout"} { + command, _, err := root.Find([]string{name}) + if err != nil || command == nil || command.Name() != name { + t.Fatalf("root.Find(%q) = %#v, %v", name, command, err) + } + } +} + func newPPEFlagTestRoot(t *testing.T) (*config.Config, *cobra.Command, *bool) { t.Helper() cfg := config.Load() diff --git a/cmd/short_drama_test.go b/cmd/short_drama_test.go index 53c4f1b..d86630f 100644 --- a/cmd/short_drama_test.go +++ b/cmd/short_drama_test.go @@ -971,14 +971,11 @@ func assertAccessKeyGuidance(t *testing.T, err error) { t.Fatal("error = nil, want access key guidance") } msg := err.Error() - if !strings.Contains(msg, "XYQ_ACCESS_KEY 缺失") { + if !strings.Contains(msg, "pippit-tool-cli login") { t.Fatalf("error = %q, want access key guidance", err) } - if !strings.Contains(msg, "https://xyq.jianying.com/home?tab_name=home") { - t.Fatalf("error = %q, want access key settings URL", err) - } - if !strings.Contains(msg, `export XYQ_ACCESS_KEY=""`) { - t.Fatalf("error = %q, want setup command guidance", err) + if !strings.Contains(msg, "XYQ_ACCESS_KEY") { + t.Fatalf("error = %q, want CI override guidance", err) } } diff --git a/internal/common/access_key.go b/internal/common/access_key.go index 042693a..a0ab704 100644 --- a/internal/common/access_key.go +++ b/internal/common/access_key.go @@ -2,15 +2,17 @@ package common import ( "context" + "errors" "fmt" "net/http" "strings" + internal_auth "github.com/Pippit-dev/pippit-cli/internal/auth" "github.com/Pippit-dev/pippit-cli/internal/config" ) type accessKeyAuthorizer struct { - accessKey func() string + accessKey func(context.Context) (string, error) } func NewAccessKeyAuthorizer(accessKey string) RequestAuthorizer { @@ -25,6 +27,18 @@ func NewAccessKeyProviderAuthorizer(accessKey func() string) RequestAuthorizer { if accessKey == nil { accessKey = func() string { return "" } } + return NewAccessKeyContextProviderAuthorizer(func(context.Context) (string, error) { + return accessKey(), nil + }) +} + +// NewAccessKeyContextProviderAuthorizer resolves a credential lazily for each +// request. This keeps keychain access out of --help and lets a browser login in +// the same process authorize the next request without rebuilding the client. +func NewAccessKeyContextProviderAuthorizer(accessKey func(context.Context) (string, error)) RequestAuthorizer { + if accessKey == nil { + accessKey = func(context.Context) (string, error) { return "", nil } + } return &accessKeyAuthorizer{accessKey: accessKey} } @@ -34,10 +48,17 @@ func (a *accessKeyAuthorizer) Inject(ctx context.Context, req *http.Request) err } accessKey := "" if a != nil && a.accessKey != nil { - accessKey = strings.TrimSpace(a.accessKey()) + resolved, err := a.accessKey(ctx) + if err != nil && req.Method == http.MethodPost { + if errors.Is(err, internal_auth.ErrCredentialNotFound) || errors.Is(err, internal_auth.ErrCredentialExpired) { + return fmt.Errorf("未找到可用的小云雀登录凭证;请先运行 pippit-tool-cli login: %w", err) + } + return fmt.Errorf("读取小云雀 CLI 安全登录凭证失败;请检查本机凭证库后重试: %w", err) + } + accessKey = strings.TrimSpace(resolved) } if accessKey == "" && req.Method == http.MethodPost { - return fmt.Errorf("%s 缺失;请前往小云雀官网个人设置页创建 Access Key,地址:https://xyq.jianying.com/home?tab_name=home\n配置后重试:\n export %s=\"\"", config.EnvXYQAccessKey, config.EnvXYQAccessKey) + return fmt.Errorf("未登录小云雀 CLI;请先运行 pippit-tool-cli login(CI 仍可设置 %s)", config.EnvXYQAccessKey) } if accessKey != "" { req.Header.Set("Authorization", "Bearer "+accessKey) diff --git a/internal/common/access_key_test.go b/internal/common/access_key_test.go index 24a94b5..30b0812 100644 --- a/internal/common/access_key_test.go +++ b/internal/common/access_key_test.go @@ -2,9 +2,12 @@ package common import ( "context" + "errors" "net/http" "strings" "testing" + + internal_auth "github.com/Pippit-dev/pippit-cli/internal/auth" ) func TestAccessKeyProviderAuthorizerReadsLatestValue(t *testing.T) { @@ -35,7 +38,7 @@ func TestAccessKeyProviderAuthorizerRejectsMissingKeyWithoutLeakingPriorValue(t accessKey = " " err := authorizer.Inject(context.Background(), newAccessKeyTestRequest(t, http.MethodPost)) - if err == nil || !strings.Contains(err.Error(), "XYQ_ACCESS_KEY 缺失") { + if err == nil || !strings.Contains(err.Error(), "pippit-tool-cli login") { t.Fatalf("Inject() error = %v, want missing Access Key guidance", err) } if strings.Contains(err.Error(), "prior-secret") { @@ -43,6 +46,39 @@ func TestAccessKeyProviderAuthorizerRejectsMissingKeyWithoutLeakingPriorValue(t } } +func TestAccessKeyContextProviderAuthorizerLoadsSavedCredentialLazily(t *testing.T) { + called := 0 + authorizer := NewAccessKeyContextProviderAuthorizer(func(ctx context.Context) (string, error) { + called++ + if err := ctx.Err(); err != nil { + return "", err + } + return " saved-browser-key ", nil + }) + request := newAccessKeyTestRequest(t, http.MethodPost) + if called != 0 { + t.Fatal("credential provider was called before request authorization") + } + if err := authorizer.Inject(context.Background(), request); err != nil { + t.Fatalf("Inject() error = %v", err) + } + if called != 1 || request.Header.Get("Authorization") != "Bearer saved-browser-key" { + t.Fatalf("called=%d Authorization=%q", called, request.Header.Get("Authorization")) + } +} + +func TestAccessKeyContextProviderAuthorizerPreservesCredentialStateErrors(t *testing.T) { + for _, cause := range []error{internal_auth.ErrCredentialNotFound, internal_auth.ErrCredentialExpired} { + authorizer := NewAccessKeyContextProviderAuthorizer(func(context.Context) (string, error) { + return "", cause + }) + err := authorizer.Inject(context.Background(), newAccessKeyTestRequest(t, http.MethodPost)) + if !errors.Is(err, cause) || !strings.Contains(err.Error(), "pippit-tool-cli login") { + t.Fatalf("Inject() error = %v, want wrapped %v", err, cause) + } + } +} + func TestAccessKeyProviderAuthorizerAllowsUnauthenticatedRead(t *testing.T) { authorizer := NewAccessKeyProviderAuthorizer(nil) request := newAccessKeyTestRequest(t, http.MethodGet) diff --git a/internal/common/runner.go b/internal/common/runner.go index 29e567d..5f9f411 100644 --- a/internal/common/runner.go +++ b/internal/common/runner.go @@ -1,13 +1,28 @@ package common import ( + "context" + + "github.com/Pippit-dev/pippit-cli/internal/auth" "github.com/Pippit-dev/pippit-cli/internal/config" ) +// AuthManager is the credential boundary shared by native CLI commands. It +// resolves explicit CI credentials first and browser-managed credentials +// second, without exposing either value to command output. +type AuthManager interface { + ResolveAccessKey(context.Context) (string, error) + Login(context.Context, auth.LoginOptions) (*auth.Credential, error) + Status(context.Context) (*auth.Status, error) + Logout(context.Context, bool) error + CredentialScope(context.Context) (string, error) +} + // Runner carries runtime dependencies for command execution. type Runner struct { Config *config.Config Client Client + Auth AuthManager } func NewRunner(cfg *config.Config, client Client) *Runner { diff --git a/internal/config/config.go b/internal/config/config.go index 2d3371c..77a81ff 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,8 +12,6 @@ const ( DefaultBaseURL = "https://xyq.jianying.com" DefaultHTTPTimeout = 30 * time.Minute DefaultAuthTTL = 30 * time.Second - DefaultOAuthClientKey = "mock-cli" - DefaultOAuthBaseURL = "https://passport.bytedance.com" DefaultAuthStoreServiceName = "pippit-cli" SubmitRunPath = "/api/biz/v1/skill/submit_run" GetThreadPath = "/api/biz/v1/skill/get_thread" @@ -33,17 +31,9 @@ type Config struct { AuthTTL time.Duration AccessKey string PPEEnv string - OAuth *OAuth Paths *Paths } -type OAuth struct { - ClientKey string - BaseURL string - StoreServiceName string - Scopes []string -} - type Paths struct { SubmitRun string GetThread string @@ -59,7 +49,6 @@ func Load() *Config { AuthTTL: DefaultAuthTTL, AccessKey: strings.TrimSpace(os.Getenv(EnvXYQAccessKey)), PPEEnv: strings.TrimSpace(os.Getenv(EnvPPEEnv)), - OAuth: resolveOAuth(), Paths: &Paths{ SubmitRun: SubmitRunPath, GetThread: GetThreadPath, @@ -82,12 +71,3 @@ func NormalizePPEEnv(value string) (string, error) { } return value, nil } - -func resolveOAuth() *OAuth { - return &OAuth{ - ClientKey: DefaultOAuthClientKey, - BaseURL: DefaultOAuthBaseURL, - StoreServiceName: DefaultAuthStoreServiceName, - Scopes: []string{"user_info", "aigc_generate"}, - } -} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 561e1af..01e88c4 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -21,24 +21,6 @@ func TestLoadUsesDefaultConfig(t *testing.T) { if cfg.PPEEnv != "" { t.Fatalf("PPEEnv = %q, want empty", cfg.PPEEnv) } - if cfg.OAuth.ClientKey != DefaultOAuthClientKey { - t.Fatalf("OAuth.ClientKey = %q, want %q", cfg.OAuth.ClientKey, DefaultOAuthClientKey) - } - if cfg.OAuth.StoreServiceName != DefaultAuthStoreServiceName { - t.Fatalf("OAuth.StoreServiceName = %q, want %q", cfg.OAuth.StoreServiceName, DefaultAuthStoreServiceName) - } - if cfg.OAuth.BaseURL != DefaultOAuthBaseURL { - t.Fatalf("OAuth.BaseURL = %q, want %q", cfg.OAuth.BaseURL, DefaultOAuthBaseURL) - } - wantScopes := []string{"user_info", "aigc_generate"} - if len(cfg.OAuth.Scopes) != len(wantScopes) { - t.Fatalf("OAuth.Scopes = %#v, want %#v", cfg.OAuth.Scopes, wantScopes) - } - for i := range wantScopes { - if cfg.OAuth.Scopes[i] != wantScopes[i] { - t.Fatalf("OAuth.Scopes = %#v, want %#v", cfg.OAuth.Scopes, wantScopes) - } - } if cfg.Paths.SubmitRun != SubmitRunPath { t.Fatalf("SubmitRun path = %q, want %q", cfg.Paths.SubmitRun, SubmitRunPath) } From e355e5cff718a23314d66b9bae37f44590878814 Mon Sep 17 00:00:00 2001 From: "xuyan.smackgg" Date: Wed, 12 Aug 2026 08:44:07 +0800 Subject: [PATCH 11/14] fix(update): keep CLI credentials out of child processes Co-authored-by: Codex --- cmd/update/update.go | 39 +++++++++++++++--- cmd/update/update_test.go | 84 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 5 deletions(-) diff --git a/cmd/update/update.go b/cmd/update/update.go index c149225..ae15d1d 100644 --- a/cmd/update/update.go +++ b/cmd/update/update.go @@ -14,6 +14,7 @@ import ( "sync" "time" + "github.com/Pippit-dev/pippit-cli/internal/auth" "github.com/Pippit-dev/pippit-cli/internal/version" "github.com/spf13/cobra" ) @@ -219,20 +220,48 @@ func runInherit(stderr io.Writer, name string, args ...string) error { func runInheritEnv(stderr io.Writer, env []string, name string, args ...string) error { cmd := command(name, args...) - if len(env) > 0 { - cmd.Env = append(os.Environ(), env...) - } + cmd.Env = sanitizedUpdateEnv(os.Environ(), env) cmd.Stdout = stderr cmd.Stderr = stderr return cmd.Run() } +func sanitizedUpdateEnv(environment, overrides []string) []string { + merged := overlayEnvironment(environment, overrides) + return auth.SanitizedBrowserEnv(merged) +} + +func overlayEnvironment(environment, overrides []string) []string { + result := make([]string, 0, len(environment)+len(overrides)) + indexes := make(map[string]int, len(environment)+len(overrides)) + for _, entry := range append(append([]string(nil), environment...), overrides...) { + name, _, found := strings.Cut(entry, "=") + if !found || strings.TrimSpace(name) == "" { + continue + } + key := name + if runtime.GOOS == "windows" { + key = strings.ToUpper(key) + } + if index, exists := indexes[key]; exists { + result[index] = entry + continue + } + indexes[key] = len(result) + result = append(result, entry) + } + return result +} func command(name string, args ...string) *exec.Cmd { + var cmd *exec.Cmd if runtime.GOOS == "windows" { cmdArgs := append([]string{"/c", name}, args...) - return exec.Command("cmd.exe", cmdArgs...) + cmd = exec.Command("cmd.exe", cmdArgs...) + } else { + cmd = exec.Command(name, args...) } - return exec.Command(name, args...) + cmd.Env = sanitizedUpdateEnv(os.Environ(), nil) + return cmd } func prepareSelfReplace() (func(), error) { diff --git a/cmd/update/update_test.go b/cmd/update/update_test.go index c3c9cfc..31e2bc1 100644 --- a/cmd/update/update_test.go +++ b/cmd/update/update_test.go @@ -171,3 +171,87 @@ func TestStripPrereleaseVersion(t *testing.T) { } } } + +func TestRunInheritEnvSanitizesPippitCredentials(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("test uses a POSIX shell script to capture the child environment") + } + + binDir := t.TempDir() + capturePath := filepath.Join(t.TempDir(), "environment.txt") + commandPath := filepath.Join(binDir, "capture-update-environment") + script := "#!/bin/sh\n/usr/bin/env > \"$CAPTURE_ENV\"\n" + if err := os.WriteFile(commandPath, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + + basePath := binDir + string(os.PathListSeparator) + "/base/path" + overriddenPath := binDir + string(os.PathListSeparator) + "/overridden/path" + t.Setenv("PATH", basePath) + t.Setenv("CAPTURE_ENV", capturePath) + t.Setenv("SAFE_INHERITED", "kept") + t.Setenv("XYQ_ACCESS_KEY", "xyq-secret") + t.Setenv("PIPPIT_ACCESS_KEY", "pippit-secret") + t.Setenv("PIPPIT_AK", "legacy-secret") + t.Setenv("PIPPIT_CLI_TOKEN", "pippit-token") + t.Setenv("XYQ_CLIENT_SECRET", "xyq-client-secret") + t.Setenv("NPM_TOKEN", "npm-secret") + t.Setenv("NODE_AUTH_TOKEN", "registry-secret") + + var stderr bytes.Buffer + err := runInheritEnv(&stderr, []string{ + "PATH=" + overriddenPath, + "SAFE_INHERITED=overridden", + "SAFE_EXPLICIT=kept", + "PIPPIT_CLI_SKIP_SKILLS=1", + "XYQ_ACCESS_KEY=override-must-not-leak", + "PIPPIT_OVERRIDE_SECRET=override-must-not-leak", + "REGISTRY_TOKEN=explicit-registry-secret", + }, "capture-update-environment") + if err != nil { + t.Fatalf("runInheritEnv() error = %v, stderr = %s", err, stderr.String()) + } + + captured, err := os.ReadFile(capturePath) + if err != nil { + t.Fatal(err) + } + got := parseEnvironment(string(captured)) + for _, forbidden := range []string{ + "XYQ_ACCESS_KEY", + "PIPPIT_ACCESS_KEY", + "PIPPIT_AK", + "PIPPIT_CLI_TOKEN", + "XYQ_CLIENT_SECRET", + "PIPPIT_OVERRIDE_SECRET", + } { + if value, exists := got[forbidden]; exists { + t.Fatalf("child environment retained %s=%q", forbidden, value) + } + } + for name, want := range map[string]string{ + "PATH": overriddenPath, + "CAPTURE_ENV": capturePath, + "SAFE_INHERITED": "overridden", + "SAFE_EXPLICIT": "kept", + "PIPPIT_CLI_SKIP_SKILLS": "1", + "NPM_TOKEN": "npm-secret", + "NODE_AUTH_TOKEN": "registry-secret", + "REGISTRY_TOKEN": "explicit-registry-secret", + } { + if value := got[name]; value != want { + t.Fatalf("child environment %s = %q, want %q", name, value, want) + } + } +} + +func parseEnvironment(environment string) map[string]string { + result := make(map[string]string) + for _, entry := range strings.Split(environment, "\n") { + name, value, found := strings.Cut(entry, "=") + if found { + result[name] = value + } + } + return result +} From 874ec6a280569765ee2d9f669377aad60e303c81 Mon Sep 17 00:00:00 2001 From: "xuyan.smackgg" Date: Wed, 12 Aug 2026 18:16:30 +0800 Subject: [PATCH 12/14] refactor(canvas): keep only public atomic operations Co-authored-by: Codex --- internal/canvas/allocate.go | 69 ---------------------------------- internal/canvas/canvas_test.go | 25 ------------ internal/canvas/types.go | 1 - 3 files changed, 95 deletions(-) delete mode 100644 internal/canvas/allocate.go diff --git a/internal/canvas/allocate.go b/internal/canvas/allocate.go deleted file mode 100644 index 95eee07..0000000 --- a/internal/canvas/allocate.go +++ /dev/null @@ -1,69 +0,0 @@ -package canvas - -import ( - "context" - "encoding/json" - "fmt" - "strings" - - "github.com/Pippit-dev/pippit-cli/internal/common" -) - -const MaxAllocateCount = 5000 - -type AllocateResult struct { - AssetIDs []string `json:"asset_ids"` - LogID string `json:"log_id,omitempty"` -} - -type allocateRequest struct { - Count int `json:"count"` - Base map[string]any `json:"Base"` -} - -func Allocate(ctx context.Context, count int, runner *common.Runner) (*AllocateResult, error) { - client, err := runnerClient(runner, "canvas allocate") - if err != nil { - return nil, err - } - if count <= 0 || count > MaxAllocateCount { - return nil, fmt.Errorf("canvas allocate count must be between 1 and %d", MaxAllocateCount) - } - var envelope responseEnvelope - if err := client.SendRequest(ctx, AllocatePath, allocateRequest{Count: count, Base: base()}, &envelope); err != nil { - return nil, fmt.Errorf("canvas allocate request failed: %w", err) - } - if err := envelope.validate("canvas allocate"); err != nil { - return nil, err - } - var data map[string]json.RawMessage - if err := json.Unmarshal(envelope.Data, &data); err != nil { - return nil, common.NewLogIDError(fmt.Sprintf("canvas allocate returned invalid data: %v", err), envelope.LogID) - } - idsRaw, ok := rawField(data, "ids", "IDs", "asset_ids") - if !ok { - return nil, common.NewLogIDError("canvas allocate response is missing data.ids", envelope.LogID) - } - var ids []string - if err := json.Unmarshal(idsRaw, &ids); err != nil { - return nil, common.NewLogIDError("canvas allocate data.ids must contain JSON strings", envelope.LogID) - } - if len(ids) != count { - return nil, common.NewLogIDError( - fmt.Sprintf("canvas allocate returned %d ids, want %d", len(ids), count), - envelope.LogID, - ) - } - seen := make(map[string]struct{}, len(ids)) - for index, id := range ids { - ids[index] = strings.TrimSpace(id) - if ids[index] == "" { - return nil, common.NewLogIDError(fmt.Sprintf("canvas allocate returned empty id at index %d", index), envelope.LogID) - } - if _, duplicate := seen[ids[index]]; duplicate { - return nil, common.NewLogIDError(fmt.Sprintf("canvas allocate returned duplicate id %q", ids[index]), envelope.LogID) - } - seen[ids[index]] = struct{}{} - } - return &AllocateResult{AssetIDs: ids, LogID: strings.TrimSpace(envelope.LogID)}, nil -} diff --git a/internal/canvas/canvas_test.go b/internal/canvas/canvas_test.go index 60c3270..1e6873a 100644 --- a/internal/canvas/canvas_test.go +++ b/internal/canvas/canvas_test.go @@ -269,31 +269,6 @@ func TestGetRejectsNumericAssetIDInResponse(t *testing.T) { } } -func TestAllocateRequiresExactUniqueStringIDs(t *testing.T) { - client := &fakeClient{send: func(_ context.Context, path string, body any, out any) error { - if path != AllocatePath { - t.Fatalf("path = %q, want allocate", path) - } - request := requestJSON(t, body) - if request["count"] != float64(2) { - t.Fatalf("count = %#v, want 2", request["count"]) - } - return decodeInto(out, `{"ret":"0","data":{"ids":["10","11"]}}`) - }} - result, err := Allocate(context.Background(), 2, runnerWithClient(client)) - if err != nil || strings.Join(result.AssetIDs, ",") != "10,11" { - t.Fatalf("Allocate() = (%#v, %v), want two IDs", result, err) - } - - duplicate := &fakeClient{send: func(_ context.Context, _ string, _ any, out any) error { - return decodeInto(out, `{"ret":"0","data":{"ids":["10","10"]}}`) - }} - _, err = Allocate(context.Background(), 2, runnerWithClient(duplicate)) - if err == nil || !strings.Contains(err.Error(), "duplicate id") { - t.Fatalf("Allocate() error = %v, want duplicate rejection", err) - } -} - func TestApplyRequiresAcknowledgementAndEveryAssetVersion(t *testing.T) { version := int64(0) request := ApplyRequest{ diff --git a/internal/canvas/types.go b/internal/canvas/types.go index 6ffb5a3..644052a 100644 --- a/internal/canvas/types.go +++ b/internal/canvas/types.go @@ -12,7 +12,6 @@ import ( const ( CreatePath = "/api/biz/v1/skill/canvas/create" QueryPath = "/api/biz/v1/skill/canvas/query" - AllocatePath = "/api/biz/v1/skill/canvas/batch_generate_asset_id" ApplyPath = "/api/biz/v1/skill/canvas/batch_patch_asset" UploadPath = "/api/biz/v1/skill/upload_file" ) From 68eeb3e57cf70558edbe940850d7d5a6df25f58c Mon Sep 17 00:00:00 2001 From: "xuyan.smackgg" Date: Wed, 12 Aug 2026 18:10:10 +0800 Subject: [PATCH 13/14] docs(cli): document browser login and canvas commands Co-authored-by: Codex --- README.md | 26 ++++++++++++++++++++++++-- skills/short-drama/SKILL.md | 8 ++++---- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 6dad317..26cb621 100644 --- a/README.md +++ b/README.md @@ -205,7 +205,7 @@ python3 skills/xyq-nest-skill/scripts/download_results.py \ ```bash npx @pippit-dev/cli@latest install -export XYQ_ACCESS_KEY="" +pippit-tool-cli login pippit-tool-cli --version pippit-tool-cli short-drama +submit-run --message "写一个赛博朋克短剧开头" pippit-tool-cli short-drama +upload-file --path ./reference.doc @@ -222,6 +222,26 @@ pippit-tool-cli download-result --output-path ./thread_123/results/result.mp4 -- 短剧命令的错误日志会追加写入本地每日日志文件:`~/.pippit_tool_cli/logs/yyyy-mm-dd.log`。日志路径会基于当前用户主目录和系统路径分隔符生成,因此可在 macOS、Linux 和 Windows 上使用。 +## Canvas 原子命令 + +CLI 提供个人漫剧画布的通用原子命令,不包含特定来源的导入或转换逻辑: + +```bash +# 首次使用时打开小云雀网页授权 +pippit-tool-cli login +pippit-tool-cli status + +# 创建、查询、上传与提交单个画布 transaction +pippit-tool-cli canvas create --title "CLI Canvas" --wait +pippit-tool-cli canvas get --asset-id PIPPIT_ASSET_ID +pippit-tool-cli canvas upload --path ./reference.png +pippit-tool-cli canvas apply --project-id PROJECT_ID --file ./patch.json +``` + +仅测试 PPE 时,在命令前增加 `--ppe-env ppe_cli_canvas_ak`;生产环境不要设置该参数。PPE 只影响登录完成后的同源业务请求,不改变登录账号或本机凭证。 + +四个命令均输出单行 JSON,资源 ID 保持字符串。`create` 的 `request_id` 用于追踪,不是跨服务崩溃窗口的严格幂等键;写请求结果不明确时不要盲目重放,应先使用 `canvas get` 回读确认。`apply` 当前只接受一个 transaction,但该 transaction 可以包含多个 patches;CLI 会严格检查 transaction ACK 和每个目标资产的新版本。 + ## 生图 CLI `generate-image` 会上传本地参考图片,然后向综合 Nest Agent 提交生图请求: @@ -333,4 +353,6 @@ pippit-tool-cli query-result \ ## 鉴权 -`short-drama +submit-run`、`get-thread`、`list-thread-file`、`short-drama +upload-file` 以及 `xyq-skill` Python 脚本都使用 `Authorization: Bearer ` 鉴权。OAuth 命令代码仍保留在仓库中,但短剧运行时请求不使用 OAuth。 +原生 CLI 命令通过 `pippit-tool-cli login` 打开小云雀网页授权,并把本机设备专属凭证保存到系统安全凭证库;Access Key 不会显示在终端。可用 `pippit-tool-cli status` 查看状态、`pippit-tool-cli logout` 清除本机登录。 + +CI 或 Agent 可继续显式设置 `XYQ_ACCESS_KEY`,它会覆盖本机网页登录凭证;配置错误时不会静默回退到个人登录。`skills/xyq-nest-skill/scripts` 下的独立 Python 脚本尚未接入原生 CLI 凭证库,当前仍需要该环境变量。 diff --git a/skills/short-drama/SKILL.md b/skills/short-drama/SKILL.md index 957601b..597e927 100644 --- a/skills/short-drama/SKILL.md +++ b/skills/short-drama/SKILL.md @@ -86,14 +86,14 @@ metadata: npx @pippit-dev/cli@latest install ``` -部分功能需要先配置 `XYQ_ACCESS_KEY`。缺失时 CLI 会直接提示用户先创建 Access Key,此时请等待用户给与Access Key后再继续运行。 - -Access Key 创建地址:https://xyq.jianying.com/home?tab_name=home +首次使用原生 CLI 时运行网页登录;CLI 会自动申请或复用本机专属凭证,并保存到系统安全凭证库,不要求用户复制 Access Key: ```bash -export XYQ_ACCESS_KEY="" +pippit-tool-cli login ``` +`XYQ_ACCESS_KEY` 仅保留给 CI、Agent 等非交互环境作为显式覆盖。若该环境变量已设置但无效,CLI 不会静默改用个人网页登录凭证;应先修正或取消该环境变量。 + ## 小云雀界面打开契约 `+submit-run` 返回 `web_thread_link` 后,用户侧 Agent 必须优先把小云雀短剧 WebUI 打开给用户,而不是只展示链接。 From 473dcf82e15a31a76943fe66365118dbc3cee043 Mon Sep 17 00:00:00 2001 From: "xuyan.smackgg" Date: Wed, 12 Aug 2026 18:17:38 +0800 Subject: [PATCH 14/14] test(cli): tighten root command assertion Co-authored-by: Codex --- cmd/short_drama_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/short_drama_test.go b/cmd/short_drama_test.go index d86630f..cd59040 100644 --- a/cmd/short_drama_test.go +++ b/cmd/short_drama_test.go @@ -140,7 +140,7 @@ func TestRootHelpListsSupportedCommands(t *testing.T) { t.Fatalf("help output = %q, want %q", got, want) } } - for _, unwanted := range []string{"completion", "version "} { + for _, unwanted := range []string{"completion", "\n version "} { if strings.Contains(got, unwanted) { t.Fatalf("help output = %q, should not contain %q", got, unwanted) }