@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.
- package/README.md +42 -11
- package/checksums.txt +6 -6
- package/cmd/auth/auth.go +19 -5
- package/cmd/auth/auth_test.go +23 -0
- package/cmd/canvas/canvas.go +7 -7
- package/cmd/download_result.go +3 -2
- package/cmd/marketing.go +19 -3
- package/cmd/marketing_test.go +11 -5
- package/cmd/model.go +5 -4
- package/cmd/root.go +17 -5
- package/cmd/root_update_check_test.go +139 -0
- package/cmd/short_drama_test.go +2 -0
- package/cmd/source_test.go +27 -4
- package/cmd/space.go +199 -0
- package/cmd/space_test.go +469 -0
- package/cmd/upload_file_test.go +3 -1
- package/dist/checksums.txt +6 -6
- package/internal/adaptercli/commands_test.go +9 -5
- package/internal/auth/loopback.go +16 -11
- package/internal/auth/loopback_origins_test.go +152 -0
- package/internal/auth/space.go +26 -0
- package/internal/auth/space_test.go +42 -0
- package/internal/canvas/get.go +9 -2
- package/internal/commandnames/names.go +9 -4
- package/internal/common/client.go +23 -0
- package/internal/common/host_source.go +5 -0
- package/internal/common/host_source_test.go +9 -1
- package/internal/common/runner.go +5 -3
- package/internal/common/space.go +185 -0
- package/internal/common/space_test.go +132 -0
- package/internal/common/submit_run.go +2 -0
- package/internal/common/upload_file.go +2 -0
- package/internal/config/config.go +15 -12
- package/internal/generate_image/generate_image.go +3 -9
- package/internal/short_drama/submit_run.go +3 -9
- package/internal/space/session.go +153 -0
- package/internal/space/store.go +211 -0
- package/internal/space/store_test.go +246 -0
- package/internal/version/version.go +13 -0
- package/internal/version/version_test.go +38 -0
- package/internal/versioncheck/check.go +147 -0
- package/internal/versioncheck/check_test.go +204 -0
- package/internal/versioncheck/semver.go +84 -0
- package/package.json +1 -1
- package/scripts/canvas-command.js +47 -5
- package/scripts/install-wizard.js +1 -1
- package/scripts/run.js +15 -4
- package/scripts/telemetry.js +3 -1
- package/scripts/version-check.js +8 -1
- package/skills/short-drama/SKILL.md +12 -1
- package/skills/xyq-marketing-skill/SKILL.md +22 -3
- package/skills/xyq-marketing-skill/references/api.md +7 -1
- package/skills/xyq-marketing-skill/scripts/marketing.js +51 -19
- package/skills/xyq-nest-skill/SKILL.md +15 -4
- package/skills/xyq-nest-skill/commands/canvas-assets.md +1 -1
- package/skills/xyq-nest-skill/commands/canvas.md +5 -3
- package/skills/xyq-nest-skill/commands/get-credit-balance.md +2 -2
- package/skills/xyq-nest-skill/commands/model.md +1 -1
- package/skills/xyq-nest-skill/scripts/ensure-cli.js +8 -1
- package/skills/xyq-nest-skill/scripts/install.md +1 -1
- 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
|
+
}
|
package/internal/canvas/get.go
CHANGED
|
@@ -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
|
-
|
|
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
|
-
}
|
|
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
|
|
6
|
-
Logout
|
|
7
|
-
Status
|
|
8
|
-
|
|
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
|
-
{"
|
|
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
|
|
24
|
-
Client
|
|
25
|
-
Auth
|
|
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
|
+
}
|