@pippit-dev/cli 1.0.42 → 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 (52) 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 +13 -5
  11. package/cmd/short_drama_test.go +2 -0
  12. package/cmd/source_test.go +27 -4
  13. package/cmd/space.go +199 -0
  14. package/cmd/space_test.go +469 -0
  15. package/cmd/upload_file_test.go +3 -1
  16. package/dist/checksums.txt +6 -6
  17. package/internal/adaptercli/commands_test.go +9 -5
  18. package/internal/auth/space.go +26 -0
  19. package/internal/auth/space_test.go +42 -0
  20. package/internal/canvas/get.go +9 -2
  21. package/internal/commandnames/names.go +9 -4
  22. package/internal/common/client.go +23 -0
  23. package/internal/common/host_source.go +5 -0
  24. package/internal/common/host_source_test.go +9 -1
  25. package/internal/common/runner.go +5 -3
  26. package/internal/common/space.go +185 -0
  27. package/internal/common/space_test.go +132 -0
  28. package/internal/common/submit_run.go +2 -0
  29. package/internal/common/upload_file.go +2 -0
  30. package/internal/config/config.go +15 -12
  31. package/internal/generate_image/generate_image.go +3 -9
  32. package/internal/short_drama/submit_run.go +3 -9
  33. package/internal/space/session.go +153 -0
  34. package/internal/space/store.go +211 -0
  35. package/internal/space/store_test.go +246 -0
  36. package/package.json +1 -1
  37. package/scripts/canvas-command.js +37 -4
  38. package/scripts/install-wizard.js +1 -1
  39. package/scripts/run.js +2 -2
  40. package/scripts/telemetry.js +3 -1
  41. package/skills/short-drama/SKILL.md +12 -1
  42. package/skills/xyq-marketing-skill/SKILL.md +22 -3
  43. package/skills/xyq-marketing-skill/references/api.md +7 -1
  44. package/skills/xyq-marketing-skill/scripts/marketing.js +42 -18
  45. package/skills/xyq-nest-skill/SKILL.md +15 -4
  46. package/skills/xyq-nest-skill/commands/canvas-assets.md +1 -1
  47. package/skills/xyq-nest-skill/commands/canvas.md +5 -3
  48. package/skills/xyq-nest-skill/commands/get-credit-balance.md +2 -2
  49. package/skills/xyq-nest-skill/commands/model.md +1 -1
  50. package/skills/xyq-nest-skill/scripts/ensure-cli.js +8 -1
  51. package/skills/xyq-nest-skill/scripts/install.md +1 -1
  52. package/skills/xyq-nest-skill/workflows/canvas-edit.md +2 -2
