@pippit-dev/cli 1.0.41 → 1.0.43

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 (61) hide show
  1. package/README.md +42 -11
  2. package/checksums.txt +6 -6
  3. package/cmd/auth/auth.go +19 -5
  4. package/cmd/auth/auth_test.go +23 -0
  5. package/cmd/canvas/canvas.go +7 -7
  6. package/cmd/download_result.go +3 -2
  7. package/cmd/marketing.go +19 -3
  8. package/cmd/marketing_test.go +11 -5
  9. package/cmd/model.go +5 -4
  10. package/cmd/root.go +17 -5
  11. package/cmd/root_update_check_test.go +139 -0
  12. package/cmd/short_drama_test.go +2 -0
  13. package/cmd/source_test.go +27 -4
  14. package/cmd/space.go +199 -0
  15. package/cmd/space_test.go +469 -0
  16. package/cmd/upload_file_test.go +3 -1
  17. package/dist/checksums.txt +6 -6
  18. package/internal/adaptercli/commands_test.go +9 -5
  19. package/internal/auth/loopback.go +16 -11
  20. package/internal/auth/loopback_origins_test.go +152 -0
  21. package/internal/auth/space.go +26 -0
  22. package/internal/auth/space_test.go +42 -0
  23. package/internal/canvas/get.go +9 -2
  24. package/internal/commandnames/names.go +9 -4
  25. package/internal/common/client.go +23 -0
  26. package/internal/common/host_source.go +5 -0
  27. package/internal/common/host_source_test.go +9 -1
  28. package/internal/common/runner.go +5 -3
  29. package/internal/common/space.go +185 -0
  30. package/internal/common/space_test.go +132 -0
  31. package/internal/common/submit_run.go +2 -0
  32. package/internal/common/upload_file.go +2 -0
  33. package/internal/config/config.go +15 -12
  34. package/internal/generate_image/generate_image.go +3 -9
  35. package/internal/short_drama/submit_run.go +3 -9
  36. package/internal/space/session.go +153 -0
  37. package/internal/space/store.go +211 -0
  38. package/internal/space/store_test.go +246 -0
  39. package/internal/version/version.go +13 -0
  40. package/internal/version/version_test.go +38 -0
  41. package/internal/versioncheck/check.go +147 -0
  42. package/internal/versioncheck/check_test.go +204 -0
  43. package/internal/versioncheck/semver.go +84 -0
  44. package/package.json +1 -1
  45. package/scripts/canvas-command.js +47 -5
  46. package/scripts/install-wizard.js +1 -1
  47. package/scripts/run.js +15 -4
  48. package/scripts/telemetry.js +3 -1
  49. package/scripts/version-check.js +8 -1
  50. package/skills/short-drama/SKILL.md +12 -1
  51. package/skills/xyq-marketing-skill/SKILL.md +22 -3
  52. package/skills/xyq-marketing-skill/references/api.md +7 -1
  53. package/skills/xyq-marketing-skill/scripts/marketing.js +51 -19
  54. package/skills/xyq-nest-skill/SKILL.md +15 -4
  55. package/skills/xyq-nest-skill/commands/canvas-assets.md +1 -1
  56. package/skills/xyq-nest-skill/commands/canvas.md +5 -3
  57. package/skills/xyq-nest-skill/commands/get-credit-balance.md +2 -2
  58. package/skills/xyq-nest-skill/commands/model.md +1 -1
  59. package/skills/xyq-nest-skill/scripts/ensure-cli.js +8 -1
  60. package/skills/xyq-nest-skill/scripts/install.md +1 -1
  61. package/skills/xyq-nest-skill/workflows/canvas-edit.md +2 -2
