@pippit-dev/cli 1.0.17 → 1.0.20
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 +41 -2
- package/checksums.txt +6 -6
- package/cmd/auth/auth.go +136 -142
- package/cmd/auth/auth_test.go +134 -0
- package/cmd/canvas/canvas.go +241 -0
- package/cmd/canvas/canvas_test.go +182 -0
- package/cmd/get_credit_balance.go +37 -0
- package/cmd/get_credit_balance_test.go +73 -0
- package/cmd/root.go +31 -5
- package/cmd/root_test.go +69 -0
- package/cmd/short_drama_test.go +5 -7
- package/cmd/update/update.go +34 -5
- package/cmd/update/update_test.go +84 -0
- package/dist/checksums.txt +6 -0
- package/dist/xyq-canvas-command-runtime.cjs +16 -0
- package/dist/xyq-canvas-command-runtime.cjs.LEGAL.txt +599 -0
- package/dist/xyq-canvas-command-runtime.cjs.sha256 +2 -0
- package/internal/auth/auth_test.go +813 -0
- package/internal/auth/browser_darwin.go +14 -0
- package/internal/auth/browser_env.go +35 -0
- package/internal/auth/browser_linux.go +14 -0
- package/internal/auth/browser_windows.go +14 -0
- package/internal/auth/identity.go +89 -0
- package/internal/auth/loopback.go +324 -0
- package/internal/auth/manager.go +338 -144
- package/internal/auth/store.go +303 -0
- package/internal/auth/store_file_unix.go +176 -0
- package/internal/auth/store_file_windows.go +11 -0
- package/internal/auth/types.go +72 -0
- package/internal/canvas/allocate.go +70 -0
- package/internal/canvas/apply.go +279 -0
- package/internal/canvas/canvas_test.go +559 -0
- package/internal/canvas/create.go +380 -0
- package/internal/canvas/get.go +156 -0
- package/internal/canvas/types.go +68 -0
- package/internal/canvas/upload.go +250 -0
- package/internal/common/access_key.go +42 -6
- package/internal/common/access_key_test.go +113 -0
- package/internal/common/client.go +119 -21
- package/internal/common/client_test.go +213 -0
- package/internal/common/get_credit_balance.go +61 -0
- package/internal/common/get_credit_balance_test.go +75 -0
- package/internal/common/runner.go +15 -0
- package/internal/config/config.go +11 -28
- package/internal/config/config_test.go +3 -18
- package/package.json +9 -2
- package/scripts/canvas-command.js +881 -0
- package/scripts/run.js +21 -4
- package/skills/short-drama/SKILL.md +4 -4
- package/skills/xyq-nest-skill/SKILL.md +11 -1
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
package auth
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"errors"
|
|
7
|
+
"fmt"
|
|
8
|
+
"os"
|
|
9
|
+
"path/filepath"
|
|
10
|
+
"strings"
|
|
11
|
+
|
|
12
|
+
"github.com/zalando/go-keyring"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
const (
|
|
16
|
+
defaultKeyringAccount = "browser-login"
|
|
17
|
+
credentialFileName = "browser-credential.json"
|
|
18
|
+
maxCredentialBytes = 64 << 10
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
type keyringBackend interface {
|
|
22
|
+
Get(service, user string) (string, error)
|
|
23
|
+
Set(service, user, password string) error
|
|
24
|
+
Delete(service, user string) error
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type systemKeyring struct{}
|
|
28
|
+
|
|
29
|
+
func (systemKeyring) Get(service, user string) (string, error) {
|
|
30
|
+
return keyring.Get(service, user)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
func (systemKeyring) Set(service, user, password string) error {
|
|
34
|
+
return keyring.Set(service, user, password)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
func (systemKeyring) Delete(service, user string) error {
|
|
38
|
+
return keyring.Delete(service, user)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type keyringCredentialStore struct {
|
|
42
|
+
backend keyringBackend
|
|
43
|
+
service string
|
|
44
|
+
account string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
func (s *keyringCredentialStore) Load(ctx context.Context) (*Credential, error) {
|
|
48
|
+
if err := ctx.Err(); err != nil {
|
|
49
|
+
return nil, err
|
|
50
|
+
}
|
|
51
|
+
value, err := s.backend.Get(s.service, s.account)
|
|
52
|
+
if errors.Is(err, keyring.ErrNotFound) {
|
|
53
|
+
return nil, ErrCredentialNotFound
|
|
54
|
+
}
|
|
55
|
+
if err != nil {
|
|
56
|
+
return nil, fmt.Errorf("读取系统钥匙串失败: %w", ErrSecureStore)
|
|
57
|
+
}
|
|
58
|
+
credential, err := decodeCredential([]byte(value))
|
|
59
|
+
if err != nil {
|
|
60
|
+
return nil, err
|
|
61
|
+
}
|
|
62
|
+
return credential, nil
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
func (s *keyringCredentialStore) Save(ctx context.Context, credential *Credential) error {
|
|
66
|
+
if err := ctx.Err(); err != nil {
|
|
67
|
+
return err
|
|
68
|
+
}
|
|
69
|
+
payload, err := encodeCredential(credential)
|
|
70
|
+
if err != nil {
|
|
71
|
+
return err
|
|
72
|
+
}
|
|
73
|
+
if err := s.backend.Set(s.service, s.account, string(payload)); err != nil {
|
|
74
|
+
return fmt.Errorf("写入系统钥匙串失败: %w", ErrSecureStore)
|
|
75
|
+
}
|
|
76
|
+
return nil
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
func (s *keyringCredentialStore) Delete(ctx context.Context) error {
|
|
80
|
+
if err := ctx.Err(); err != nil {
|
|
81
|
+
return err
|
|
82
|
+
}
|
|
83
|
+
err := s.backend.Delete(s.service, s.account)
|
|
84
|
+
if errors.Is(err, keyring.ErrNotFound) {
|
|
85
|
+
return ErrCredentialNotFound
|
|
86
|
+
}
|
|
87
|
+
if err != nil {
|
|
88
|
+
return fmt.Errorf("删除系统钥匙串凭证失败: %w", ErrSecureStore)
|
|
89
|
+
}
|
|
90
|
+
return nil
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
type resilientCredentialStore struct {
|
|
94
|
+
primary CredentialStore
|
|
95
|
+
fallback CredentialStore
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
type storedCredential struct {
|
|
99
|
+
Version int `json:"version"`
|
|
100
|
+
DeviceID string `json:"device_id"`
|
|
101
|
+
CredentialScope string `json:"credential_scope"`
|
|
102
|
+
// LegacyTokenName is decoded only for compatibility with credentials written
|
|
103
|
+
// by the first browser-auth beta. New records intentionally omit it.
|
|
104
|
+
LegacyTokenName string `json:"token_name,omitempty"`
|
|
105
|
+
AccessKey string `json:"access_key,omitempty"`
|
|
106
|
+
TokenID string `json:"token_id,omitempty"`
|
|
107
|
+
UID string `json:"uid,omitempty"`
|
|
108
|
+
ExpiredAt int64 `json:"expired_at,omitempty"`
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// NewDefaultCredentialStore prefers the operating system keyring. Unix uses
|
|
112
|
+
// a private no-follow file as a fallback; Windows deliberately has no file
|
|
113
|
+
// fallback because an equivalent ACL guarantee is not provided here.
|
|
114
|
+
func NewDefaultCredentialStore(serviceName string) CredentialStore {
|
|
115
|
+
serviceName = strings.TrimSpace(serviceName)
|
|
116
|
+
if serviceName == "" {
|
|
117
|
+
serviceName = "pippit-cli"
|
|
118
|
+
}
|
|
119
|
+
primary := &keyringCredentialStore{
|
|
120
|
+
backend: systemKeyring{},
|
|
121
|
+
service: serviceName,
|
|
122
|
+
account: defaultKeyringAccount,
|
|
123
|
+
}
|
|
124
|
+
return &resilientCredentialStore{
|
|
125
|
+
primary: primary,
|
|
126
|
+
fallback: newPlatformFallbackStore(),
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
func (s *resilientCredentialStore) Load(ctx context.Context) (*Credential, error) {
|
|
131
|
+
credential, primaryErr := s.primary.Load(ctx)
|
|
132
|
+
if primaryErr == nil {
|
|
133
|
+
return credential, nil
|
|
134
|
+
}
|
|
135
|
+
if err := ctx.Err(); err != nil {
|
|
136
|
+
return nil, err
|
|
137
|
+
}
|
|
138
|
+
if !errors.Is(primaryErr, ErrCredentialNotFound) && !errors.Is(primaryErr, ErrSecureStore) {
|
|
139
|
+
// A decodable-but-invalid primary record may indicate corruption or
|
|
140
|
+
// tampering. Never mask it with an older fallback credential.
|
|
141
|
+
return nil, primaryErr
|
|
142
|
+
}
|
|
143
|
+
if s.fallback == nil {
|
|
144
|
+
if errors.Is(primaryErr, ErrCredentialNotFound) {
|
|
145
|
+
return nil, ErrCredentialNotFound
|
|
146
|
+
}
|
|
147
|
+
return nil, primaryErr
|
|
148
|
+
}
|
|
149
|
+
credential, fallbackErr := s.fallback.Load(ctx)
|
|
150
|
+
if fallbackErr == nil {
|
|
151
|
+
return credential, nil
|
|
152
|
+
}
|
|
153
|
+
if errors.Is(fallbackErr, ErrCredentialNotFound) {
|
|
154
|
+
// An available, empty fallback is the active store when the primary
|
|
155
|
+
// keyring is unavailable. Report a fresh login state so Manager can
|
|
156
|
+
// create the device identity and Save can persist it to that fallback.
|
|
157
|
+
return nil, ErrCredentialNotFound
|
|
158
|
+
}
|
|
159
|
+
if !errors.Is(fallbackErr, ErrCredentialNotFound) {
|
|
160
|
+
return nil, fallbackErr
|
|
161
|
+
}
|
|
162
|
+
return nil, primaryErr
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
func (s *resilientCredentialStore) Save(ctx context.Context, credential *Credential) error {
|
|
166
|
+
primaryErr := s.primary.Save(ctx, credential)
|
|
167
|
+
if primaryErr == nil {
|
|
168
|
+
if s.fallback != nil {
|
|
169
|
+
if err := ignoreNotFound(s.fallback.Delete(ctx)); err != nil {
|
|
170
|
+
return fmt.Errorf("系统钥匙串已更新,但清理旧的备用凭证失败: %w", err)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return nil
|
|
174
|
+
}
|
|
175
|
+
if err := ctx.Err(); err != nil {
|
|
176
|
+
return err
|
|
177
|
+
}
|
|
178
|
+
if !errors.Is(primaryErr, ErrCredentialNotFound) && !errors.Is(primaryErr, ErrSecureStore) {
|
|
179
|
+
return primaryErr
|
|
180
|
+
}
|
|
181
|
+
if s.fallback == nil {
|
|
182
|
+
return primaryErr
|
|
183
|
+
}
|
|
184
|
+
if err := s.fallback.Save(ctx, credential); err != nil {
|
|
185
|
+
return err
|
|
186
|
+
}
|
|
187
|
+
return nil
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
func (s *resilientCredentialStore) Delete(ctx context.Context) error {
|
|
191
|
+
primaryErr := ignoreNotFound(s.primary.Delete(ctx))
|
|
192
|
+
var fallbackErr error
|
|
193
|
+
if s.fallback != nil {
|
|
194
|
+
fallbackErr = ignoreNotFound(s.fallback.Delete(ctx))
|
|
195
|
+
}
|
|
196
|
+
if primaryErr != nil {
|
|
197
|
+
return primaryErr
|
|
198
|
+
}
|
|
199
|
+
return fallbackErr
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
func ignoreNotFound(err error) error {
|
|
203
|
+
if errors.Is(err, ErrCredentialNotFound) {
|
|
204
|
+
return nil
|
|
205
|
+
}
|
|
206
|
+
return err
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
func encodeCredential(credential *Credential) ([]byte, error) {
|
|
210
|
+
if err := validateCredential(credential); err != nil {
|
|
211
|
+
return nil, err
|
|
212
|
+
}
|
|
213
|
+
payload, err := json.Marshal(storedCredential{
|
|
214
|
+
Version: credential.Version,
|
|
215
|
+
DeviceID: credential.DeviceID,
|
|
216
|
+
CredentialScope: credential.CredentialScope,
|
|
217
|
+
AccessKey: credential.AccessKey,
|
|
218
|
+
TokenID: credential.TokenID,
|
|
219
|
+
UID: credential.UID,
|
|
220
|
+
ExpiredAt: credential.ExpiredAt,
|
|
221
|
+
})
|
|
222
|
+
if err != nil {
|
|
223
|
+
return nil, errors.New("编码本机登录凭证失败")
|
|
224
|
+
}
|
|
225
|
+
return payload, nil
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
func decodeCredential(payload []byte) (*Credential, error) {
|
|
229
|
+
if len(payload) == 0 || len(payload) > maxCredentialBytes {
|
|
230
|
+
return nil, errors.New("本机登录凭证格式无效")
|
|
231
|
+
}
|
|
232
|
+
record := &storedCredential{}
|
|
233
|
+
if err := json.Unmarshal(payload, record); err != nil {
|
|
234
|
+
return nil, errors.New("本机登录凭证格式无效")
|
|
235
|
+
}
|
|
236
|
+
credential := &Credential{
|
|
237
|
+
Version: record.Version,
|
|
238
|
+
DeviceID: record.DeviceID,
|
|
239
|
+
CredentialScope: record.CredentialScope,
|
|
240
|
+
AccessKey: record.AccessKey,
|
|
241
|
+
TokenID: record.TokenID,
|
|
242
|
+
UID: record.UID,
|
|
243
|
+
ExpiredAt: record.ExpiredAt,
|
|
244
|
+
}
|
|
245
|
+
if err := validateCredential(credential); err != nil {
|
|
246
|
+
return nil, err
|
|
247
|
+
}
|
|
248
|
+
return credential, nil
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
func defaultCredentialPath() string {
|
|
252
|
+
dir, err := os.UserConfigDir()
|
|
253
|
+
if err != nil || strings.TrimSpace(dir) == "" {
|
|
254
|
+
return ""
|
|
255
|
+
}
|
|
256
|
+
return filepath.Join(dir, "pippit-cli", credentialFileName)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
type unavailableCredentialStore struct{}
|
|
260
|
+
|
|
261
|
+
func (unavailableCredentialStore) Load(context.Context) (*Credential, error) {
|
|
262
|
+
return nil, ErrSecureStore
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
func (unavailableCredentialStore) Save(context.Context, *Credential) error {
|
|
266
|
+
return ErrSecureStore
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
func (unavailableCredentialStore) Delete(context.Context) error {
|
|
270
|
+
return ErrSecureStore
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
func validateCredential(credential *Credential) error {
|
|
274
|
+
if credential == nil {
|
|
275
|
+
return errors.New("本机登录凭证不能为空")
|
|
276
|
+
}
|
|
277
|
+
if credential.Version != credentialVersion {
|
|
278
|
+
return errors.New("本机登录凭证版本不受支持")
|
|
279
|
+
}
|
|
280
|
+
if !validDeviceID(credential.DeviceID) {
|
|
281
|
+
return errors.New("本机登录设备标识无效")
|
|
282
|
+
}
|
|
283
|
+
if credential.AccessKey == "" {
|
|
284
|
+
// TokenID is non-secret. Keeping it lets an explicit force login select
|
|
285
|
+
// exactly this device token without guessing by display name.
|
|
286
|
+
if (credential.TokenID != "" && !validTokenID(credential.TokenID)) || credential.UID != "" ||
|
|
287
|
+
credential.ExpiredAt != 0 || credential.CredentialScope != "" {
|
|
288
|
+
return errors.New("本机登录凭证不完整")
|
|
289
|
+
}
|
|
290
|
+
return nil
|
|
291
|
+
}
|
|
292
|
+
if strings.TrimSpace(credential.AccessKey) != credential.AccessKey || len(credential.AccessKey) > 4096 ||
|
|
293
|
+
!validTokenID(credential.TokenID) ||
|
|
294
|
+
credential.UID == "" || len(credential.UID) > 256 || strings.TrimSpace(credential.UID) != credential.UID || credential.ExpiredAt <= 0 {
|
|
295
|
+
return errors.New("本机登录凭证不完整")
|
|
296
|
+
}
|
|
297
|
+
expectedScope := credentialScope(credential.UID, credential.DeviceID)
|
|
298
|
+
if !constantTimeEqual(credential.CredentialScope, expectedScope) &&
|
|
299
|
+
!constantTimeEqual(credential.CredentialScope, legacyCredentialScope(credential.DeviceID)) {
|
|
300
|
+
return errors.New("本机登录凭证作用域无效")
|
|
301
|
+
}
|
|
302
|
+
return nil
|
|
303
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
//go:build !windows
|
|
2
|
+
|
|
3
|
+
package auth
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"context"
|
|
7
|
+
"errors"
|
|
8
|
+
"fmt"
|
|
9
|
+
"io"
|
|
10
|
+
"os"
|
|
11
|
+
"path/filepath"
|
|
12
|
+
"strings"
|
|
13
|
+
|
|
14
|
+
"golang.org/x/sys/unix"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
type fileCredentialStore struct {
|
|
18
|
+
path string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
func NewFileCredentialStore(path string) CredentialStore {
|
|
22
|
+
return &fileCredentialStore{path: filepath.Clean(path)}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
func newPlatformFallbackStore() CredentialStore {
|
|
26
|
+
path := defaultCredentialPath()
|
|
27
|
+
if path == "" {
|
|
28
|
+
return nil
|
|
29
|
+
}
|
|
30
|
+
return NewFileCredentialStore(path)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
func (s *fileCredentialStore) Load(ctx context.Context) (*Credential, error) {
|
|
34
|
+
if err := ctx.Err(); err != nil {
|
|
35
|
+
return nil, err
|
|
36
|
+
}
|
|
37
|
+
dirFD, name, err := s.openPrivateDirectory(false)
|
|
38
|
+
if errors.Is(err, os.ErrNotExist) {
|
|
39
|
+
return nil, ErrCredentialNotFound
|
|
40
|
+
}
|
|
41
|
+
if err != nil {
|
|
42
|
+
return nil, err
|
|
43
|
+
}
|
|
44
|
+
defer unix.Close(dirFD)
|
|
45
|
+
|
|
46
|
+
fd, err := unix.Openat(dirFD, name, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0)
|
|
47
|
+
if errors.Is(err, unix.ENOENT) {
|
|
48
|
+
return nil, ErrCredentialNotFound
|
|
49
|
+
}
|
|
50
|
+
if err != nil {
|
|
51
|
+
return nil, fmt.Errorf("安全打开本机登录凭证失败: %w", ErrSecureStore)
|
|
52
|
+
}
|
|
53
|
+
file := os.NewFile(uintptr(fd), name)
|
|
54
|
+
defer file.Close()
|
|
55
|
+
if err := verifyPrivateRegularFile(fd); err != nil {
|
|
56
|
+
return nil, err
|
|
57
|
+
}
|
|
58
|
+
payload, err := io.ReadAll(io.LimitReader(file, maxCredentialBytes+1))
|
|
59
|
+
if err != nil {
|
|
60
|
+
return nil, fmt.Errorf("读取本机登录凭证失败: %w", ErrSecureStore)
|
|
61
|
+
}
|
|
62
|
+
return decodeCredential(payload)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
func (s *fileCredentialStore) Save(ctx context.Context, credential *Credential) error {
|
|
66
|
+
if err := ctx.Err(); err != nil {
|
|
67
|
+
return err
|
|
68
|
+
}
|
|
69
|
+
payload, err := encodeCredential(credential)
|
|
70
|
+
if err != nil {
|
|
71
|
+
return err
|
|
72
|
+
}
|
|
73
|
+
dirFD, name, err := s.openPrivateDirectory(true)
|
|
74
|
+
if err != nil {
|
|
75
|
+
return err
|
|
76
|
+
}
|
|
77
|
+
defer unix.Close(dirFD)
|
|
78
|
+
|
|
79
|
+
tempName, err := randomTempName()
|
|
80
|
+
if err != nil {
|
|
81
|
+
return errors.New("创建本机登录凭证临时文件失败")
|
|
82
|
+
}
|
|
83
|
+
fd, err := unix.Openat(dirFD, tempName, unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0o600)
|
|
84
|
+
if err != nil {
|
|
85
|
+
return fmt.Errorf("创建本机登录凭证临时文件失败: %w", ErrSecureStore)
|
|
86
|
+
}
|
|
87
|
+
cleanup := true
|
|
88
|
+
defer func() {
|
|
89
|
+
if cleanup {
|
|
90
|
+
_ = unix.Unlinkat(dirFD, tempName, 0)
|
|
91
|
+
}
|
|
92
|
+
}()
|
|
93
|
+
file := os.NewFile(uintptr(fd), tempName)
|
|
94
|
+
if _, err := file.Write(payload); err != nil {
|
|
95
|
+
_ = file.Close()
|
|
96
|
+
return fmt.Errorf("写入本机登录凭证失败: %w", ErrSecureStore)
|
|
97
|
+
}
|
|
98
|
+
if err := file.Sync(); err != nil {
|
|
99
|
+
_ = file.Close()
|
|
100
|
+
return fmt.Errorf("同步本机登录凭证失败: %w", ErrSecureStore)
|
|
101
|
+
}
|
|
102
|
+
if err := file.Close(); err != nil {
|
|
103
|
+
return fmt.Errorf("关闭本机登录凭证失败: %w", ErrSecureStore)
|
|
104
|
+
}
|
|
105
|
+
if err := unix.Renameat(dirFD, tempName, dirFD, name); err != nil {
|
|
106
|
+
return fmt.Errorf("原子保存本机登录凭证失败: %w", ErrSecureStore)
|
|
107
|
+
}
|
|
108
|
+
cleanup = false
|
|
109
|
+
if err := unix.Fsync(dirFD); err != nil {
|
|
110
|
+
return fmt.Errorf("同步本机凭证目录失败: %w", ErrSecureStore)
|
|
111
|
+
}
|
|
112
|
+
return nil
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
func (s *fileCredentialStore) Delete(ctx context.Context) error {
|
|
116
|
+
if err := ctx.Err(); err != nil {
|
|
117
|
+
return err
|
|
118
|
+
}
|
|
119
|
+
dirFD, name, err := s.openPrivateDirectory(false)
|
|
120
|
+
if errors.Is(err, os.ErrNotExist) {
|
|
121
|
+
return ErrCredentialNotFound
|
|
122
|
+
}
|
|
123
|
+
if err != nil {
|
|
124
|
+
return err
|
|
125
|
+
}
|
|
126
|
+
defer unix.Close(dirFD)
|
|
127
|
+
if err := unix.Unlinkat(dirFD, name, 0); errors.Is(err, unix.ENOENT) {
|
|
128
|
+
return ErrCredentialNotFound
|
|
129
|
+
} else if err != nil {
|
|
130
|
+
return fmt.Errorf("删除本机登录凭证失败: %w", ErrSecureStore)
|
|
131
|
+
}
|
|
132
|
+
if err := unix.Fsync(dirFD); err != nil {
|
|
133
|
+
return fmt.Errorf("同步本机凭证目录失败: %w", ErrSecureStore)
|
|
134
|
+
}
|
|
135
|
+
return nil
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
func (s *fileCredentialStore) openPrivateDirectory(create bool) (int, string, error) {
|
|
139
|
+
if s == nil || strings.TrimSpace(s.path) == "" || filepath.Base(s.path) == "." {
|
|
140
|
+
return -1, "", ErrSecureStore
|
|
141
|
+
}
|
|
142
|
+
dir := filepath.Dir(s.path)
|
|
143
|
+
if create {
|
|
144
|
+
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
145
|
+
return -1, "", fmt.Errorf("创建本机凭证目录失败: %w", ErrSecureStore)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
info, err := os.Lstat(dir)
|
|
149
|
+
if err != nil {
|
|
150
|
+
return -1, "", err
|
|
151
|
+
}
|
|
152
|
+
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() || info.Mode().Perm()&0o077 != 0 {
|
|
153
|
+
return -1, "", fmt.Errorf("本机凭证目录必须是权限 0700 的真实目录: %w", ErrSecureStore)
|
|
154
|
+
}
|
|
155
|
+
dirFD, err := unix.Open(dir, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_DIRECTORY|unix.O_NOFOLLOW, 0)
|
|
156
|
+
if err != nil {
|
|
157
|
+
return -1, "", fmt.Errorf("安全打开本机凭证目录失败: %w", ErrSecureStore)
|
|
158
|
+
}
|
|
159
|
+
var stat unix.Stat_t
|
|
160
|
+
if err := unix.Fstat(dirFD, &stat); err != nil || stat.Uid != uint32(os.Geteuid()) {
|
|
161
|
+
unix.Close(dirFD)
|
|
162
|
+
return -1, "", fmt.Errorf("本机凭证目录所有者无效: %w", ErrSecureStore)
|
|
163
|
+
}
|
|
164
|
+
return dirFD, filepath.Base(s.path), nil
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
func verifyPrivateRegularFile(fd int) error {
|
|
168
|
+
var stat unix.Stat_t
|
|
169
|
+
if err := unix.Fstat(fd, &stat); err != nil {
|
|
170
|
+
return fmt.Errorf("检查本机登录凭证失败: %w", ErrSecureStore)
|
|
171
|
+
}
|
|
172
|
+
if stat.Uid != uint32(os.Geteuid()) || stat.Mode&unix.S_IFMT != unix.S_IFREG || stat.Mode&0o077 != 0 {
|
|
173
|
+
return fmt.Errorf("本机登录凭证必须是当前用户拥有的权限 0600 文件: %w", ErrSecureStore)
|
|
174
|
+
}
|
|
175
|
+
return nil
|
|
176
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
package auth
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"io"
|
|
7
|
+
"time"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
const (
|
|
11
|
+
loginPagePath = "/cli/pippit-tool-login"
|
|
12
|
+
callbackPath = "/pippit-tool/callback"
|
|
13
|
+
loginSource = "pippit-tool-cli"
|
|
14
|
+
credentialVersion = 1
|
|
15
|
+
deviceIDBytes = 32
|
|
16
|
+
randomBindingBytes = 32
|
|
17
|
+
|
|
18
|
+
DefaultLoginTimeout = 5 * time.Minute
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
var (
|
|
22
|
+
ErrCredentialNotFound = errors.New("未找到本机小云雀 CLI 登录凭证")
|
|
23
|
+
ErrCredentialExpired = errors.New("本机小云雀 CLI 登录凭证已过期")
|
|
24
|
+
ErrSecureStore = errors.New("安全凭证存储不可用")
|
|
25
|
+
ErrCredentialAccountMismatch = errors.New("网页授权账号与当前任务账号不一致")
|
|
26
|
+
ErrRemoteRevokeUnsupported = errors.New("当前版本不支持在 CLI 中安全撤销远程 Access Key")
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
// Credential is the dedicated, device-scoped Access Key managed by this CLI.
|
|
30
|
+
// AccessKey is secret and must never be printed, logged, or written to journals.
|
|
31
|
+
type Credential struct {
|
|
32
|
+
Version int `json:"version"`
|
|
33
|
+
DeviceID string `json:"device_id"`
|
|
34
|
+
CredentialScope string `json:"credential_scope"`
|
|
35
|
+
AccessKey string `json:"-"`
|
|
36
|
+
TokenID string `json:"token_id,omitempty"`
|
|
37
|
+
UID string `json:"uid,omitempty"`
|
|
38
|
+
ExpiredAt int64 `json:"expired_at,omitempty"`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// CredentialStore persists a credential without exposing its serialized form.
|
|
42
|
+
type CredentialStore interface {
|
|
43
|
+
Load(context.Context) (*Credential, error)
|
|
44
|
+
Save(context.Context, *Credential) error
|
|
45
|
+
Delete(context.Context) error
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
type LoginOptions struct {
|
|
49
|
+
// OpenURL should open the URL without persisting it. Login writes the
|
|
50
|
+
// one-time URL to Progress so the user can continue manually when automatic
|
|
51
|
+
// opening is unavailable. When nil, the platform's standard browser opener
|
|
52
|
+
// is used with credential-bearing env vars removed.
|
|
53
|
+
OpenURL func(string) error
|
|
54
|
+
Progress io.Writer
|
|
55
|
+
Timeout time.Duration
|
|
56
|
+
// ForceRefresh asks the browser page to rotate this device's rejected AK.
|
|
57
|
+
// The CLI never calls QueryAk, DeleteAk, or GenerateAk itself.
|
|
58
|
+
ForceRefresh bool
|
|
59
|
+
// ExpectedCredentialScope binds reauthentication to the UID and device that
|
|
60
|
+
// started a durable operation. A different browser account fails before the
|
|
61
|
+
// returned Access Key is saved.
|
|
62
|
+
ExpectedCredentialScope string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
type Status struct {
|
|
66
|
+
LoggedIn bool `json:"logged_in"`
|
|
67
|
+
Source string `json:"source,omitempty"`
|
|
68
|
+
UID string `json:"uid,omitempty"`
|
|
69
|
+
TokenID string `json:"token_id,omitempty"`
|
|
70
|
+
CredentialScope string `json:"credential_scope,omitempty"`
|
|
71
|
+
ExpiresAt time.Time `json:"expires_at,omitempty"`
|
|
72
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
package canvas
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"fmt"
|
|
7
|
+
"strings"
|
|
8
|
+
|
|
9
|
+
"github.com/Pippit-dev/pippit-cli/internal/common"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
const MaxAllocateCount = 5000
|
|
13
|
+
|
|
14
|
+
type AllocateResult struct {
|
|
15
|
+
AssetIDs []string `json:"asset_ids"`
|
|
16
|
+
LogID string `json:"log_id,omitempty"`
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type allocateRequest struct {
|
|
20
|
+
Count int `json:"count"`
|
|
21
|
+
Base map[string]any `json:"Base"`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Allocate reserves unique IDs for assets that a later canvas apply transaction creates.
|
|
25
|
+
func Allocate(ctx context.Context, count int, runner *common.Runner) (*AllocateResult, error) {
|
|
26
|
+
client, err := runnerClient(runner, "canvas allocate")
|
|
27
|
+
if err != nil {
|
|
28
|
+
return nil, err
|
|
29
|
+
}
|
|
30
|
+
if count <= 0 || count > MaxAllocateCount {
|
|
31
|
+
return nil, fmt.Errorf("canvas allocate count must be between 1 and %d", MaxAllocateCount)
|
|
32
|
+
}
|
|
33
|
+
var envelope responseEnvelope
|
|
34
|
+
if err := client.SendRequest(ctx, AllocatePath, allocateRequest{Count: count, Base: base()}, &envelope); err != nil {
|
|
35
|
+
return nil, fmt.Errorf("canvas allocate request failed: %w", err)
|
|
36
|
+
}
|
|
37
|
+
if err := envelope.validate("canvas allocate"); err != nil {
|
|
38
|
+
return nil, err
|
|
39
|
+
}
|
|
40
|
+
var data map[string]json.RawMessage
|
|
41
|
+
if err := json.Unmarshal(envelope.Data, &data); err != nil {
|
|
42
|
+
return nil, common.NewLogIDError(fmt.Sprintf("canvas allocate returned invalid data: %v", err), envelope.LogID)
|
|
43
|
+
}
|
|
44
|
+
idsRaw, ok := rawField(data, "ids", "IDs", "asset_ids")
|
|
45
|
+
if !ok {
|
|
46
|
+
return nil, common.NewLogIDError("canvas allocate response is missing data.ids", envelope.LogID)
|
|
47
|
+
}
|
|
48
|
+
var ids []string
|
|
49
|
+
if err := json.Unmarshal(idsRaw, &ids); err != nil {
|
|
50
|
+
return nil, common.NewLogIDError("canvas allocate data.ids must contain JSON strings", envelope.LogID)
|
|
51
|
+
}
|
|
52
|
+
if len(ids) != count {
|
|
53
|
+
return nil, common.NewLogIDError(
|
|
54
|
+
fmt.Sprintf("canvas allocate returned %d ids, want %d", len(ids), count),
|
|
55
|
+
envelope.LogID,
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
seen := make(map[string]struct{}, len(ids))
|
|
59
|
+
for index, id := range ids {
|
|
60
|
+
ids[index] = strings.TrimSpace(id)
|
|
61
|
+
if ids[index] == "" {
|
|
62
|
+
return nil, common.NewLogIDError(fmt.Sprintf("canvas allocate returned empty id at index %d", index), envelope.LogID)
|
|
63
|
+
}
|
|
64
|
+
if _, duplicate := seen[ids[index]]; duplicate {
|
|
65
|
+
return nil, common.NewLogIDError(fmt.Sprintf("canvas allocate returned duplicate id %q", ids[index]), envelope.LogID)
|
|
66
|
+
}
|
|
67
|
+
seen[ids[index]] = struct{}{}
|
|
68
|
+
}
|
|
69
|
+
return &AllocateResult{AssetIDs: ids, LogID: strings.TrimSpace(envelope.LogID)}, nil
|
|
70
|
+
}
|