@@ -1,6 +1,6 @@
1
- a27b2392e4a291329599d6ee02dd704fde6eb2bf34f3390b30a973bce0009259 pippit-tool-cli-1.0.42-darwin-amd64.tar.gz
2
- 921fa4fff779f0c7fca7a6176d0aa47c23fe3debd78df29d3bca50ae1eff0e54 pippit-tool-cli-1.0.42-darwin-arm64.tar.gz
3
- f786c2954c1d537638ef248c420bbf8d9fe8040f6ee6ce7a5467c6fe73689747 pippit-tool-cli-1.0.42-linux-amd64.tar.gz
4
- a6adb356975c1724e553ec8fdc9aa602a259a081171f41c84680946c1459cea9 pippit-tool-cli-1.0.42-linux-arm64.tar.gz
5
- 9d1385bec216144594b83c42c244ebd9cc6eafe7758f54a1dc636f7ee231aea7 pippit-tool-cli-1.0.42-windows-amd64.zip
6
- 008d4f238b2fd66db3e4ede1425ec6919853889359dd60b379939b7f73452154 pippit-tool-cli-1.0.42-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())
@@ -0,0 +1,26 @@
1
+ package auth
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+
7
+ "github.com/Pippit-dev/pippit-cli/internal/space"
8
+ )
9
+
10
+ // SpaceAccount uses the verified browser UID, stable across AK rotation.
11
+ func (m *Manager) SpaceAccount(ctx context.Context) (string, error) {
12
+ // Re-read at request boundaries so an account switch in another process
13
+ // cannot keep using this process's cached identity or credential.
14
+ m.clearCredentialCache()
15
+ credential, err := m.loadCredential(ctx)
16
+ if errors.Is(err, ErrCredentialNotFound) {
17
+ return "", nil
18
+ }
19
+ if err != nil {
20
+ return "", err
21
+ }
22
+ if credential.UID == "" || credential.AccessKey == "" {
23
+ return "", nil
24
+ }
25
+ return "user:" + space.Digest(credential.UID), nil
26
+ }
@@ -0,0 +1,42 @@
1
+ package auth
2
+
3
+ import (
4
+ "context"
5
+ "strings"
6
+ "testing"
7
+ "time"
8
+
9
+ "github.com/Pippit-dev/pippit-cli/internal/config"
10
+ )
11
+
12
+ func TestSpaceAccountRefreshesCredentialAndSurvivesAKRotation(t *testing.T) {
13
+ ctx := context.Background()
14
+ cfg := config.Load()
15
+ cfg.AccessKey = ""
16
+ store := &memoryCredentialStore{credential: &Credential{UID: "user-a", AccessKey: "key-a", ExpiredAt: time.Now().Add(time.Hour).Unix()}}
17
+ manager := NewManager(cfg, WithCredentialStore(store))
18
+ a, err := manager.SpaceAccount(ctx)
19
+ if err != nil {
20
+ t.Fatal(err)
21
+ }
22
+ if strings.Contains(a, "user-a") || strings.Contains(a, "key-a") {
23
+ t.Fatal("raw identity leaked")
24
+ }
25
+ if err := store.Save(ctx, &Credential{UID: "user-a", AccessKey: "key-rotated", ExpiredAt: time.Now().Add(time.Hour).Unix()}); err != nil {
26
+ t.Fatal(err)
27
+ }
28
+ after, err := manager.SpaceAccount(ctx)
29
+ if err != nil || after != a {
30
+ t.Fatalf("rotation changed identity: %s, %v", after, err)
31
+ }
32
+ if key, err := manager.ResolveAccessKey(ctx); err != nil || key != "key-rotated" {
33
+ t.Fatalf("request used stale credential: %v", err)
34
+ }
35
+ if err := store.Save(ctx, &Credential{UID: "user-b", AccessKey: "key-b", ExpiredAt: time.Now().Add(time.Hour).Unix()}); err != nil {
36
+ t.Fatal(err)
37
+ }
38
+ b, err := manager.SpaceAccount(ctx)
39
+ if err != nil || b == a {
40
+ t.Fatal("new account inherited old identity", err)
41
+ }
42
+ }
@@ -14,6 +14,8 @@ type GetOptions struct {
14
14
  }
15
15
 
16
16
  type GetResult struct {
17
+ TeamID string `json:"team_id"`
18
+ SpaceContext string `json:"space_context,omitempty"`
17
19
  RequestedAssetIDs []string `json:"requested_asset_ids"`
18
20
  Assets []json.RawMessage `json:"assets"`
19
21
  LogID string `json:"log_id,omitempty"`
@@ -77,11 +79,16 @@ func queryAssets(ctx context.Context, assetIDs []string, requireAll bool, runner
77
79
  envelope.LogID,
78
80
  )
79
81
  }
80
- return &GetResult{
82
+ result := &GetResult{
83
+ TeamID: common.SpaceTeamID(ctx),
81
84
  RequestedAssetIDs: append([]string(nil), assetIDs...),
82
85
  Assets: ordered,
83
86
  LogID: strings.TrimSpace(envelope.LogID),
84
- }, nil
87
+ }
88
+ if session := common.SpaceSession(ctx); session != nil {
89
+ result.SpaceContext = session.Token()
90
+ }
91
+ return result, nil
85
92
  }
86
93
 
