@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,152 @@
1
+ package auth
2
+
3
+ import (
4
+ "bytes"
5
+ "encoding/json"
6
+ "net/http"
7
+ "net/http/httptest"
8
+ "net/url"
9
+ "strings"
10
+ "testing"
11
+ "time"
12
+
13
+ "github.com/Pippit-dev/pippit-cli/internal/config"
14
+ "github.com/Pippit-dev/pippit-cli/internal/version"
15
+ )
16
+
17
+ func newOriginTestFlow(t *testing.T) (*browserFlow, accessKeyPayload) {
18
+ t.Helper()
19
+ authURL, _ := url.Parse(config.DefaultBaseURL)
20
+ deviceID, _ := randomEncoded(bytes.NewReader(bytes.Repeat([]byte{0x21}, deviceIDBytes)), deviceIDBytes)
21
+ flow, err := startBrowserFlow(authURL, bytes.NewReader(bytes.Repeat([]byte{0x42}, 2*randomBindingBytes)), deviceID, "", "", false)
22
+ if err != nil {
23
+ t.Fatal(err)
24
+ }
25
+ t.Cleanup(flow.close)
26
+ return flow, accessKeyPayload{
27
+ Type: "access_key", AccessKey: "local-test-ak", UID: "test-user", TokenID: "test-token",
28
+ ExpiredAt: time.Now().Add(time.Hour).Unix(), RandomSecretKey: flow.secret,
29
+ Source: loginSource, CallbackURL: flow.callbackURL,
30
+ }
31
+ }
32
+
33
+ func originTestRequest(t *testing.T, flow *browserFlow, method, origin string, payload accessKeyPayload, mutations ...func(*http.Request)) *httptest.ResponseRecorder {
34
+ t.Helper()
35
+ body, err := json.Marshal(payload)
36
+ if err != nil {
37
+ t.Fatal(err)
38
+ }
39
+ request := httptest.NewRequest(method, flow.callbackURL, bytes.NewReader(body))
40
+ if origin != "" {
41
+ request.Header.Set("Origin", origin)
42
+ }
43
+ request.Header.Set("Content-Type", "application/json")
44
+ request.Header.Set("Access-Control-Request-Method", "POST")
45
+ request.Header.Set("Access-Control-Request-Headers", "Content-Type")
46
+ for _, mutate := range mutations {
47
+ mutate(request)
48
+ }
49
+ response := httptest.NewRecorder()
50
+ flow.handleCallback(response, request)
51
+ return response
52
+ }
53
+
54
+ func TestBrowserCallbackAcceptsBothOfficialOriginsWithMatchingCORS(t *testing.T) {
55
+ for _, origin := range []string{"https://xyq.jianying.com", "https://www.xiaoyunque.com"} {
56
+ t.Run(origin, func(t *testing.T) {
57
+ flow, payload := newOriginTestFlow(t)
58
+ for _, method := range []string{http.MethodOptions, http.MethodPost} {
59
+ response := originTestRequest(t, flow, method, origin, payload)
60
+ wantStatus := http.StatusOK
61
+ if method == http.MethodOptions {
62
+ wantStatus = http.StatusNoContent
63
+ }
64
+ if response.Code != wantStatus || response.Header().Get("Access-Control-Allow-Origin") != origin {
65
+ t.Fatalf("%s status/CORS = %d/%q", method, response.Code, response.Header().Get("Access-Control-Allow-Origin"))
66
+ }
67
+ if response.Header().Get("Access-Control-Allow-Private-Network") != "true" ||
68
+ !strings.Contains(strings.Join(response.Header().Values("Vary"), ","), "Origin") {
69
+ t.Fatal("callback lost private-network or Vary headers")
70
+ }
71
+ }
72
+ // Switching between the two trusted sites must not reset the one-shot binding.
73
+ otherOrigin := "https://xyq.jianying.com"
74
+ if origin == otherOrigin {
75
+ otherOrigin = "https://www.xiaoyunque.com"
76
+ }
77
+ if response := originTestRequest(t, flow, http.MethodPost, otherOrigin, payload); response.Code != http.StatusConflict {
78
+ t.Fatalf("cross-origin replay status = %d", response.Code)
79
+ }
80
+ })
81
+ }
82
+ }
83
+
84
+ func TestBrowserCallbackRejectsUntrustedOriginsBeforeCORS(t *testing.T) {
85
+ for _, origin := range []string{
86
+ "", "null", "http://xyq.jianying.com", "http://www.xiaoyunque.com",
87
+ "https://www.xiaoyunque.com:443", "https://www.xiaoyunque.com/",
88
+ "https://www.xiaoyunque.com.evil.example", "https://evil.example",
89
+ "https://other.xiaoyunque.com", "https://www.xiaoyunque.com https://xyq.jianying.com",
90
+ } {
91
+ t.Run(origin, func(t *testing.T) {
92
+ flow, payload := newOriginTestFlow(t)
93
+ for _, method := range []string{http.MethodOptions, http.MethodPost} {
94
+ response := originTestRequest(t, flow, method, origin, payload)
95
+ if response.Code != http.StatusForbidden || response.Header().Get("Access-Control-Allow-Origin") != "" {
96
+ t.Fatalf("%s untrusted origin status/CORS = %d/%q", method, response.Code, response.Header().Get("Access-Control-Allow-Origin"))
97
+ }
98
+ }
99
+ if len(flow.payload) != 0 {
100
+ t.Fatal("untrusted origin delivered a credential")
101
+ }
102
+ })
103
+ }
104
+ }
105
+
106
+ func TestNewAuthOriginStillRequiresEveryCallbackBinding(t *testing.T) {
107
+ for _, binding := range []string{"secret", "source", "callback_url", "state", "host"} {
108
+ t.Run(binding, func(t *testing.T) {
109
+ flow, payload := newOriginTestFlow(t)
110
+ switch binding {
111
+ case "secret":
112
+ payload.RandomSecretKey = "wrong"
113
+ case "source":
114
+ payload.Source = "wrong"
115
+ case "callback_url":
116
+ payload.CallbackURL += "&extra=1"
117
+ case "state":
118
+ flow.state = "wrong"
119
+ }
120
+ response := originTestRequest(t, flow, http.MethodPost, "https://www.xiaoyunque.com", payload, func(request *http.Request) {
121
+ if binding == "host" {
122
+ request.Host = "evil.example"
123
+ }
124
+ })
125
+ if response.Code != http.StatusBadRequest || len(flow.payload) != 0 {
126
+ t.Fatalf("invalid %s status = %d, queued callbacks = %d", binding, response.Code, len(flow.payload))
127
+ }
128
+ })
129
+ }
130
+ }
131
+
132
+ func TestBrowserLoginMetadataUsesBinaryVersionAndProtocol(t *testing.T) {
133
+ previous := version.Version
134
+ t.Cleanup(func() { version.Version = previous })
135
+ for _, buildVersion := range []string{"1.0.42", "1.0.42-canary.1", "dev", ""} {
136
+ t.Run(buildVersion, func(t *testing.T) {
137
+ version.Version = buildVersion
138
+ flow, _ := newOriginTestFlow(t)
139
+ loginURL, err := url.Parse(flow.loginURL)
140
+ if err != nil {
141
+ t.Fatal(err)
142
+ }
143
+ wantVersion := buildVersion
144
+ if wantVersion == "" {
145
+ wantVersion = "dev"
146
+ }
147
+ if query := loginURL.Query(); query.Get("cli_version") != wantVersion || query.Get("auth_protocol") != "1" {
148
+ t.Fatalf("auth metadata = version %q, protocol %q", query.Get("cli_version"), query.Get("auth_protocol"))
149
+ }
150
+ })
151
+ }
152
+ }
@@ -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
+ }