@@ -0,0 +1,469 @@
1
+ package cmd
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ "encoding/json"
7
+ "errors"
8
+ "io"
9
+ "net/http"
10
+ "net/http/httptest"
11
+ "os"
12
+ "path/filepath"
13
+ "strings"
14
+ "testing"
15
+ "time"
16
+
17
+ "github.com/Pippit-dev/pippit-cli/internal/adapter"
18
+ "github.com/Pippit-dev/pippit-cli/internal/auth"
19
+ canvascore "github.com/Pippit-dev/pippit-cli/internal/canvas"
20
+ "github.com/Pippit-dev/pippit-cli/internal/common"
21
+ "github.com/Pippit-dev/pippit-cli/internal/config"
22
+ "github.com/Pippit-dev/pippit-cli/internal/space"
23
+ )
24
+
25
+ func TestSpaceCommandsAndBusinessTaskScope(t *testing.T) {
26
+ t.Setenv("HOME", t.TempDir())
27
+ t.Setenv("XDG_CACHE_HOME", t.TempDir())
28
+ t.Setenv("LocalAppData", t.TempDir())
29
+ var requests []string
30
+ var requestRef space.References
31
+ listFailure := false
32
+ listTeamID := ""
33
+ api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
34
+ requests = append(requests, r.URL.Path+":"+r.Header.Get("X-Team-Id"))
35
+ w.Header().Set("Content-Type", "application/json")
36
+ if r.URL.Path == config.GetLiteTeamListPath {
37
+ if r.Header.Get("X-Team-Id") != listTeamID {
38
+ t.Errorf("team list header = %q, want %q", r.Header.Get("X-Team-Id"), listTeamID)
39
+ }
40
+ var body struct {
41
+ Cursor string `json:"cursor"`
42
+ Count int `json:"count"`
43
+ }
44
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
45
+ t.Error(err)
46
+ }
47
+ if body.Count != 100 {
48
+ t.Error("unexpected page size")
49
+ }
50
+ if listFailure {
51
+ _, _ = w.Write([]byte(`{"ret":"101","errmsg":"denied"}`))
52
+ return
53
+ }
54
+ if body.Cursor == "0" {
55
+ _, _ = w.Write([]byte(`{"ret":"0","data":{"team_info_list":[{"team_id":"12","team_name":"A","current_role":1}],"has_more":true,"next_cursor":"next"}}`))
56
+ } else {
57
+ _, _ = w.Write([]byte(`{"ret":"0","data":{"team_info_list":[{"team_id":"34","team_name":"B"}],"has_more":false}}`))
58
+ }
59
+ return
60
+ }
61
+ if r.URL.Path == config.UploadFilePath {
62
+ _, _ = io.Copy(io.Discard, r.Body)
63
+ } else {
64
+ requestRef = space.References{}
65
+ if err := json.NewDecoder(r.Body).Decode(&requestRef); err != nil {
66
+ t.Error(err)
67
+ }
68
+ }
69
+ switch r.URL.Path {
70
+ case canvascore.QueryPath:
71
+ _, _ = w.Write([]byte(`{"ret":"0","data":{"Assets":[{"PippitAssetID":"canvas"}]}}`))
72
+ case config.SubmitRunPath:
73
+ _, _ = w.Write([]byte(`{"ret":"0","data":{"run":{"thread_id":"thread-a","run_id":"run-a"}}}`))
74
+ case config.GetThreadPath:
75
+ _, _ = w.Write([]byte(`{"ret":"0","data":{"readable_text":"task result"}}`))
76
+ case config.ListThreadFilePath:
77
+ _, _ = w.Write([]byte(`{"ret":"0","data":{"files":[],"total":0}}`))
78
+ case config.UploadFilePath:
79
+ _, _ = w.Write([]byte(`{"ret":"0","data":{"pippit_asset_id":"asset-a"}}`))
80
+ case marketingPaths["generate"]:
81
+ _, _ = w.Write([]byte(`{"ret":"0","data":{"run":{"thread_id":"marketing-thread","run_id":"marketing-run"}}}`))
82
+ case marketingPaths["query"]:
83
+ _, _ = w.Write([]byte(`{"ret":"0","data":{"thread_id":"marketing-thread","run_id":"marketing-run","run_state":2}}`))
84
+ case config.GetCreditBalancePath:
85
+ _, _ = w.Write([]byte(`{"ret":"0","data":{"total_remain_amount":"100"}}`))
86
+ case config.GetAvailableModelListPath:
87
+ _, _ = w.Write([]byte(`{"ret":"0","data":{"scene":"web_turbo_video_generator","config_key":"key","config":{"models":[{"key":"MiniMax-H3","name":"MiniMax","kind":"video","supported_ratio_list":[0,2,3],"default_ratio":3,"audio_total_limit":0}]}}}`))
88
+ default:
89
+ t.Errorf("unexpected request %s", r.URL.Path)
90
+ }
91
+ }))
92
+ defer api.Close()
93
+ cfg := config.Load()
94
+ cfg.BaseURL, cfg.AccessKey = api.URL, ""
95
+ runner := newRootRunner(cfg)
96
+ credentials := &marketingCredentialStore{credential: &auth.Credential{UID: "space-user", AccessKey: "space-test-key", ExpiredAt: time.Now().Add(time.Hour).Unix()}}
97
+ runner.Auth = auth.NewManager(cfg, auth.WithCredentialStore(credentials))
98
+ runner.SpaceStore = &space.Store{Dir: t.TempDir()}
99
+ store, err := common.SpaceStoreForRunner(context.Background(), runner)
100
+ if err != nil {
101
+ t.Fatal(err)
102
+ }
103
+ execute := func(args ...string) (string, error) {
104
+ var out, warnings bytes.Buffer
105
+ root := newRootCommand(&out, &warnings, runner)
106
+ root.SetIn(strings.NewReader(""))
107
+ root.SetArgs(args)
108
+ err := root.Execute()
109
+ return out.String(), err
110
+ }
111
+ must := func(args ...string) string {
112
+ t.Helper()
113
+ out, err := execute(args...)
114
+ if err != nil {
115
+ t.Fatalf("%v: %v", args, err)
116
+ }
117
+ return out
118
+ }
119
+ checkList := func(teamID string) {
120
+ t.Helper()
121
+ var result struct {
122
+ Spaces []listedSpace `json:"spaces"`
123
+ Current *space.Selection `json:"current"`
124
+ }
125
+ out := must("space", "list")
126
+ if err := json.Unmarshal([]byte(out), &result); err != nil {
127
+ t.Fatal(err)
128
+ }
129
+ if result.Current == nil || result.Current.TeamID != teamID || len(result.Spaces) != 3 {
130
+ t.Fatalf("unexpected space list: %s", out)
131
+ }
132
+ for _, item := range result.Spaces {
133
+ if item.Current != (item.TeamID == teamID) {
134
+ t.Fatalf("wrong current marker: %+v", item)
135
+ }
136
+ }
137
+ }
138
+ checkList("0")
139
+ must("space", "use", "12")
140
+ listTeamID = "12"
141
+ checkList("12")
142
+ file := filepath.Join(t.TempDir(), "image.png")
143
+ if err := os.WriteFile(file, []byte("media"), 0600); err != nil {
144
+ t.Fatal(err)
145
+ }
146
+ if out := must("upload-file", "--path", file); !strings.Contains(out, `"team_id":"12"`) {
147
+ t.Fatal(out)
148
+ }
149
+ if out := must("short-drama", "+submit-run", "--message", "create", "--asset-ids", "asset-a"); !strings.Contains(out, `"team_id":"12"`) {
150
+ t.Fatal(out)
151
+ }
152
+ must("space", "use", "34")
153
+ listTeamID = "34"
154
+ checkList("34")
155
+ must("model", "list", "--refresh")
156
+ if requests[len(requests)-1] != config.GetAvailableModelListPath+":34" {
157
+ t.Fatal("model query did not forward the selected team", requests)
158
+ }
159
+ must("model", "list", "--refresh", "--team-id", "0")
160
+ if requests[len(requests)-1] != config.GetAvailableModelListPath+":" {
161
+ t.Fatal("personal model query carried a team header", requests)
162
+ }
163
+ must("get-thread", "--thread-id", "thread-a", "--run-id", "run-a")
164
+ if requests[len(requests)-1] != config.GetThreadPath+":12" {
165
+ t.Fatal("old task used new default", requests)
166
+ }
167
+ for _, tc := range []struct {
168
+ args []string
169
+ path string
170
+ }{
171
+ {[]string{"get-thread", "--thread-id", " thread-a ", "--run-id", " run-a "}, config.GetThreadPath},
172
+ {[]string{"list-thread-file", "--thread-id", " thread-a "}, config.ListThreadFilePath},
173
+ {[]string{"short-drama", "+submit-run", "--thread-id", " thread-a ", "--message", "continue"}, config.SubmitRunPath},
174
+ } {
175
+ must(tc.args...)
176
+ if requests[len(requests)-1] != tc.path+":12" || requestRef.ThreadID != "thread-a" {
177
+ t.Fatalf("normalized task used wrong space: %v, %+v", requests, requestRef)
178
+ }
179
+ if tc.path == config.GetThreadPath && requestRef.RunID != "run-a" {
180
+ t.Fatalf("run ID was not normalized: %+v", requestRef)
181
+ }
182
+ }
183
+ must("get-credit-balance")
184
+ if requests[len(requests)-1] != config.GetCreditBalancePath+":34" {
185
+ t.Fatal(requests)
186
+ }
187
+ must("get-credit-balance", "--team-id", "0")
188
+ if requests[len(requests)-1] != config.GetCreditBalancePath+":" {
189
+ t.Fatal(requests)
190
+ }
191
+ // Canvas uses the same default, override and cross-process binding as other commands.
192
+ for _, tc := range []struct {
193
+ flags []string
194
+ teamID string
195
+ }{
196
+ {nil, "34"},
197
+ {[]string{"--team-id", "12"}, "12"},
198
+ {[]string{"--team-id", "0"}, "0"},
199
+ } {
200
+ var result canvascore.GetResult
201
+ out := must(append([]string{"canvas", "get", "--asset-id", "canvas"}, tc.flags...)...)
202
+ if err := json.Unmarshal([]byte(out), &result); err != nil {
203
+ t.Fatal(err)
204
+ }
205
+ header := tc.teamID
206
+ if header == "0" {
207
+ header = ""
208
+ }
209
+ if result.TeamID != tc.teamID || requests[len(requests)-1] != canvascore.QueryPath+":"+header {
210
+ t.Fatalf("canvas space = %s, requests = %v", out, requests)
211
+ }
212
+ binding, err := space.DecodeBinding(result.SpaceContext)
213
+ if err != nil || binding.TeamID != tc.teamID || binding.Account != store.Account || binding.Origin != store.Origin {
214
+ t.Fatalf("canvas binding = %+v, %v", binding, err)
215
+ }
216
+ must("canvas", "get", "--asset-id", "canvas", "--space-context", result.SpaceContext)
217
+ if requests[len(requests)-1] != canvascore.QueryPath+":"+header {
218
+ t.Fatal("canvas binding did not preserve the original space", requests)
219
+ }
220
+ }
221
+ before := len(requests)
222
+ for _, args := range [][]string{
223
+ {"get-thread", "--thread-id", "thread-a", "--team-id", "34"},
224
+ {"short-drama", "+submit-run", "--message", "wrong asset", "--asset-ids", "asset-a"},
225
+ {"get-credit-balance", "--team-id", "-1"},
226
+ {"space", "use"},
227
+ {"space", "use", "12", "34"},
228
+ {"space", "current"},
229
+ } {
230
+ if _, err := execute(args...); err == nil {
231
+ t.Errorf("accepted %v", args)
232
+ }
233
+ }
234
+ if len(requests) != before {
235
+ t.Fatal("invalid operations reached API")
236
+ }
237
+ if _, err := execute("space", "use", "99"); err == nil {
238
+ t.Fatal("nonmember selection saved")
239
+ }
240
+ listFailure = true
241
+ if _, err := execute("space", "use", "12"); err == nil {
242
+ t.Fatal("list failure ignored")
243
+ }
244
+ if selected, err := store.Current(); err != nil || selected.TeamID != "34" {
245
+ t.Fatalf("failed selection changed default: %+v, %v", selected, err)
246
+ }
247
+ before = len(requests)
248
+ must("space", "use", "0")
249
+ listTeamID = ""
250
+ if len(requests) != before {
251
+ t.Fatal("personal switch depends on team list")
252
+ }
253
+ if selected, err := store.Current(); err != nil || selected.TeamID != "0" {
254
+ t.Fatalf("personal selection not saved: %+v, %v", selected, err)
255
+ }
256
+ listFailure = false
257
+ checkList("0")
258
+ before = len(requests)
259
+ // Marketing preview resolves an existing thread before reading the new default.
260
+ if err := store.SaveDefault(space.Selection{TeamID: "34", Name: "B"}); err != nil {
261
+ t.Fatal(err)
262
+ }
263
+ requestFile := filepath.Join(t.TempDir(), "request.json")
264
+ if err := os.WriteFile(requestFile, []byte(`{"thread_id":"thread-a","message":"continue","general_agent_settings":{"video_model":"model"}}`), 0600); err != nil {
265
+ t.Fatal(err)
266
+ }
267
+ loadsBefore := credentials.loads
268
+ if out := must("marketing", "generate", "--request", requestFile); !strings.Contains(out, `"team_id":"12"`) {
269
+ t.Fatal(out)
270
+ }
271
+ if credentials.loads-loadsBefore != 2 {
272
+ t.Fatal("preview must read identity at initialization and check it once")
273
+ }
274
+ if len(requests) != before {
275
+ t.Fatal("preview contacted API")
276
+ }
277
+ if err := os.WriteFile(requestFile, []byte(`{"message":"new marketing task","general_agent_settings":{"video_model":"model"}}`), 0600); err != nil {
278
+ t.Fatal(err)
279
+ }
280
+ loadsBefore = credentials.loads
281
+ if out := must("marketing", "generate", "--request", requestFile, "--execute"); !strings.Contains(out, `"team_id":"34"`) || !strings.Contains(out, `"space_context"`) {
282
+ t.Fatal(out)
283
+ }
284
+ if credentials.loads-loadsBefore != 2 {
285
+ t.Fatal("submission must read identity at initialization and check it only at the HTTP boundary")
286
+ }
287
+ if requests[len(requests)-1] != marketingPaths["generate"]+":34" {
288
+ t.Fatal("independent marketing client lost scope", requests)
289
+ }
290
+ if err := store.SaveDefault(space.Selection{TeamID: "12", Name: "A"}); err != nil {
291
+ t.Fatal(err)
292
+ }
293
+ must("marketing", "query", "--thread-id", "marketing-thread", "--run-id", "marketing-run")
294
+ if requests[len(requests)-1] != marketingPaths["query"]+":34" {
295
+ t.Fatal("marketing task lost scope", requests)
296
+ }
297
+ }
298
+
299
+ type spaceExternalAuthAdapter struct{ adapter.Default }
300
+
301
+ func (spaceExternalAuthAdapter) CLIOptions() adapter.CLIOptions {
302
+ options := adapter.Default{}.CLIOptions()
303
+ options.LocalCredentialsEnabled = false
304
+ return options
305
+ }
306
+
307
+ func (spaceExternalAuthAdapter) PrepareAPIRequest(_ context.Context, req *http.Request) (adapter.AuthDecision, error) {
308
+ req.Header.Set("Authorization", "Bearer adapter-key")
309
+ return adapter.AuthProvided, nil
310
+ }
311
+
312
+ type spaceUnavailableAccount struct{ common.AuthManager }
313
+
314
+ func (spaceUnavailableAccount) SpaceAccount(context.Context) (string, error) {
315
+ return "", errors.New("credential metadata unavailable")
316
+ }
317
+
318
+ func TestSpaceUsesAvailableIdentityInsteadOfLocalLoginSwitch(t *testing.T) {
319
+ var requests []string
320
+ denied := false
321
+ api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
322
+ if r.Header.Get("Authorization") != "Bearer adapter-key" {
323
+ t.Error("adapter authentication was not used")
324
+ }
325
+ requests = append(requests, r.URL.Path+":"+r.Header.Get("X-Team-Id"))
326
+ switch r.URL.Path {
327
+ case config.GetLiteTeamListPath:
328
+ _, _ = w.Write([]byte(`{"ret":"0","data":{"team_info_list":[{"team_id":"12","team_name":"A"}],"has_more":false}}`))
329
+ case config.GetCreditBalancePath:
330
+ if denied {
331
+ _, _ = w.Write([]byte(`{"ret":"101","errmsg":"denied"}`))
332
+ return
333
+ }
334
+ _, _ = w.Write([]byte(`{"ret":"0","data":{"total_remain_amount":"100"}}`))
335
+ case config.UploadFilePath:
336
+ _, _ = io.Copy(io.Discard, r.Body)
337
+ _, _ = w.Write([]byte(`{"ret":"0","data":{"pippit_asset_id":"asset-a"}}`))
338
+ case config.SubmitRunPath:
339
+ _, _ = w.Write([]byte(`{"ret":"0","data":{"run":{"thread_id":"thread-a","run_id":"run-a"}}}`))
340
+ case config.GetThreadPath:
341
+ _, _ = w.Write([]byte(`{"ret":"0","data":{"readable_text":"task result"}}`))
342
+ case config.QueryGenerateVideoResultPath:
343
+ _, _ = w.Write([]byte(`{"ret":"0","data":{"thread_id":"thread-a","run_id":"run-a","run_state":2}}`))
344
+ default:
345
+ t.Errorf("unexpected request: %s", r.URL.Path)
346
+ }
347
+ }))
348
+ defer api.Close()
349
+ cfg := config.Load()
350
+ // This AK is not used by the adapter and must not supply a false identity.
351
+ cfg.BaseURL, cfg.AccessKey = api.URL, "unrelated-environment-key"
352
+ policy := spaceExternalAuthAdapter{}
353
+ runner := newRootRunnerWithAdapter(cfg, policy)
354
+ dir := filepath.Join(t.TempDir(), "space-records")
355
+ runner.SpaceStore = &space.Store{Dir: dir}
356
+ execute := func(args ...string) (string, error) {
357
+ var out bytes.Buffer
358
+ root := newRootCommandWithAdapter(&out, io.Discard, runner, policy)
359
+ root.SetArgs(args)
360
+ err := root.Execute()
361
+ return out.String(), err
362
+ }
363
+ out, err := execute("space", "list")
364
+ if err != nil || !strings.Contains(out, `"team_id":"12"`) || !strings.Contains(out, `"current":null`) {
365
+ t.Fatalf("space list without local identity = %s, %v", out, err)
366
+ }
367
+ if requests[len(requests)-1] != config.GetLiteTeamListPath+":" {
368
+ t.Fatal("team list used a selected team", requests)
369
+ }
370
+ before := len(requests)
371
+ for _, args := range [][]string{
372
+ {"space", "use", "0"}, {"space", "use", "12"},
373
+ } {
374
+ if _, err := execute(args...); err == nil || !strings.Contains(err.Error(), "无法识别当前账号") {
375
+ t.Fatalf("%v: %v", args, err)
376
+ }
377
+ }
378
+ if len(requests) != before {
379
+ t.Fatal("unknown identity tried to persist a default space")
380
+ }
381
+ for _, teamID := range []string{"0", "12", "34"} {
382
+ if _, err := execute("get-credit-balance", "--team-id", teamID); err != nil {
383
+ t.Fatal(err)
384
+ }
385
+ wantHeader := teamID
386
+ if teamID == "0" {
387
+ wantHeader = ""
388
+ }
389
+ if requests[len(requests)-1] != config.GetCreditBalancePath+":"+wantHeader {
390
+ t.Fatal("explicit space was not forwarded", requests)
391
+ }
392
+ }
393
+ file := filepath.Join(t.TempDir(), "image.png")
394
+ if err := os.WriteFile(file, []byte("image"), 0600); err != nil {
395
+ t.Fatal(err)
396
+ }
397
+ for _, tc := range []struct {
398
+ args []string
399
+ path string
400
+ }{
401
+ {[]string{"upload-file", "--path", file}, config.UploadFilePath},
402
+ {[]string{"short-drama", "+submit-run", "--message", "test", "--asset-ids", "asset-a"}, config.SubmitRunPath},
403
+ {[]string{"get-thread", "--thread-id", "thread-a"}, config.GetThreadPath},
404
+ {[]string{"marketing", "query", "--thread-id", "thread-a", "--run-id", "run-a"}, config.QueryGenerateVideoResultPath},
405
+ } {
406
+ if _, err := execute(append(tc.args, "--team-id", "12")...); err != nil {
407
+ t.Fatalf("%v: %v", tc.args, err)
408
+ }
409
+ if requests[len(requests)-1] != tc.path+":12" {
410
+ t.Fatal("explicit task space was not forwarded", requests)
411
+ }
412
+ }
413
+ before = len(requests)
414
+ denied = true
415
+ if _, err := execute("get-credit-balance", "--team-id", "12"); err == nil || !strings.Contains(err.Error(), "denied") {
416
+ t.Fatalf("server denial was not returned: %v", err)
417
+ }
418
+ denied = false
419
+ if len(requests) != before+1 || requests[len(requests)-1] != config.GetCreditBalancePath+":12" {
420
+ t.Fatal("server denial retried or switched spaces", requests)
421
+ }
422
+ if _, err := os.Stat(dir); !os.IsNotExist(err) {
423
+ t.Fatalf("unknown identity created local space records: %v", err)
424
+ }
425
+ if _, err := execute("get-credit-balance"); err != nil {
426
+ t.Fatal("ordinary adapter requests must remain available", err)
427
+ }
428
+ // Metadata lookup failure must not block an explicitly scoped API request.
429
+ cfg.AccessKey = ""
430
+ runner.Auth = spaceUnavailableAccount{}
431
+ if _, err := execute("get-credit-balance", "--team-id", "12"); err != nil {
432
+ t.Fatal("local metadata failure blocked adapter authentication", err)
433
+ }
434
+ if requests[len(requests)-1] != config.GetCreditBalancePath+":12" {
435
+ t.Fatal("metadata failure dropped explicit space", requests)
436
+ }
437
+ if _, err := execute("get-credit-balance"); err == nil {
438
+ t.Fatal("implicit space silently ignored metadata failure")
439
+ }
440
+ // With a known matching account, the login-command switch must not suppress
441
+ // saved defaults or their use by business requests.
442
+ cfg.AccessKey = "adapter-key"
443
+ runner.Auth = auth.NewManager(cfg, auth.WithCredentialStore(&marketingCredentialStore{credential: &auth.Credential{UID: "adapter-user", AccessKey: "adapter-key"}}))
444
+ if _, err := execute("space", "use", "12"); err != nil {
445
+ t.Fatal(err)
446
+ }
447
+ out, err = execute("space", "list")
448
+ if err != nil {
449
+ t.Fatal(err)
450
+ }
451
+ var result struct {
452
+ Current *space.Selection `json:"current"`
453
+ }
454
+ if err := json.Unmarshal([]byte(out), &result); err != nil || result.Current == nil || result.Current.TeamID != "12" {
455
+ t.Fatalf("space list = %s, %v", out, err)
456
+ }
457
+ if _, err := execute("get-credit-balance"); err != nil {
458
+ t.Fatal(err)
459
+ }
460
+ if requests[len(requests)-1] != config.GetCreditBalancePath+":12" {
461
+ t.Fatal("saved default was ignored", requests)
462
+ }
463
+ if _, err := execute("get-credit-balance", "--team-id", "0"); err != nil {
464
+ t.Fatal(err)
465
+ }
466
+ if requests[len(requests)-1] != config.GetCreditBalancePath+":" {
467
+ t.Fatal("personal override was ignored", requests)
468
+ }
469
+ }
@@ -2,6 +2,7 @@ package cmd
2
2
 