87
94
  func normalizeAssetIDs(values []string) ([]string, error) {
@@ -2,8 +2,13 @@
2
2
  package commandnames
3
3
 
4
4
  const (
5
- Login = "login"
6
- Logout = "logout"
7
- Status = "status"
8
- Update = "update"
5
+ Login = "login"
6
+ Logout = "logout"
7
+ Status = "status"
8
+ Space = "space"
9
+ Update = "update"
10
+ Completion = "completion"
11
+ Help = "help"
12
+ Model = "model"
13
+ DownloadResult = "download-result"
9
14
  )
@@ -90,6 +90,11 @@ func (c *httpClient) SendRequest(ctx context.Context, path string, body any, out
90
90
  }
91
91
 
92
92
  func (c *httpClient) SendRequestWithHeaders(ctx context.Context, path string, body any, headers map[string]string, out any) error {
93
+ var scopeErr error
94
+ ctx, scopeErr = c.prepareSpaceRequest(ctx, path, body)
95
+ if scopeErr != nil {
96
+ return scopeErr
97
+ }
93
98
  method := http.MethodPost
94
99
  if body == nil {
95
100
  method = http.MethodGet
@@ -145,6 +150,11 @@ func (c *httpClient) SendRequestWithHeaders(ctx context.Context, path string, bo
145
150
  }
146
151
 
147
152
  func (c *httpClient) SendMultipartRequest(ctx context.Context, path string, fields map[string]string, file MultipartFile, out any) error {
153
+ var scopeErr error
154
+ ctx, scopeErr = c.prepareSpaceRequest(ctx, path, nil)
155
+ if scopeErr != nil {
156
+ return scopeErr
157
+ }
148
158
  reqURL, err := c.resolveURL(path, nil)
149
159
  if err != nil {
150
160
  return err
@@ -242,6 +252,7 @@ func (c *httpClient) prepareRequest(ctx context.Context, req *http.Request, head
242
252
  c.injectHeaders(req, headers)
243
253
  // Rebuild the authentication header only for requests to the configured API origin; general request headers cannot override it.
244
254
  req.Header.Del("Authorization")
255
+ req.Header.Del("X-Team-Id")
245
256
  if !isAPIOrigin {
246
257
  c.adapter.StripSensitiveHeaders(req)
247
258
  return nil
@@ -250,6 +261,16 @@ func (c *httpClient) prepareRequest(ctx context.Context, req *http.Request, head
250
261
  if err != nil {
251
262
  return err
252
263
  }
264
+ // The selected space, like credentials, is rebuilt only for the API origin.
265
+ if session := SpaceSession(ctx); session != nil {
266
+ if err := session.CheckIdentity(ctx); err != nil {
267
+ return err
268
+ }
269
+ req.Header.Del("X-Team-Id")
270
+ if session.TeamID() != "0" {
271
+ req.Header.Set("X-Team-Id", session.TeamID())
272
+ }
273
+ }
253
274
  switch decision {
254
275
  case adapter.AuthProvided:
255
276
  return nil
@@ -313,6 +334,7 @@ func (c *httpClient) checkRedirect(req *http.Request, via []*http.Request) error
313
334
  }
314
335
  if !trusted {
315
336
  req.Header.Del("Authorization")
337
+ req.Header.Del("X-Team-Id")
316
338
  c.adapter.StripSensitiveHeaders(req)
317
339
  }
318
340
  return nil
@@ -362,6 +384,7 @@ func (c *httpClient) do(req *http.Request, out any) error {
362
384
  if err := sonic.Unmarshal(data, out); err != nil {
363
385
  return fmt.Errorf("解析响应体失败: %w", err)
364
386
  }
387
+ rememberSpaceResponse(req.Context(), data)
365
388
  return nil
366
389
  }
367
390
 
@@ -22,6 +22,11 @@ func detectHostSource(getenv func(string) string) string {
22
22
  {"CLAUDECODE", "claude_code"},
23
23
  {"CURSOR_AGENT", "cursor"},
24
24
  {"GEMINI_CLI", "gemini_cli"},
25
+ {"OPENCODE", "opencode"},
26
+ {"QWEN_CODE", "qwen_code"},
27
+ {"CLINE_ACTIVE", "cline"},
28
+ {"COPILOT_CLI", "github_copilot_cli"},
29
+ {"COPILOT_AGENT_SESSION_ID", "github_copilot_cli"},
25
30
  } {
26
31
  value := strings.ToLower(strings.TrimSpace(getenv(marker.key)))
27
32
  if value == "" || value == "0" || value == "false" {
@@ -14,9 +14,17 @@ func TestDetectHostSource(t *testing.T) {
14
14
  {"claude", map[string]string{"CLAUDECODE": "1"}, "claude_code"},
15
15
  {"cursor", map[string]string{"CURSOR_AGENT": "1"}, "cursor"},
16
16
  {"gemini", map[string]string{"GEMINI_CLI": "1"}, "gemini_cli"},
17
+ {"opencode", map[string]string{"OPENCODE": "1"}, "opencode"},
18
+ {"qwen", map[string]string{"QWEN_CODE": "1"}, "qwen_code"},
19
+ {"cline", map[string]string{"CLINE_ACTIVE": " true "}, "cline"},
20
+ {"copilot cli", map[string]string{"COPILOT_CLI": "1"}, "github_copilot_cli"},
21
+ {"copilot session", map[string]string{"COPILOT_AGENT_SESSION_ID": "secret"}, "github_copilot_cli"},
22
+ {"copilot markers agree", map[string]string{"COPILOT_CLI": "1", "COPILOT_AGENT_SESSION_ID": "secret"}, "github_copilot_cli"},
17
23
  {"ambiguous", map[string]string{"CODEX_THREAD_ID": "secret", "CURSOR_AGENT": "1"}, ""},
24
+ {"new markers conflict", map[string]string{"OPENCODE": "1", "CLINE_ACTIVE": "true"}, ""},
18
25
  {"disabled markers", map[string]string{"CLAUDECODE": "0", "GEMINI_CLI": " false "}, ""},
19
- {"configuration is not runtime evidence", map[string]string{"CODEX_HOME": "/home/example", "ANTHROPIC_API_KEY": "secret", "TERM_PROGRAM": "vscode"}, ""},
26
+ {"new markers disabled", map[string]string{"OPENCODE": "0", "QWEN_CODE": " FALSE ", "CLINE_ACTIVE": "", "COPILOT_CLI": "false", "COPILOT_AGENT_SESSION_ID": " "}, ""},
27
+ {"configuration is not runtime evidence", map[string]string{"CODEX_HOME": "/home/example", "ANTHROPIC_API_KEY": "secret", "TERM_PROGRAM": "vscode", "AGENT": "1", "OPENCODE_CONFIG": "/example/config.json", "QWEN_API_KEY": "secret", "COPILOT_HOME": "/example/copilot"}, ""},
20
28
  } {
21
29
  t.Run(tc.name, func(t *testing.T) {
22
30
  if got := detectHostSource(func(key string) string { return tc.env[key] }); got != tc.want {
@@ -5,6 +5,7 @@ import (
5
5
 
6
6
  "github.com/Pippit-dev/pippit-cli/internal/auth"
7
7
  "github.com/Pippit-dev/pippit-cli/internal/config"
8
+ "github.com/Pippit-dev/pippit-cli/internal/space"
8
9
  )
9
10
 
10
11
  // AuthManager is the credential boundary shared by native CLI commands. It
@@ -20,9 +21,10 @@ type AuthManager interface {
20
21
 
21
22
  // Runner carries runtime dependencies for command execution.
22
23
  type Runner struct {
23
- Config *config.Config
24
- Client Client
25
- Auth AuthManager
24
+ Config *config.Config
25
+ Client Client
26
+ Auth AuthManager
27
+ SpaceStore *space.Store
26
28
  }
27
29
 
28
30
  func NewRunner(cfg *config.Config, client Client) *Runner {
@@ -0,0 +1,185 @@
1
+ package common
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "fmt"
7
+ "net/url"
8
+
9
+ "github.com/Pippit-dev/pippit-cli/internal/config"
10
+ "github.com/Pippit-dev/pippit-cli/internal/space"
11
+ )
12
+
13
+ type spaceContextKey struct{}
14
+ type spaceRequestKey struct{}
15
+ type spaceRequest struct {
16
+ operation string
17
+ refs space.References
18
+ }
19
+
20
+ func WithSpace(ctx context.Context, session *space.Session) context.Context {
21
+ return context.WithValue(ctx, spaceContextKey{}, session)
22
+ }
23
+
24
+ func SpaceSession(ctx context.Context) *space.Session {
25
+ session, _ := ctx.Value(spaceContextKey{}).(*space.Session)
26
+ return session
27
+ }
28
+
29
+ func SpaceTeamID(ctx context.Context) string { return SpaceSession(ctx).TeamID() }
30
+
31
+ // spaceOperation classifies successful responses for local records only.
32
+ // It does not decide whether a request carries the selected TeamID.
33
+ func spaceOperation(path string) string {
34
+ u, err := url.Parse(path)
35
+ if err != nil {
36
+ return ""
37
+ }
38
+ switch u.Path {
39
+ case config.SubmitRunPath, config.SubmitMarketingRunPath:
40
+ return "submit"
41
+ case config.GetThreadPath, config.QueryGenerateVideoResultPath:
42
+ return "query"
43
+ case config.ListThreadFilePath:
44
+ return "files"
45
+ case config.UploadFilePath:
46
+ return "upload"
47
+ }
48
+ return ""
49
+ }
50
+
51
+ func SpaceReferences(body any) (space.References, error) {
52
+ var ref space.References
53
+ if body == nil {
54
+ return ref, nil
55
+ }
56
+ data, err := json.Marshal(body)
57
+ if err != nil {
58
+ return ref, err
59
+ }
60
+ if err := json.Unmarshal(data, &ref); err != nil {
61
+ return ref, fmt.Errorf("任务或素材 ID 格式无效: %w", err)
62
+ }
63
+ return ref, nil
64
+ }
65
+
66
+ func (c *httpClient) prepareSpaceRequest(ctx context.Context, path string, body any) (context.Context, error) {
67
+ session := SpaceSession(ctx)
68
+ if session == nil {
69
+ return ctx, nil
70
+ }
71
+ if target, err := url.Parse(path); err == nil && target.IsAbs() {
72
+ trusted, err := c.isBaseURLOrigin(target)
73
+ if err != nil {
74
+ return ctx, err
75
+ }
76
+ if !trusted {
77
+ return ctx, nil
78
+ }
79
+ }
80
+ ref, err := SpaceReferences(body)
81
+ if err != nil {
82
+ return ctx, err
83
+ }
84
+ if _, err := session.Resolve(ref); err != nil {
85
+ return ctx, err
86
+ }
87
+ return context.WithValue(ctx, spaceRequestKey{}, spaceRequest{operation: spaceOperation(path), refs: ref}), nil
88
+ }
89
+
90
+ func rememberSpaceResponse(ctx context.Context, data []byte) {
91
+ session := SpaceSession(ctx)
92
+ request, ok := ctx.Value(spaceRequestKey{}).(spaceRequest)
93
+ if session == nil || !ok || request.operation == "" {
94
+ return
95
+ }
96
+ var response struct {
97
+ Ret json.RawMessage `json:"ret"`
98
+ Data *struct {
99
+ ThreadID string `json:"thread_id"`
100
+ RunID string `json:"run_id"`
101
+ PippitAssetID string `json:"pippit_asset_id"`
102
+ Run struct {
103
+ ThreadID string `json:"thread_id"`
104
+ RunID string `json:"run_id"`
105
+ } `json:"run"`
106
+ } `json:"data"`
107
+ }
108
+ if json.Unmarshal(data, &response) != nil || response.Data == nil || (string(response.Ret) != `"0"` && string(response.Ret) != "0") {
109
+ return
110
+ }
111
+ ref := request.refs
112
+ switch request.operation {
113
+ case "submit":
114
+ if response.Data.Run.ThreadID == "" || response.Data.Run.RunID == "" {
115
+ return
116
+ }
117
+ if ref.ThreadID != "" && ref.ThreadID != response.Data.Run.ThreadID {
118
+ return
119
+ }
120
+ ref.ThreadID, ref.RunID = response.Data.Run.ThreadID, response.Data.Run.RunID
121
+ case "query", "files":
122
+ if ref.ThreadID == "" {
123
+ return
124
+ }
125
+ if response.Data.ThreadID != "" && response.Data.ThreadID != ref.ThreadID {
126
+ return
127
+ }
128
+ if response.Data.RunID != "" && response.Data.RunID != ref.RunID {
129
+ return
130
+ }
131
+ case "upload":
132
+ if response.Data.PippitAssetID == "" {
133
+ return
134
+ }
135
+ ref.AssetIDs = []string{response.Data.PippitAssetID}
136
+ default:
137
+ return
138
+ }
139
+ session.Remember(ref)
140
+ }
141
+
142
+ func SpaceAccount(ctx context.Context, runner *Runner) (string, error) {
143
+ // Without an AuthManager, authentication belongs to the adapter. An unused
144
+ // environment AK must not stand in for the adapter's actual account.
145
+ if runner.Auth == nil {
146
+ return "", nil
147
+ }
148
+ if provider, ok := runner.Auth.(interface {
149
+ SpaceAccount(context.Context) (string, error)
150
+ }); ok {
151
+ return provider.SpaceAccount(ctx)
152
+ }
153
+ status, err := runner.Auth.Status(ctx)
154
+ if err != nil {
155
+ return "", err
156
+ }
157
+ if status != nil && status.UID != "" {
158
+ return "user:" + space.Digest(status.UID), nil
159
+ }
160
+ return "", nil
161
+ }
162
+
163
+ // SpaceStoreForRunner returns nil when the current account cannot be identified.
164
+ // Callers can use an explicit space without persisting local records.
165
+ func SpaceStoreForRunner(ctx context.Context, runner *Runner) (*space.Store, error) {
166
+ account, err := SpaceAccount(ctx, runner)
167
+ if err != nil {
168
+ return nil, err
169
+ }
170
+ if account == "" {
171
+ return nil, nil
172
+ }
173
+ base := config.DefaultBaseURL
174
+ if runner.Config != nil && runner.Config.BaseURL != "" {
175
+ base = runner.Config.BaseURL
176
+ }
177
+ store, err := space.NewStore(base, account)
178
+ if err != nil {
179
+ return nil, err
180
+ }
181
+ if runner.SpaceStore != nil {
182
+ store.Dir = runner.SpaceStore.Dir
183
+ }
184
+ return store, nil
185
+ }
@@ -0,0 +1,132 @@
1
+ package common
2
+
3
+ import (
4
+ "context"
5
+ "io"
6
+ "net/http"
7
+ "net/http/httptest"
8
+ "os"
9
+ "path/filepath"
10
+ "testing"
11
+ "time"
12
+
13
+ "github.com/Pippit-dev/pippit-cli/internal/adapter"
14
+ "github.com/Pippit-dev/pippit-cli/internal/config"
15
+ "github.com/Pippit-dev/pippit-cli/internal/space"
16
+ )
17
+
18
+ type spaceRewriteAdapter struct{ adapter.Default }
19
+
20
+ func (spaceRewriteAdapter) PrepareAPIRequest(_ context.Context, r *http.Request) (adapter.AuthDecision, error) {
21
+ r.URL.Path = "/rewritten" + r.URL.Path
22
+ return adapter.UseDefaultAuth, nil
23
+ }
24
+
25
+ func TestSpaceHeadersAndTaskRecoveryThroughHTTP(t *testing.T) {
26
+ var headers []string
27
+ api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
28
+ headers = append(headers, r.Header.Get("X-Team-Id"))
29
+ if r.Header.Get("Authorization") != "Bearer test-key" {
30
+ t.Error("missing authorization")
31
+ }
32
+ _, _ = io.Copy(io.Discard, r.Body)
33
+ switch r.URL.Path {
34
+ case config.UploadFilePath:
35
+ _, _ = w.Write([]byte(`{"ret":"0","data":{"pippit_asset_id":"asset"}}`))
36
+ case config.SubmitRunPath:
37
+ _, _ = w.Write([]byte(`{"ret":"0","data":{"run":{"thread_id":"thread","run_id":"run"}}}`))
38
+ default:
39
+ _, _ = w.Write([]byte(`{"ret":"0","data":{}}`))
40
+ }
41
+ }))
42
+ defer api.Close()
43
+ store := &space.Store{Dir: t.TempDir(), Account: "account", Origin: api.URL}
44
+ if err := store.SaveDefault(space.Selection{TeamID: "12", Name: "A"}); err != nil {
45
+ t.Fatal(err)
46
+ }
47
+ account, identityChecks := store.Account, 0
48
+ ctx := WithSpace(context.Background(), &space.Session{Store: store, Identity: func(context.Context) (string, error) {
49
+ identityChecks++
50
+ return account, nil
51
+ }})
52
+ client := NewHTTPClient(api.URL, time.Second, NewAccessKeyAuthorizer("test-key"))
53
+ file := filepath.Join(t.TempDir(), "image.png")
54
+ if err := os.WriteFile(file, []byte("image"), 0600); err != nil {
55
+ t.Fatal(err)
56
+ }
57
+ var result map[string]any
58
+ if err := client.SendMultipartRequest(ctx, config.UploadFilePath, nil, MultipartFile{FieldName: "file", Path: file}, &result); err != nil {
59
+ t.Fatal(err)
60
+ }
61
+ if identityChecks != 1 {
62
+ t.Fatalf("identity checks per request = %d, want 1", identityChecks)
63
+ }
64
+ if err := store.SaveDefault(space.Selection{TeamID: "34", Name: "B"}); err != nil {
65
+ t.Fatal(err)
66
+ }
67
+ if err := client.SendRequest(ctx, config.SubmitRunPath, map[string]any{"asset_ids": []string{"asset"}}, &result); err != nil {
68
+ t.Fatal(err)
69
+ }
70
+ // A new command recovers the original task even after a saved-space switch.
71
+ queryCtx := WithSpace(context.Background(), &space.Session{Store: store})
72
+ if err := client.SendRequest(queryCtx, api.URL+config.GetThreadPath, space.References{ThreadID: "thread", RunID: "run"}, &result); err != nil {
73
+ t.Fatal(err)
74
+ }
75
+ if err := client.SendRequestWithHeaders(ctx, config.GetAvailableModelListPath, nil, map[string]string{"X-Team-Id": "999"}, &result); err != nil {
76
+ t.Fatal(err)
77
+ }
78
+ zero := "0"
79
+ personal := WithSpace(context.Background(), &space.Session{Store: store, Explicit: &zero})
80
+ if err := client.SendRequest(personal, config.GetCreditBalancePath, struct{}{}, &result); err != nil {
81
+ t.Fatal(err)
82
+ }
83
+ for i, want := range []string{"12", "12", "12", "12", ""} {
84
+ if headers[i] != want {
85
+ t.Errorf("request %d team = %q, want %q", i, headers[i], want)
86
+ }
87
+ }
88
+ other := "34"
89
+ conflict := WithSpace(context.Background(), &space.Session{Store: store, Explicit: &other})
90
+ if err := client.SendRequest(conflict, config.GetThreadPath, space.References{ThreadID: "thread", RunID: "run"}, &result); err == nil {
91
+ t.Fatal("conflict sent")
92
+ }
93
+ if len(headers) != 5 {
94
+ t.Fatal("conflict reached API")
95
+ }
96
+ // A new API path still receives the selected space without a client allowlist.
97
+ unlisted := WithSpace(context.Background(), &space.Session{Store: store})
98
+ if err := client.SendRequest(unlisted, "/api/unlisted", struct{}{}, &result); err != nil {
99
+ t.Fatal(err)
100
+ }
101
+ if headers[len(headers)-1] != "34" {
102
+ t.Fatal("unlisted API did not receive the saved team")
103
+ }
104
+ foreign := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
105
+ if r.Header.Get("X-Team-Id") != "" || r.Header.Get("Authorization") != "" {
106
+ t.Error("foreign origin received sensitive headers")
107
+ }
108
+ _, _ = w.Write([]byte(`{}`))
109
+ }))
110
+ defer foreign.Close()
111
+ // A matching path on another origin must bypass both scope lookup and headers.
112
+ if err := client.SendRequestWithHeaders(conflict, foreign.URL+config.GetThreadPath, space.References{ThreadID: "thread", RunID: "run"}, map[string]string{"X-Team-Id": "999"}, &result); err != nil {
113
+ t.Fatal(err)
114
+ }
115
+ // Path rewriting by an adapter must preserve the original operation's scope.
116
+ rewritten := NewHTTPClientWithAdapter(api.URL, time.Second, NewAccessKeyAuthorizer("test-key"), spaceRewriteAdapter{})
117
+ if err := rewritten.SendRequest(ctx, config.GetCreditBalancePath, struct{}{}, &result); err != nil {
118
+ t.Fatal(err)
119
+ }
120
+ if headers[len(headers)-1] != "12" {
121
+ t.Fatal("adapter dropped space")
122
+ }
123
+ // Even with a frozen space, a changed account must stop before sending.
124
+ account = "other-account"
125
+ before := len(headers)
126
+ if err := client.SendRequest(ctx, config.GetCreditBalancePath, struct{}{}, &result); err == nil {
127
+ t.Fatal("account switch accepted")
128
+ }
129
+ if len(headers) != before {
130
+ t.Fatal("account switch reached API")
131
+ }
132
+ }
@@ -15,6 +15,7 @@ const AgentNameVideoPart = "pippit_video_part_agent"
15
15
 
16
16
  // SubmitRunResult is the shared successful submit_run output.
17
17
  type SubmitRunResult struct {
18
+ TeamID string `json:"team_id"`
18
19
  ThreadID string `json:"thread_id"`
19
20
  RunID string `json:"run_id"`
20
21
  WebThreadLink string `json:"web_thread_link"`
@@ -86,6 +87,7 @@ func submitRun(ctx context.Context, command, path string, body any, runner *Runn
86
87
  }
87
88
 
88
89
  return &SubmitRunResult{
90
+ TeamID: SpaceTeamID(ctx),
89
91
  ThreadID: resp.Data.Run.ThreadID,
90
92
  RunID: resp.Data.Run.RunID,
91
93
  WebThreadLink: resp.Data.WebThreadLink,
@@ -20,6 +20,7 @@ type UploadFileOptions struct {
20
20
 
21
21
  // UploadFileResult is the shared JSON output for media and short-drama uploads.
22
22
  type UploadFileResult struct {
23
+ TeamID string `json:"team_id"`
23
24
  AssetID string `json:"asset_id"`
24
25
  }
25
26
 
@@ -102,6 +103,7 @@ func UploadFile(ctx context.Context, opts UploadFileOptions, runner *Runner) (*U
102
103
  }
103
104
 
104
105
  return &UploadFileResult{
106
+ TeamID: SpaceTeamID(ctx),
105
107
  AssetID: assetID,
106
108
  }, nil
107
109
  }
@@ -12,18 +12,21 @@ import (
12
12
  )
13
13
 
14
14
  const (
15
- DefaultBaseURL = "https://xyq.jianying.com"
16
- DefaultHTTPTimeout = 30 * time.Minute
17
- DefaultAuthTTL = 30 * time.Second
18
- DefaultAuthStoreServiceName = "pippit-cli"
19
- SubmitRunPath = "/api/biz/v1/skill/submit_run"
20
- GetCreditBalancePath = "/api/biz/v1/skill/get_credit_balance"
21
- GetAvailableModelListPath = "/api/biz/v1/skill/get_available_model_list"
22
- GetThreadPath = "/api/biz/v1/skill/get_thread"
23
- UploadFilePath = "/api/biz/v1/skill/upload_file"
24
- ListThreadFilePath = "/api/biz/v1/skill/list_thread_file"
25
- EnvXYQAccessKey = "XYQ_ACCESS_KEY"
26
- FileName = "config.toml"
15
+ DefaultBaseURL = "https://xyq.jianying.com"
16
+ DefaultHTTPTimeout = 30 * time.Minute
17
+ DefaultAuthTTL = 30 * time.Second
18
+ DefaultAuthStoreServiceName = "pippit-cli"
19
+ SubmitRunPath = "/api/biz/v1/skill/submit_run"
20
+ SubmitMarketingRunPath = "/api/biz/v1/agent/submit_marketing_run"
21
+ QueryGenerateVideoResultPath = "/api/biz/v1/agent/query_generate_video_result"
22
+ GetCreditBalancePath = "/api/biz/v1/skill/get_credit_balance"
23
+ GetLiteTeamListPath = "/api/biz/v1/skill/get_lite_team_list"
24
+ GetAvailableModelListPath = "/api/biz/v1/skill/get_available_model_list"
25
+ GetThreadPath = "/api/biz/v1/skill/get_thread"
26
+ UploadFilePath = "/api/biz/v1/skill/upload_file"
27
+ ListThreadFilePath = "/api/biz/v1/skill/list_thread_file"
28
+ EnvXYQAccessKey = "XYQ_ACCESS_KEY"
29
+ FileName = "config.toml"
27
30
  )
28
31
 
29
32
  // Config holds runtime settings selected by the root command and passed down