@pippit-dev/cli 1.0.17 → 1.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +23 -2
  2. package/checksums.txt +6 -6
  3. package/cmd/auth/auth.go +136 -142
  4. package/cmd/auth/auth_test.go +134 -0
  5. package/cmd/canvas/canvas.go +235 -0
  6. package/cmd/canvas/canvas_test.go +126 -0
  7. package/cmd/root.go +29 -4
  8. package/cmd/root_test.go +60 -0
  9. package/cmd/short_drama_test.go +4 -7
  10. package/cmd/update/update.go +34 -5
  11. package/cmd/update/update_test.go +84 -0
  12. package/internal/auth/auth_test.go +813 -0
  13. package/internal/auth/browser_darwin.go +14 -0
  14. package/internal/auth/browser_env.go +35 -0
  15. package/internal/auth/browser_linux.go +14 -0
  16. package/internal/auth/browser_windows.go +14 -0
  17. package/internal/auth/identity.go +89 -0
  18. package/internal/auth/loopback.go +324 -0
  19. package/internal/auth/manager.go +338 -144
  20. package/internal/auth/store.go +303 -0
  21. package/internal/auth/store_file_unix.go +176 -0
  22. package/internal/auth/store_file_windows.go +11 -0
  23. package/internal/auth/types.go +72 -0
  24. package/internal/canvas/allocate.go +70 -0
  25. package/internal/canvas/apply.go +203 -0
  26. package/internal/canvas/canvas_test.go +446 -0
  27. package/internal/canvas/create.go +380 -0
  28. package/internal/canvas/get.go +156 -0
  29. package/internal/canvas/types.go +68 -0
  30. package/internal/canvas/upload.go +250 -0
  31. package/internal/common/access_key.go +42 -6
  32. package/internal/common/access_key_test.go +113 -0
  33. package/internal/common/client.go +119 -21
  34. package/internal/common/client_test.go +213 -0
  35. package/internal/common/runner.go +15 -0
  36. package/internal/config/config.go +0 -20
  37. package/internal/config/config_test.go +0 -18
  38. package/package.json +1 -1
  39. package/skills/short-drama/SKILL.md +4 -4
  40. package/skills/xyq-nest-skill/SKILL.md +11 -1