3
3
  import (
4
4
  "bytes"
5
+ "encoding/json"
5
6
  "io"
6
7
  "net/http"
7
8
  "net/http/httptest"
@@ -77,7 +78,8 @@ func TestUploadFileMediaContract(t *testing.T) {
77
78
  if err := root.Execute(); err != nil {
78
79
  t.Fatal(err)
79
80
  }
80
- if stdout.String() != "{\"asset_id\":\"asset_original\"}\n" || calls != 1 {
81
+ var output map[string]string
82
+ if json.Unmarshal(stdout.Bytes(), &output) != nil || output["asset_id"] != "asset_original" || output["team_id"] != "0" || calls != 1 {
81
83
  t.Fatalf("stdout = %q, requests = %d", stdout.String(), calls)
82
84
  }
83
85
  })
@@ -1,6 +1,6 @@
1
- 6f7daba79b3423b148e30373bb87e7e8998df3d76be2196e39f84af209f0ea50 pippit-tool-cli-1.0.41-darwin-amd64.tar.gz
2
- 9f03ae01c213a76f45b0dfa110272b45e5b5ecd54b188313c9d256ccbe512980 pippit-tool-cli-1.0.41-darwin-arm64.tar.gz
3
- ea603412d1c5b0baa42efd34bec92fcd0907333babd64a6e08514d4e2dcb86e5 pippit-tool-cli-1.0.41-linux-amd64.tar.gz
4
- cebd1f0f34157624c0fc8203eec306bd6c7e31a2db44089f52bca9c74c57ee03 pippit-tool-cli-1.0.41-linux-arm64.tar.gz
5
- 729f413373d185964a42c178a7e0eeafc3fca490670fb753dc36fb38bb47aaf7 pippit-tool-cli-1.0.41-windows-amd64.zip
6
- b49491ae751e72f994da9388777a43f1b6b5781399dc2da15ced6d74f48dad6d pippit-tool-cli-1.0.41-windows-arm64.zip
1
+ e7ee32acef1c0a16912c46500ae7f15c46d920953792d1012886fac7e6424b2e pippit-tool-cli-1.0.43-darwin-amd64.tar.gz
2
+ 57e4df86eaa244cd9845eb966299c8c2e1186f74495da0c16f0b378fb4bc5125 pippit-tool-cli-1.0.43-darwin-arm64.tar.gz
3
+ 21823c7a675ebcaadebe2ddbfa0324b9e939220d4dd74d5d5d4546f5e6938060 pippit-tool-cli-1.0.43-linux-amd64.tar.gz
4
+ e387bc8eaeafa430a9a594ed1cc381964953b598875b520a6d009a12aec9d619 pippit-tool-cli-1.0.43-linux-arm64.tar.gz
5
+ 4569d389a20c75f0485ecc24ad5034308a818f294886dabdf07b2804e915f3a2 pippit-tool-cli-1.0.43-windows-amd64.zip
6
+ 078fb4fb09353a58095e1370668884664aad1760ae065983bb23abfdcd9939d1 pippit-tool-cli-1.0.43-windows-arm64.zip
@@ -12,22 +12,26 @@ func TestConfigureDefaultAllowRootCommands(t *testing.T) {
12
12
  for _, tc := range []struct {
13
13
  name string
14
14
  defaultAllow bool
15
+ localAuth bool
15
16
  allowlist []string
16
17
  want []string
17
18
  }{
18
19
  {name: "默认拒绝且 nil 白名单"},
19
20
  {name: "默认拒绝且空白名单", allowlist: []string{}},
20
21
  {name: "默认拒绝且有白名单", allowlist: []string{"canvas"}, want: []string{"canvas"}},
21
- {name: "默认放行且 nil 白名单", defaultAllow: true, want: []string{"canvas", "future-command"}},
22
- {name: "默认放行且空白名单", defaultAllow: true, allowlist: []string{}, want: []string{"canvas", "future-command"}},
23
- {name: "默认放行且有白名单", defaultAllow: true, allowlist: []string{"canvas"}, want: []string{"canvas", "future-command"}},
22
+ {name: "默认放行且 nil 白名单", defaultAllow: true, want: []string{"canvas", "future-command", "space"}},
23
+ {name: "默认放行且空白名单", defaultAllow: true, allowlist: []string{}, want: []string{"canvas", "future-command", "space"}},
24
+ {name: "默认放行且有白名单", defaultAllow: true, allowlist: []string{"canvas"}, want: []string{"canvas", "future-command", "space"}},
25
+ {name: "启用本地账号", defaultAllow: true, localAuth: true, want: []string{"canvas", "future-command", "login", "logout", "space", "status"}},
26
+ {name: "启用本地账号仍受白名单限制", localAuth: true, allowlist: []string{"space"}, want: []string{"space"}},
27
+ {name: "关闭本地登录仍允许空间命令", allowlist: []string{"login", "logout", "space", "status"}, want: []string{"space"}},
24
28
  } {
25
29
  t.Run(tc.name, func(t *testing.T) {
26
30
  root := &cobra.Command{Use: "tool"}
27
- for _, name := range []string{"canvas", "future-command", "login", "logout", "status", "update"} {
31
+ for _, name := range []string{"canvas", "future-command", "login", "logout", "space", "status", "update"} {
28
32
  root.AddCommand(&cobra.Command{Use: name})
29
33
  }
30
- Configure(root, adapter.CLIOptions{DefaultAllowRootCommands: tc.defaultAllow, AllowedRootCommands: tc.allowlist})
34
+ Configure(root, adapter.CLIOptions{DefaultAllowRootCommands: tc.defaultAllow, AllowedRootCommands: tc.allowlist, LocalCredentialsEnabled: tc.localAuth})
31
35
  var got []string
32
36
  for _, child := range root.Commands() {
33
37
  got = append(got, child.Name())
@@ -5,7 +5,6 @@ import (
5
5
  "encoding/base64"
6
6
  "encoding/json"
7
7
  "errors"
8
- "fmt"
9
8
  "io"
10
9
  "mime"
11
10
  "net"
@@ -14,6 +13,8 @@ import (
14
13
  "strings"
15
14
  "sync"
16
15
  "time"
16
+
17
+ "github.com/Pippit-dev/pippit-cli/internal/version"
17
18
  )
18
19
 
19
20
  const maxCallbackBodyBytes = 64 << 10
@@ -37,7 +38,6 @@ type browserFlow struct {
37
38
  secret string
38
39
  state string
39
40
  source string
40
- origin string
41
41
  listener net.Listener
42
42
  server *http.Server
43
43
  payload chan accessKeyPayload
@@ -93,6 +93,9 @@ func startBrowserFlow(authBaseURL *url.URL, randomReader io.Reader, deviceID, to
93
93
  query.Set("callback", callback.String())
94
94
  query.Set("random_secret_key", secret)
95
95
  query.Set("source", loginSource)
96
+ query.Set("cli_version", version.BuildVersion())
97
+ // Protocol 1 supports callbacks from both official login origins.
98
+ query.Set("auth_protocol", "1")
96
99
  query.Set("device_id", deviceID)
97
100
  if tokenID != "" {
98
101
  query.Set("token_id", tokenID)
@@ -111,7 +114,6 @@ func startBrowserFlow(authBaseURL *url.URL, randomReader io.Reader, deviceID, to
111
114
  secret: secret,
112
115
  state: state,
113
116
  source: loginSource,
114
- origin: originOf(authBaseURL),
115
117
  listener: listener,
116
118
  payload: make(chan accessKeyPayload, 1),
117
119
  serveErr: make(chan error, 1),
@@ -166,11 +168,12 @@ func (f *browserFlow) handleCallback(writer http.ResponseWriter, request *http.R
166
168
  http.Error(writer, "invalid callback target", http.StatusBadRequest)
167
169
  return
168
170
  }
169
- if !constantTimeEqual(request.Header.Get("Origin"), f.origin) {
171
+ origin := request.Header.Get("Origin")
172
+ if !isAllowedAuthOrigin(origin) {
170
173
  http.Error(writer, "origin not allowed", http.StatusForbidden)
171
174
  return
172
175
  }
173
- f.setCORSHeaders(writer.Header())
176
+ f.setCORSHeaders(writer.Header(), origin)
174
177
 
175
178
  if request.Method == http.MethodOptions {
176
179
  if !strings.EqualFold(strings.TrimSpace(request.Header.Get("Access-Control-Request-Method")), http.MethodPost) ||
@@ -288,8 +291,14 @@ func (f *browserFlow) callbackURLHost() string {
288
291
  return parsed.Host
289
292
  }
290
293
 
291
- func (f *browserFlow) setCORSHeaders(header http.Header) {
292
- header.Set("Access-Control-Allow-Origin", f.origin)
294
+ // Authentication trusts only these official HTTPS origins, independently of
295
+ // runtime business API configuration or browser-supplied query parameters.
296
+ func isAllowedAuthOrigin(origin string) bool {
297
+ return origin == "https://xyq.jianying.com" || origin == "https://www.xiaoyunque.com"
298
+ }
299
+
300
+ func (f *browserFlow) setCORSHeaders(header http.Header, origin string) {
301
+ header.Set("Access-Control-Allow-Origin", origin)
293
302
  header.Set("Access-Control-Allow-Methods", "POST")
294
303
  header.Set("Access-Control-Allow-Headers", "Content-Type")
295
304
  header.Set("Access-Control-Allow-Private-Network", "true")
@@ -318,7 +327,3 @@ func ensureJSONEOF(decoder *json.Decoder) error {
318
327
  }
319
328
  return err
320
329
  }
321
-
322
- func originOf(value *url.URL) string {
323
- return fmt.Sprintf("%s://%s", strings.ToLower(value.Scheme), value.Host)
324
- }