@@ -0,0 +1,14 @@
1
+ //go:build darwin
2
+
3
+ package auth
4
+
5
+ import (
6
+ "os"
7
+ "os/exec"
8
+ )
9
+
10
+ func OpenBrowser(rawURL string) error {
11
+ command := exec.Command("open", rawURL)
12
+ command.Env = SanitizedBrowserEnv(os.Environ())
13
+ return command.Start()
14
+ }
@@ -0,0 +1,35 @@
1
+ package auth
2
+
3
+ import "strings"
4
+
5
+ // SanitizedBrowserEnv removes Pippit/XYQ credential variables before spawning
6
+ // a browser helper. The login URL carries its own one-time binding and does not
7
+ // need any CLI credential from the child process environment.
8
+ func SanitizedBrowserEnv(environment []string) []string {
9
+ result := make([]string, 0, len(environment))
10
+ for _, entry := range environment {
11
+ name, _, found := strings.Cut(entry, "=")
12
+ if !found || isCredentialEnvName(name) {
13
+ continue
14
+ }
15
+ result = append(result, entry)
16
+ }
17
+ return result
18
+ }
19
+
20
+ func isCredentialEnvName(name string) bool {
21
+ upper := strings.ToUpper(strings.TrimSpace(name))
22
+ if !strings.HasPrefix(upper, "PIPPIT_") && !strings.HasPrefix(upper, "XYQ_") {
23
+ return false
24
+ }
25
+ if strings.Contains(upper, "ACCESS_KEY") || strings.Contains(upper, "TOKEN") || strings.Contains(upper, "SECRET") {
26
+ return true
27
+ }
28
+ parts := strings.Split(upper, "_")
29
+ for _, part := range parts {
30
+ if part == "AK" {
31
+ return true
32
+ }
33
+ }
34
+ return false
35
+ }
@@ -0,0 +1,14 @@
1
+ //go:build linux
2
+
3
+ package auth
4
+
5
+ import (
6
+ "os"
7
+ "os/exec"
8
+ )
9
+
10
+ func OpenBrowser(rawURL string) error {
11
+ command := exec.Command("xdg-open", rawURL)
12
+ command.Env = SanitizedBrowserEnv(os.Environ())
13
+ return command.Start()
14
+ }
@@ -0,0 +1,14 @@
1
+ //go:build windows
2
+
3
+ package auth
4
+
5
+ import (
6
+ "os"
7
+ "os/exec"
8
+ )
9
+
10
+ func OpenBrowser(rawURL string) error {
11
+ command := exec.Command("rundll32", "url.dll,FileProtocolHandler", rawURL)
12
+ command.Env = SanitizedBrowserEnv(os.Environ())
13
+ return command.Start()
14
+ }
@@ -0,0 +1,89 @@
1
+ package auth
2
+
3
+ import (
4
+ "crypto/rand"
5
+ "crypto/sha256"
6
+ "crypto/subtle"
7
+ "encoding/base64"
8
+ "errors"
9
+ "io"
10
+ "strings"
11
+ )
12
+
13
+ func randomEncoded(reader io.Reader, size int) (string, error) {
14
+ if reader == nil {
15
+ reader = rand.Reader
16
+ }
17
+ value := make([]byte, size)
18
+ if _, err := io.ReadFull(reader, value); err != nil {
19
+ return "", err
20
+ }
21
+ return base64.RawURLEncoding.EncodeToString(value), nil
22
+ }
23
+
24
+ func randomTempName() (string, error) {
25
+ value, err := randomEncoded(rand.Reader, 18)
26
+ if err != nil {
27
+ return "", err
28
+ }
29
+ return ".credential-" + value + ".tmp", nil
30
+ }
31
+
32
+ func validDeviceID(deviceID string) bool {
33
+ decoded, err := base64.RawURLEncoding.DecodeString(deviceID)
34
+ return err == nil && len(decoded) == deviceIDBytes &&
35
+ constantTimeEqual(deviceID, base64.RawURLEncoding.EncodeToString(decoded))
36
+ }
37
+
38
+ func credentialScope(uid, deviceID string) string {
39
+ return "pippit-tool-cli:user:" + accountBinding(uid) + ":device:" + deviceID
40
+ }
41
+
42
+ func accountBinding(uid string) string {
43
+ digest := sha256.Sum256([]byte(strings.TrimSpace(uid)))
44
+ return base64.RawURLEncoding.EncodeToString(digest[:16])
45
+ }
46
+
47
+ func accountBindingFromCredentialScope(scope, deviceID string) (string, bool) {
48
+ prefix := "pippit-tool-cli:user:"
49
+ suffix := ":device:" + deviceID
50
+ if !strings.HasPrefix(scope, prefix) || !strings.HasSuffix(scope, suffix) {
51
+ return "", false
52
+ }
53
+ binding := strings.TrimSuffix(strings.TrimPrefix(scope, prefix), suffix)
54
+ return binding, validAccountBinding(binding)
55
+ }
56
+
57
+ func legacyCredentialScope(deviceID string) string {
58
+ return "pippit-tool-cli:device:" + deviceID
59
+ }
60
+
61
+ func constantTimeEqual(left, right string) bool {
62
+ leftHash := sha256.Sum256([]byte(left))
63
+ rightHash := sha256.Sum256([]byte(right))
64
+ return subtle.ConstantTimeCompare(leftHash[:], rightHash[:]) == 1
65
+ }
66
+
67
+ func newIdentity(reader io.Reader) (*Credential, error) {
68
+ deviceID, err := randomEncoded(reader, deviceIDBytes)
69
+ if err != nil {
70
+ return nil, errors.New("生成本机登录设备标识失败")
71
+ }
72
+ return &Credential{
73
+ Version: credentialVersion,
74
+ DeviceID: deviceID,
75
+ }, nil
76
+ }
77
+
78
+ func identityOnly(credential *Credential) *Credential {
79
+ if credential == nil {
80
+ return nil
81
+ }
82
+ return &Credential{
83
+ Version: credential.Version,
84
+ DeviceID: credential.DeviceID,
85
+ // TokenID is a non-secret exact selector used by the Web page to reuse the
86
+ // same remote token after logout instead of consuming another AK slot.
87
+ TokenID: credential.TokenID,
88
+ }
89
+ }
@@ -0,0 +1,324 @@
1
+ package auth
2
+
3
+ import (
4
+ "context"
5
+ "encoding/base64"
6
+ "encoding/json"
7
+ "errors"
8
+ "fmt"
9
+ "io"
10
+ "mime"
11
+ "net"
12
+ "net/http"
13
+ "net/url"
14
+ "strings"
15
+ "sync"
16
+ "time"
17
+ )
18
+
19
+ const maxCallbackBodyBytes = 64 << 10
20
+
21
+ const maxInjectedCallbackQueryValueBytes = 4 << 10
22
+
23
+ type accessKeyPayload struct {
24
+ Type string `json:"type"`
25
+ AccessKey string `json:"access_key"`
26
+ UID string `json:"uid"`
27
+ TokenID string `json:"token_id"`
28
+ ExpiredAt int64 `json:"expired_at"`
29
+ RandomSecretKey string `json:"random_secret_key"`
30
+ Source string `json:"source"`
31
+ CallbackURL string `json:"callback_url"`
32
+ }
33
+
34
+ type browserFlow struct {
35
+ loginURL string
36
+ callbackURL string
37
+ secret string
38
+ state string
39
+ source string
40
+ origin string
41
+ listener net.Listener
42
+ server *http.Server
43
+ payload chan accessKeyPayload
44
+ serveErr chan error
45
+ callbackOnce sync.Once
46
+ closeOnce sync.Once
47
+ }
48
+
49
+ func startBrowserFlow(authBaseURL *url.URL, randomReader io.Reader, deviceID, tokenID, expectedAccount string, forceRefresh bool) (*browserFlow, error) {
50
+ if !validDeviceID(deviceID) {
51
+ return nil, errors.New("本机登录设备标识无效")
52
+ }
53
+ if tokenID != "" && !validTokenID(tokenID) {
54
+ return nil, errors.New("本机 CLI 凭证编号无效")
55
+ }
56
+ if forceRefresh && tokenID == "" {
57
+ return nil, errors.New("无法确认需要轮换的本机 CLI 凭证编号")
58
+ }
59
+ if expectedAccount != "" && !validAccountBinding(expectedAccount) {
60
+ return nil, errors.New("本机登录账号绑定无效")
61
+ }
62
+ if forceRefresh && expectedAccount == "" {
63
+ return nil, errors.New("无法确认需要轮换的本机 CLI 登录账号")
64
+ }
65
+ secret, err := randomEncoded(randomReader, randomBindingBytes)
66
+ if err != nil {
67
+ return nil, errors.New("生成网页授权绑定信息失败")
68
+ }
69
+ state, err := randomEncoded(randomReader, randomBindingBytes)
70
+ if err != nil {
71
+ return nil, errors.New("生成网页授权状态失败")
72
+ }
73
+ listener, err := net.Listen("tcp4", "127.0.0.1:0")
74
+ if err != nil {
75
+ return nil, errors.New("启动本机网页授权回调失败")
76
+ }
77
+
78
+ callback := &url.URL{
79
+ Scheme: "http",
80
+ Host: listener.Addr().String(),
81
+ Path: callbackPath,
82
+ }
83
+ callbackQuery := callback.Query()
84
+ callbackQuery.Set("state", state)
85
+ callback.RawQuery = callbackQuery.Encode()
86
+
87
+ loginURL := *authBaseURL
88
+ loginURL.Path = loginPagePath
89
+ loginURL.RawPath = ""
90
+ loginURL.RawQuery = ""
91
+ loginURL.Fragment = ""
92
+ query := loginURL.Query()
93
+ query.Set("callback", callback.String())
94
+ query.Set("random_secret_key", secret)
95
+ query.Set("source", loginSource)
96
+ query.Set("device_id", deviceID)
97
+ if tokenID != "" {
98
+ query.Set("token_id", tokenID)
99
+ }
100
+ if expectedAccount != "" {
101
+ query.Set("expected_account", expectedAccount)
102
+ }
103
+ if forceRefresh {
104
+ query.Set("force", "1")
105
+ }
106
+ loginURL.RawQuery = query.Encode()
107
+
108
+ flow := &browserFlow{
109
+ loginURL: loginURL.String(),
110
+ callbackURL: callback.String(),
111
+ secret: secret,
112
+ state: state,
113
+ source: loginSource,
114
+ origin: originOf(authBaseURL),
115
+ listener: listener,
116
+ payload: make(chan accessKeyPayload, 1),
117
+ serveErr: make(chan error, 1),
118
+ }
119
+ mux := http.NewServeMux()
120
+ mux.HandleFunc(callbackPath, flow.handleCallback)
121
+ flow.server = &http.Server{
122
+ Handler: mux,
123
+ ReadHeaderTimeout: 5 * time.Second,
124
+ ReadTimeout: 10 * time.Second,
125
+ WriteTimeout: 10 * time.Second,
126
+ IdleTimeout: 15 * time.Second,
127
+ }
128
+ go func() {
129
+ err := flow.server.Serve(listener)
130
+ if err != nil && !errors.Is(err, http.ErrServerClosed) {
131
+ flow.serveErr <- err
132
+ }
133
+ close(flow.serveErr)
134
+ }()
135
+ return flow, nil
136
+ }
137
+
138
+ func (f *browserFlow) wait(ctx context.Context) (accessKeyPayload, error) {
139
+ select {
140
+ case payload := <-f.payload:
141
+ return payload, nil
142
+ case err, open := <-f.serveErr:
143
+ if open && err != nil {
144
+ return accessKeyPayload{}, errors.New("本机网页授权回调异常退出")
145
+ }
146
+ return accessKeyPayload{}, errors.New("本机网页授权回调已关闭")
147
+ case <-ctx.Done():
148
+ if errors.Is(ctx.Err(), context.DeadlineExceeded) {
149
+ return accessKeyPayload{}, errors.New("等待网页授权超时,请重新登录")
150
+ }
151
+ return accessKeyPayload{}, ctx.Err()
152
+ }
153
+ }
154
+
155
+ func (f *browserFlow) close() {
156
+ f.closeOnce.Do(func() {
157
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
158
+ defer cancel()
159
+ _ = f.server.Shutdown(ctx)
160
+ _ = f.listener.Close()
161
+ })
162
+ }
163
+
164
+ func (f *browserFlow) handleCallback(writer http.ResponseWriter, request *http.Request) {
165
+ if !f.validRequestTarget(request) {
166
+ http.Error(writer, "invalid callback target", http.StatusBadRequest)
167
+ return
168
+ }
169
+ if !constantTimeEqual(request.Header.Get("Origin"), f.origin) {
170
+ http.Error(writer, "origin not allowed", http.StatusForbidden)
171
+ return
172
+ }
173
+ f.setCORSHeaders(writer.Header())
174
+
175
+ if request.Method == http.MethodOptions {
176
+ if !strings.EqualFold(strings.TrimSpace(request.Header.Get("Access-Control-Request-Method")), http.MethodPost) ||
177
+ !allowsContentTypeHeader(request.Header.Get("Access-Control-Request-Headers")) {
178
+ http.Error(writer, "invalid preflight", http.StatusBadRequest)
179
+ return
180
+ }
181
+ writer.WriteHeader(http.StatusNoContent)
182
+ return
183
+ }
184
+ if request.Method != http.MethodPost {
185
+ writer.Header().Set("Allow", "OPTIONS, POST")
186
+ http.Error(writer, "method not allowed", http.StatusMethodNotAllowed)
187
+ return
188
+ }
189
+ mediaType, _, err := mime.ParseMediaType(request.Header.Get("Content-Type"))
190
+ if err != nil || !strings.EqualFold(mediaType, "application/json") {
191
+ http.Error(writer, "content type must be application/json", http.StatusUnsupportedMediaType)
192
+ return
193
+ }
194
+
195
+ reader := http.MaxBytesReader(writer, request.Body, maxCallbackBodyBytes)
196
+ decoder := json.NewDecoder(reader)
197
+ decoder.DisallowUnknownFields()
198
+ payload := accessKeyPayload{}
199
+ if err := decoder.Decode(&payload); err != nil {
200
+ http.Error(writer, "invalid callback payload", http.StatusBadRequest)
201
+ return
202
+ }
203
+ if err := ensureJSONEOF(decoder); err != nil {
204
+ http.Error(writer, "invalid callback payload", http.StatusBadRequest)
205
+ return
206
+ }
207
+ if payload.Type != "access_key" || !validCallbackValue(payload.AccessKey, 4096) ||
208
+ !validCallbackValue(payload.UID, 256) || !validTokenID(payload.TokenID) || payload.ExpiredAt <= 0 ||
209
+ !constantTimeEqual(payload.RandomSecretKey, f.secret) ||
210
+ !constantTimeEqual(payload.Source, f.source) ||
211
+ !constantTimeEqual(payload.CallbackURL, f.callbackURL) {
212
+ http.Error(writer, "callback binding mismatch", http.StatusBadRequest)
213
+ return
214
+ }
215
+ accepted := false
216
+ f.callbackOnce.Do(func() {
217
+ f.payload <- payload
218
+ accepted = true
219
+ })
220
+ if accepted {
221
+ writer.Header().Set("Content-Type", "application/json")
222
+ writer.WriteHeader(http.StatusOK)
223
+ _, _ = writer.Write([]byte(`{"ok":true}`))
224
+ return
225
+ }
226
+ http.Error(writer, "callback already received", http.StatusConflict)
227
+ }
228
+
229
+ func validCallbackValue(value string, maxLength int) bool {
230
+ return value != "" && len(value) <= maxLength && strings.TrimSpace(value) == value
231
+ }
232
+
233
+ func validTokenID(value string) bool {
234
+ if len(value) == 0 || len(value) > 128 {
235
+ return false
236
+ }
237
+ for index := range len(value) {
238
+ character := value[index]
239
+ if (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') ||
240
+ (character >= '0' && character <= '9') || strings.ContainsRune(":._-", rune(character)) {
241
+ continue
242
+ }
243
+ return false
244
+ }
245
+ return true
246
+ }
247
+
248
+ func validAccountBinding(value string) bool {
249
+ decoded, err := base64.RawURLEncoding.DecodeString(value)
250
+ return err == nil && len(decoded) == 16 && len(value) == 22 &&
251
+ constantTimeEqual(value, base64.RawURLEncoding.EncodeToString(decoded))
252
+ }
253
+
254
+ func (f *browserFlow) validRequestTarget(request *http.Request) bool {
255
+ if request.URL.Path != callbackPath || !constantTimeEqual(request.Host, strings.TrimPrefix(f.callbackURLHost(), "//")) {
256
+ return false
257
+ }
258
+ query := request.URL.Query()
259
+ states, ok := query["state"]
260
+ if !ok || len(states) != 1 || !constantTimeEqual(states[0], f.state) {
261
+ return false
262
+ }
263
+ for key, values := range query {
264
+ if key == "state" {
265
+ continue
266
+ }
267
+ // The site's security runtime appends these transport-only query
268
+ // parameters to cross-origin requests. They are not part of the
269
+ // callback binding: state, Origin, callback_url, and the body secret
270
+ // remain exact and independently verified below.
271
+ if key != "a_bogus" && key != "msToken" || len(values) != 1 ||
272
+ !validInjectedCallbackQueryValue(values[0]) {
273
+ return false
274
+ }
275
+ }
276
+ return true
277
+ }
278
+
279
+ func validInjectedCallbackQueryValue(value string) bool {
280
+ return value != "" && len(value) <= maxInjectedCallbackQueryValueBytes && strings.TrimSpace(value) == value
281
+ }
282
+
283
+ func (f *browserFlow) callbackURLHost() string {
284
+ parsed, err := url.Parse(f.callbackURL)
285
+ if err != nil {
286
+ return ""
287
+ }
288
+ return parsed.Host
289
+ }
290
+
291
+ func (f *browserFlow) setCORSHeaders(header http.Header) {
292
+ header.Set("Access-Control-Allow-Origin", f.origin)
293
+ header.Set("Access-Control-Allow-Methods", "POST")
294
+ header.Set("Access-Control-Allow-Headers", "Content-Type")
295
+ header.Set("Access-Control-Allow-Private-Network", "true")
296
+ header.Add("Vary", "Origin")
297
+ header.Add("Vary", "Access-Control-Request-Method")
298
+ header.Add("Vary", "Access-Control-Request-Headers")
299
+ }
300
+
301
+ func allowsContentTypeHeader(value string) bool {
302
+ for _, part := range strings.Split(value, ",") {
303
+ if strings.EqualFold(strings.TrimSpace(part), "content-type") {
304
+ return true
305
+ }
306
+ }
307
+ return false
308
+ }
309
+
310
+ func ensureJSONEOF(decoder *json.Decoder) error {
311
+ var trailing any
312
+ err := decoder.Decode(&trailing)
313
+ if errors.Is(err, io.EOF) {
314
+ return nil
315
+ }
316
+ if err == nil {
317
+ return errors.New("unexpected trailing JSON")
318
+ }
319
+ return err
320
+ }
321
+
322
+ func originOf(value *url.URL) string {
323
+ return fmt.Sprintf("%s://%s", strings.ToLower(value.Scheme), value.Host)
324
+ }