diff --git a/CHANGELOG.md b/CHANGELOG.md index c8781a6d..85bd0b0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and this project follows [Semantic Versioning](https://semver.org/). +## [Unreleased] + +### Added + +- **`dws auth export` / `dws auth import`** — portable auth bundle for migrating Linux sandbox credentials. Exports the encrypted keychain (`~/.local/share/dws-cli`, including `auth-token.enc` and `dek`) plus required `~/.dws` config so refresh tokens survive import; copying only `app.json` leaves access tokens expiring after ~2 hours. Supports `-o` / `-i` tar.gz paths and `--base64` for copy/paste between sandboxes. `dws auth status` now shows refresh-token validity in table output. + ## [1.0.32] - 2026-05-25 Two user-visible regressions resolved plus two AI-agent discoverability fixes. `dws drive upload` was returning `HTTP 403 SignatureDoesNotMatch` for any file whose MIME detects to a non-empty value — basically every real file — because the helper added a client-side `Content-Type` fallback whenever `drive.get_upload_info` returned an empty headers map. DingTalk drive's OSS presigned PUT URLs are signed against an empty `Content-Type` at signing time, so any client-supplied header makes the signature OSS recomputes diverge from the server-signed one, and the PUT is rejected (#347). On Apple Silicon, `dws upgrade` was aborting at the "解压并验证" step with `signal: killed` because GoReleaser cross-compiles `darwin/arm64` binaries on `ubuntu-latest` with no codesign step, and macOS 11+ `amfid` SIGKILLs unsigned arm64 binaries on first exec (#339) — the release pipeline now ad-hoc signs every darwin tarball, and the upgrade client self-heals if it ever encounters an unsigned binary again. On the AI-agent discoverability side, `dws aitable attachment upload-file` (the one-shot prepare + PUT + commit composite) is no longer hidden from `--help` — agents that only browse the command tree were getting stuck at the prepare-only `attachment upload` step, which returns an upload URL + fileToken but doesn't actually upload. And `dws --help` itself now surfaces the missing-command upgrade hint that the custom `renderRootHelp` had been silently dropping from cobra's `root.Long`. diff --git a/README.md b/README.md index de1b8f5e..282b8bc1 100644 --- a/README.md +++ b/README.md @@ -203,6 +203,26 @@ Credentials are securely persisted after first login (Keychain). Subsequent runs +
+Migrate auth between Linux sandboxes + +Copying only `~/.dws/app.json` does not carry the refresh token; access tokens expire after ~2 hours. Use the official export/import flow: + +```bash +# Sandbox A (already logged in) +dws auth export -o /tmp/dws-auth.tar.gz +# Or for copy/paste: dws auth export --base64 -o /tmp/dws-auth.b64 + +# Sandbox B +dws auth import -i /tmp/dws-auth.tar.gz +# Or: dws auth import -i /tmp/dws-auth.b64 --base64 +dws auth status # confirm "Refresh Token: valid" +``` + +The bundle includes the encrypted keychain under `~/.local/share/dws-cli` (with `auth-token.enc` and `dek`) plus required `~/.dws` config files. + +
+ ## Quick Start ```bash diff --git a/README_zh.md b/README_zh.md index 5d0d861e..6d0f3cff 100644 --- a/README_zh.md +++ b/README_zh.md @@ -203,6 +203,26 @@ dws auth login --client-id --client-secret +
+沙箱间迁移登录态(Linux) + +仅拷贝 `~/.dws/app.json` 无法带走 refresh token;access token 约 2 小时后会失效。请使用官方导出/导入: + +```bash +# A 沙箱(已登录) +dws auth export -o /tmp/dws-auth.tar.gz +# 或便于分片复制:dws auth export --base64 -o /tmp/dws-auth.b64 + +# B 沙箱 +dws auth import -i /tmp/dws-auth.tar.gz +# 或:dws auth import -i /tmp/dws-auth.b64 --base64 +dws auth status # 确认 Refresh Token: 有效 +``` + +包内包含 `~/.local/share/dws-cli` 加密 keychain(含 `auth-token.enc` 与 `dek`)及 `~/.dws` 必要配置。 + +
+ ## 快速开始 ```bash diff --git a/internal/app/auth_command.go b/internal/app/auth_command.go index e1a47d2e..99dd26b5 100644 --- a/internal/app/auth_command.go +++ b/internal/app/auth_command.go @@ -14,17 +14,22 @@ package app import ( + "bytes" "context" + "encoding/base64" "encoding/json" "fmt" "io" "os" "path/filepath" + "runtime" "strings" "time" authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" "github.com/spf13/cobra" @@ -55,6 +60,8 @@ func buildAuthCommand() *cobra.Command { cmd.AddCommand( newAuthLogoutCommand(), newAuthStatusCommand(), + newAuthExportCommand(), + newAuthImportCommand(), newAuthExchangeCommand(), newAuthResetCommand(), ) @@ -277,6 +284,13 @@ func newAuthStatusCommand() *cobra.Command { } else { fmt.Fprintf(w, "%-16s%s\n", "状态:", "已登录 ✅") } + if tokenData != nil { + if tokenData.IsRefreshTokenValid() { + fmt.Fprintf(w, "%-16s%s\n", "Refresh Token:", "有效 ✅") + } else { + fmt.Fprintf(w, "%-16s%s\n", "Refresh Token:", "缺失或已过期 ⚠️") + } + } if updatedAt := authStatusUpdatedAt(tokenData); updatedAt != "" { fmt.Fprintf(w, "%-16s%s\n", "有效期:", updatedAt) } @@ -291,6 +305,138 @@ func newAuthStatusCommand() *cobra.Command { } } +func newAuthExportCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "export", + Short: "导出可迁移认证包", + Long: `导出包含 refresh token 与解密材料的认证包,便于在另一台 Linux 沙箱中导入。 + +包内包含 ~/.local/share/dws-cli 加密 keychain 与 ~/.dws 必要配置,不含 token 明文。 + +示例: + dws auth export -o dws-auth.tar.gz + dws auth export --base64 > dws-auth.b64`, + DisableAutoGenTag: true, + RunE: func(cmd *cobra.Command, args []string) error { + output, err := cmd.Flags().GetString("output") + if err != nil { + return apperrors.NewInternal("failed to read --output") + } + asBase64, err := cmd.Flags().GetBool("base64") + if err != nil { + return apperrors.NewInternal("failed to read --base64") + } + output = strings.TrimSpace(output) + if !asBase64 && output == "" { + return apperrors.NewValidation("--output is required unless --base64 is used") + } + if !authpkg.PortableExportSupported() { + return apperrors.NewValidation(fmt.Sprintf( + "macOS 默认将 DEK 存在系统 Keychain,导出的包无法在其它机器解密;请设置 %s=1 后重新登录再导出", + keychain.DisableKeychainEnv, + )) + } + if !authpkg.PortableAuthSourceReady() { + return apperrors.NewValidation("尚未登录,请先运行 dws auth login") + } + + var bundle bytes.Buffer + if err := authpkg.ExportPortableAuthBundle(defaultConfigDir(), &bundle); err != nil { + return apperrors.NewInternal(fmt.Sprintf("failed to export auth bundle: %v", err)) + } + + if asBase64 { + payload := []byte(base64.StdEncoding.EncodeToString(bundle.Bytes()) + "\n") + if output == "" { + _, err := cmd.OutOrStdout().Write(payload) + return err + } + if err := helpers.AtomicWrite(output, payload, config.FilePerm); err != nil { + return apperrors.NewInternal(fmt.Sprintf("failed to write auth bundle: %v", err)) + } + fmt.Fprintf(cmd.OutOrStdout(), "[OK] 已导出认证包: %s\n", output) + fmt.Fprintf(cmd.ErrOrStderr(), "认证包含敏感凭据,用完请删除: rm -P %s\n", output) + return nil + } + + if err := helpers.AtomicWrite(output, bundle.Bytes(), config.FilePerm); err != nil { + return apperrors.NewInternal(fmt.Sprintf("failed to write auth bundle: %v", err)) + } + fmt.Fprintf(cmd.OutOrStdout(), "[OK] 已导出认证包: %s\n", output) + fmt.Fprintf(cmd.ErrOrStderr(), "认证包含敏感凭据,用完请删除: rm -P %s\n", output) + return nil + }, + } + cmd.Flags().StringP("output", "o", "", "认证包输出路径") + cmd.Flags().Bool("base64", false, "将认证包编码为 base64,便于复制粘贴") + return cmd +} + +func newAuthImportCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "import", + Short: "导入可迁移认证包", + Long: `从 dws auth export 生成的 tar.gz 或 base64 文件恢复认证。 + +导入后请运行 dws auth status 确认 refresh token 仍有效。 + +示例: + dws auth import -i dws-auth.tar.gz + dws auth import -i dws-auth.b64 --base64`, + DisableAutoGenTag: true, + RunE: func(cmd *cobra.Command, args []string) error { + input, err := cmd.Flags().GetString("input") + if err != nil { + return apperrors.NewInternal("failed to read --input") + } + input = strings.TrimSpace(input) + if input == "" { + return apperrors.NewValidation("--input is required") + } + asBase64, err := cmd.Flags().GetBool("base64") + if err != nil { + return apperrors.NewInternal("failed to read --base64") + } + force, err := cmd.Flags().GetBool("force") + if err != nil { + return apperrors.NewInternal("failed to read --force") + } + + configDir := defaultConfigDir() + if !force && authpkg.PortableAuthTargetPopulated(configDir) { + return apperrors.NewValidation("检测到已有登录态,请使用 --force 确认覆盖") + } + + payload, err := os.ReadFile(input) + if err != nil { + return apperrors.NewInternal(fmt.Sprintf("failed to read auth bundle: %v", err)) + } + if asBase64 { + payload, err = base64.StdEncoding.DecodeString(strings.TrimSpace(string(payload))) + if err != nil { + return apperrors.NewValidation(fmt.Sprintf("invalid base64 auth bundle: %v", err)) + } + } + report, err := authpkg.ImportPortableAuthBundle(configDir, bytes.NewReader(payload)) + if err != nil { + return apperrors.NewInternal(fmt.Sprintf("failed to import auth bundle: %v", err)) + } + if report.OSMismatch { + fmt.Fprintf(cmd.ErrOrStderr(), "警告: 认证包来自 %s,当前系统为 %s,请确认解密材料兼容\n", report.BundleOS, runtime.GOOS) + } + ResetRuntimeTokenCache() + clearCompatCache() + fmt.Fprintln(cmd.OutOrStdout(), "[OK] 已导入认证包") + fmt.Fprintln(cmd.OutOrStdout(), "请运行 dws auth status 验证登录状态") + return nil + }, + } + cmd.Flags().StringP("input", "i", "", "认证包输入路径") + cmd.Flags().Bool("base64", false, "输入为 base64 编码的认证包") + cmd.Flags().Bool("force", false, "覆盖已有登录态") + return cmd +} + func newAuthExchangeCommand() *cobra.Command { cmd := &cobra.Command{ Use: "exchange", diff --git a/internal/app/auth_command_test.go b/internal/app/auth_command_test.go index 895c919c..b6a1e85a 100644 --- a/internal/app/auth_command_test.go +++ b/internal/app/auth_command_test.go @@ -17,14 +17,117 @@ import ( "bytes" "errors" "net/http" + "os" "path/filepath" + "strings" "testing" "time" authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain" ) +func TestAuthExportImportBase64RoundTrip(t *testing.T) { + t.Setenv(keychain.DisableKeychainEnv, "1") + sourceKeychain := filepath.Join(t.TempDir(), "source-keychain") + sourceConfig := filepath.Join(t.TempDir(), ".dws") + t.Setenv(keychain.StorageDirEnv, sourceKeychain) + t.Setenv("DWS_CONFIG_DIR", sourceConfig) + + original := &authpkg.TokenData{ + AccessToken: "access-cli", + RefreshToken: "refresh-cli", + ExpiresAt: time.Now().Add(-time.Hour), + RefreshExpAt: time.Now().Add(24 * time.Hour), + ClientID: "client-cli", + Source: "mcp", + } + if err := authpkg.SaveTokenData(sourceConfig, original); err != nil { + t.Fatalf("SaveTokenData() error = %v", err) + } + + exportCmd := NewRootCommand() + var exported bytes.Buffer + exportCmd.SetOut(&exported) + exportCmd.SetErr(&bytes.Buffer{}) + exportCmd.SetArgs([]string{"auth", "export", "--base64"}) + if err := exportCmd.Execute(); err != nil { + t.Fatalf("auth export --base64 error = %v", err) + } + if strings.TrimSpace(exported.String()) == "" { + t.Fatal("auth export --base64 produced empty output") + } + + targetRoot := t.TempDir() + inputPath := filepath.Join(targetRoot, "dws-auth.b64") + if err := os.WriteFile(inputPath, exported.Bytes(), 0o600); err != nil { + t.Fatalf("write input bundle error = %v", err) + } + + targetKeychain := filepath.Join(targetRoot, "target-keychain") + targetConfig := filepath.Join(targetRoot, ".dws") + t.Setenv(keychain.StorageDirEnv, targetKeychain) + t.Setenv("DWS_CONFIG_DIR", targetConfig) + + importCmd := NewRootCommand() + importCmd.SetOut(&bytes.Buffer{}) + importCmd.SetErr(&bytes.Buffer{}) + importCmd.SetArgs([]string{"auth", "import", "--input", inputPath, "--base64"}) + if err := importCmd.Execute(); err != nil { + t.Fatalf("auth import --base64 error = %v", err) + } + + loaded, err := authpkg.LoadTokenData(targetConfig) + if err != nil { + t.Fatalf("LoadTokenData() after CLI import error = %v", err) + } + if loaded.RefreshToken != original.RefreshToken { + t.Fatalf("refresh token = %q, want %q", loaded.RefreshToken, original.RefreshToken) + } + if !loaded.IsRefreshTokenValid() { + t.Fatal("refresh token should remain valid after CLI import") + } +} + +func TestAuthImportRequiresForceWhenPopulated(t *testing.T) { + t.Setenv(keychain.DisableKeychainEnv, "1") + root := t.TempDir() + configDir := filepath.Join(root, ".dws") + t.Setenv(keychain.StorageDirEnv, filepath.Join(root, "keychain")) + t.Setenv("DWS_CONFIG_DIR", configDir) + + if err := authpkg.SaveTokenData(configDir, &authpkg.TokenData{ + AccessToken: "existing", + RefreshToken: "existing-refresh", + RefreshExpAt: time.Now().Add(24 * time.Hour), + }); err != nil { + t.Fatalf("SaveTokenData() error = %v", err) + } + + bundlePath := filepath.Join(root, "bundle.tar.gz") + if err := os.WriteFile(bundlePath, []byte("not-a-real-bundle"), 0o600); err != nil { + t.Fatalf("write bundle stub error = %v", err) + } + + importCmd := NewRootCommand() + var stderr bytes.Buffer + importCmd.SetOut(&bytes.Buffer{}) + importCmd.SetErr(&stderr) + importCmd.SetArgs([]string{"auth", "import", "--input", bundlePath}) + err := importCmd.Execute() + if err == nil { + t.Fatal("auth import without --force should fail when auth exists") + } + var appErr *apperrors.Error + if !errors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation { + t.Fatalf("expected validation error, got %T: %v", err, err) + } + if !strings.Contains(err.Error(), "--force") { + t.Fatalf("error = %v, want --force hint", err) + } +} + func TestAuthStatusRefreshFailureLeavesStoredTokenIntact(t *testing.T) { // Isolate keychain storage to a per-test directory so the saved // token can't leak into other test packages running in parallel. diff --git a/internal/auth/portable_store.go b/internal/auth/portable_store.go new file mode 100644 index 00000000..0bc3d256 --- /dev/null +++ b/internal/auth/portable_store.go @@ -0,0 +1,373 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package auth + +import ( + "archive/tar" + "compress/gzip" + "encoding/json" + "fmt" + "io" + "os" + "path" + "path/filepath" + "runtime" + "sort" + "strings" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" +) + +// PortableImportReport summarizes bundle metadata consumed during import. +type PortableImportReport struct { + BundleOS string + OSMismatch bool +} + +// PortableExportSupported reports whether the current platform can produce a +// bundle that includes the file-based DEK required for import elsewhere. +func PortableExportSupported() bool { + if runtime.GOOS != "darwin" { + return true + } + return os.Getenv(keychain.DisableKeychainEnv) != "" +} + +// PortableAuthTargetPopulated reports whether local auth files would be +// overwritten by a portable import. +func PortableAuthTargetPopulated(configDir string) bool { + if TokenDataExistsKeychain() { + return true + } + if _, err := os.Stat(filepath.Join(configDir, "app.json")); err == nil { + return true + } + encPath := filepath.Join(keychain.StorageDir(keychain.Service), keychain.AccountToken+".enc") + if _, err := os.Stat(encPath); err == nil { + return true + } + return false +} + +// PortableAuthSourceReady reports whether encrypted auth token exists for export. +func PortableAuthSourceReady() bool { + return portableAuthSourcePopulated(keychain.StorageDir(keychain.Service)) +} + +func portableAuthSourcePopulated(keychainDir string) bool { + _, err := os.Stat(filepath.Join(keychainDir, keychain.AccountToken+".enc")) + return err == nil +} + +const portableAuthManifest = "manifest.json" + +type portableAuthBundleManifest struct { + Version int `json:"version"` + CreatedAt string `json:"created_at"` + OS string `json:"os"` + KeychainService string `json:"keychain_service"` + ConfigFiles []string `json:"config_files,omitempty"` +} + +// ExportPortableAuthBundle writes a portable auth bundle as tar.gz. +// It copies the encrypted keychain files plus the small config files needed +// to refresh tokens in another Linux sandbox. +func ExportPortableAuthBundle(configDir string, w io.Writer) error { + if w == nil { + return fmt.Errorf("missing output writer") + } + if !PortableExportSupported() { + return fmt.Errorf("portable export unavailable on macOS while DEK is in system Keychain; set %s=1, re-login, then export", keychain.DisableKeychainEnv) + } + keychainDir := keychain.StorageDir(keychain.Service) + if _, err := os.Stat(keychainDir); err != nil { + return fmt.Errorf("auth keychain directory is not available: %w", err) + } + if !portableAuthSourcePopulated(keychainDir) { + return fmt.Errorf("auth token is not available for export; run dws auth login first") + } + + gz := gzip.NewWriter(w) + defer gz.Close() + tw := tar.NewWriter(gz) + defer tw.Close() + + configFiles, err := portableConfigFiles(configDir) + if err != nil { + return err + } + manifest := portableAuthBundleManifest{ + Version: 1, + CreatedAt: time.Now().UTC().Format(time.RFC3339), + OS: runtime.GOOS, + KeychainService: keychain.Service, + ConfigFiles: configFiles, + } + if err := writePortableManifest(tw, manifest); err != nil { + return err + } + + if err := addPortableDir(tw, keychainDir, path.Join("keychain", keychain.Service)); err != nil { + return err + } + for _, name := range configFiles { + src := filepath.Join(configDir, name) + if err := addPortableFile(tw, src, path.Join("config", filepath.ToSlash(name))); err != nil { + return err + } + } + return nil +} + +// ImportPortableAuthBundle extracts a tar.gz auth bundle into the current +// config and keychain locations. +func ImportPortableAuthBundle(configDir string, r io.Reader) (PortableImportReport, error) { + if r == nil { + return PortableImportReport{}, fmt.Errorf("missing input reader") + } + gz, err := gzip.NewReader(r) + if err != nil { + return PortableImportReport{}, fmt.Errorf("open auth bundle: %w", err) + } + defer gz.Close() + + tr := tar.NewReader(gz) + keychainDir := keychain.StorageDir(keychain.Service) + var manifest portableAuthBundleManifest + manifestRead := false + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return PortableImportReport{}, fmt.Errorf("read auth bundle: %w", err) + } + if hdr == nil { + continue + } + cleanName, err := cleanPortableName(hdr.Name) + if err != nil { + return PortableImportReport{}, err + } + if cleanName == portableAuthManifest { + if err := json.NewDecoder(tr).Decode(&manifest); err != nil { + return PortableImportReport{}, fmt.Errorf("read auth bundle manifest: %w", err) + } + manifestRead = true + continue + } + + var target string + switch { + case strings.HasPrefix(cleanName, "keychain/"+keychain.Service+"/"): + rel := strings.TrimPrefix(cleanName, "keychain/"+keychain.Service+"/") + target, err = safeJoin(keychainDir, rel) + case strings.HasPrefix(cleanName, "config/"): + rel := strings.TrimPrefix(cleanName, "config/") + target, err = safeJoin(configDir, rel) + default: + return PortableImportReport{}, fmt.Errorf("unsupported auth bundle path %q", hdr.Name) + } + if err != nil { + return PortableImportReport{}, err + } + if err := extractPortableEntry(target, hdr, tr); err != nil { + return PortableImportReport{}, err + } + } + report := PortableImportReport{} + if manifestRead { + report.BundleOS = manifest.OS + report.OSMismatch = manifest.OS != "" && manifest.OS != runtime.GOOS + } + return report, nil +} + +func portableConfigFiles(configDir string) ([]string, error) { + var files []string + patterns := []string{"app*.json", "mcp_url", "terminal_url"} + for _, pattern := range patterns { + matches, err := filepath.Glob(filepath.Join(configDir, pattern)) + if err != nil { + return nil, fmt.Errorf("scan config files: %w", err) + } + for _, match := range matches { + info, err := os.Stat(match) + if err != nil || info.IsDir() { + continue + } + rel, err := filepath.Rel(configDir, match) + if err != nil { + return nil, fmt.Errorf("resolve config file: %w", err) + } + files = append(files, rel) + } + } + sort.Strings(files) + return files, nil +} + +func writePortableManifest(tw *tar.Writer, manifest portableAuthBundleManifest) error { + data, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return fmt.Errorf("marshal auth bundle manifest: %w", err) + } + return writePortableBytes(tw, portableAuthManifest, append(data, '\n'), config.FilePerm) +} + +func addPortableDir(tw *tar.Writer, root, prefix string) error { + return filepath.WalkDir(root, func(filePath string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.Type()&os.ModeSymlink != 0 { + return nil + } + if entry.IsDir() { + if filePath == root { + return nil + } + rel, err := filepath.Rel(root, filePath) + if err != nil { + return err + } + name := path.Join(prefix, filepath.ToSlash(rel)) + return tw.WriteHeader(&tar.Header{Name: name, Typeflag: tar.TypeDir, Mode: int64(config.DirPerm)}) + } + return addPortableFile(tw, filePath, path.Join(prefix, mustPortableRel(root, filePath))) + }) +} + +func mustPortableRel(root, filePath string) string { + rel, err := filepath.Rel(root, filePath) + if err != nil { + return filepath.Base(filePath) + } + return filepath.ToSlash(rel) +} + +func addPortableFile(tw *tar.Writer, src, name string) error { + info, err := os.Stat(src) + if err != nil { + return fmt.Errorf("stat %s: %w", src, err) + } + if info.IsDir() { + return nil + } + file, err := os.Open(src) + if err != nil { + return fmt.Errorf("open %s: %w", src, err) + } + defer file.Close() + + if err := tw.WriteHeader(&tar.Header{ + Name: path.Clean(name), + Size: info.Size(), + Mode: int64(config.FilePerm), + ModTime: info.ModTime(), + }); err != nil { + return fmt.Errorf("write auth bundle header: %w", err) + } + if _, err := io.Copy(tw, file); err != nil { + return fmt.Errorf("write auth bundle file: %w", err) + } + return nil +} + +func writePortableBytes(tw *tar.Writer, name string, data []byte, mode os.FileMode) error { + if err := tw.WriteHeader(&tar.Header{ + Name: path.Clean(name), + Size: int64(len(data)), + Mode: int64(mode), + ModTime: time.Now(), + }); err != nil { + return fmt.Errorf("write auth bundle header: %w", err) + } + if _, err := tw.Write(data); err != nil { + return fmt.Errorf("write auth bundle data: %w", err) + } + return nil +} + +func cleanPortableName(name string) (string, error) { + name = path.Clean(strings.TrimSpace(name)) + if name == "." || name == "/" || strings.HasPrefix(name, "../") || strings.HasPrefix(name, "/") { + return "", fmt.Errorf("unsafe auth bundle path %q", name) + } + return name, nil +} + +func safeJoin(root, rel string) (string, error) { + if rel == "" { + return "", fmt.Errorf("empty auth bundle path") + } + rel = filepath.FromSlash(path.Clean(rel)) + if filepath.IsAbs(rel) || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." { + return "", fmt.Errorf("unsafe auth bundle path %q", rel) + } + target := filepath.Join(root, rel) + cleanRoot := filepath.Clean(root) + string(filepath.Separator) + if target != filepath.Clean(root) && !strings.HasPrefix(filepath.Clean(target)+string(filepath.Separator), cleanRoot) { + return "", fmt.Errorf("unsafe auth bundle path %q", rel) + } + return target, nil +} + +func extractPortableEntry(target string, hdr *tar.Header, r io.Reader) error { + switch hdr.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(target, config.DirPerm); err != nil { + return fmt.Errorf("create auth bundle directory: %w", err) + } + return os.Chmod(target, config.DirPerm) + case tar.TypeReg: + if err := os.MkdirAll(filepath.Dir(target), config.DirPerm); err != nil { + return fmt.Errorf("create auth bundle directory: %w", err) + } + tmp, err := os.CreateTemp(filepath.Dir(target), "."+filepath.Base(target)+".*.tmp") + if err != nil { + return fmt.Errorf("create auth bundle temp file: %w", err) + } + tmpName := tmp.Name() + success := false + defer func() { + if !success { + tmp.Close() + _ = os.Remove(tmpName) + } + }() + if err := tmp.Chmod(config.FilePerm); err != nil { + return fmt.Errorf("set auth bundle file permissions: %w", err) + } + if _, err := io.Copy(tmp, r); err != nil { + return fmt.Errorf("write auth bundle file: %w", err) + } + if err := tmp.Sync(); err != nil { + return fmt.Errorf("sync auth bundle file: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close auth bundle file: %w", err) + } + if err := os.Rename(tmpName, target); err != nil { + return fmt.Errorf("install auth bundle file: %w", err) + } + success = true + return nil + default: + return fmt.Errorf("unsupported auth bundle entry type %d for %q", hdr.Typeflag, hdr.Name) + } +} diff --git a/internal/auth/portable_store_test.go b/internal/auth/portable_store_test.go new file mode 100644 index 00000000..3e019a5b --- /dev/null +++ b/internal/auth/portable_store_test.go @@ -0,0 +1,140 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package auth + +import ( + "bytes" + "os" + "path/filepath" + "runtime" + "testing" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain" +) + +func TestPortableExportSupported(t *testing.T) { + if runtime.GOOS != "darwin" { + if !PortableExportSupported() { + t.Fatal("PortableExportSupported() should be true on non-darwin") + } + return + } + t.Setenv(keychain.DisableKeychainEnv, "") + if PortableExportSupported() { + t.Fatal("PortableExportSupported() should be false on darwin without file DEK") + } + t.Setenv(keychain.DisableKeychainEnv, "1") + if !PortableExportSupported() { + t.Fatal("PortableExportSupported() should be true when file DEK is enabled") + } +} + +func TestExportPortableAuthBundleRequiresAuthToken(t *testing.T) { + t.Setenv(keychain.DisableKeychainEnv, "1") + keychainRoot := filepath.Join(t.TempDir(), "empty-keychain") + if err := os.MkdirAll(filepath.Join(keychainRoot, keychain.Service), 0o700); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + t.Setenv(keychain.StorageDirEnv, keychainRoot) + configDir := filepath.Join(t.TempDir(), ".dws") + + var bundle bytes.Buffer + err := ExportPortableAuthBundle(configDir, &bundle) + if err == nil { + t.Fatal("ExportPortableAuthBundle() should fail without auth-token.enc") + } + if bundle.Len() != 0 { + t.Fatalf("ExportPortableAuthBundle() wrote %d bytes, want 0", bundle.Len()) + } +} + +func TestPortableAuthTargetPopulated(t *testing.T) { + t.Setenv(keychain.DisableKeychainEnv, "1") + root := t.TempDir() + configDir := filepath.Join(root, ".dws") + t.Setenv(keychain.StorageDirEnv, filepath.Join(root, "keychain")) + + if PortableAuthTargetPopulated(configDir) { + t.Fatal("PortableAuthTargetPopulated() should be false before save") + } + if err := SaveTokenData(configDir, &TokenData{ + AccessToken: "token", + RefreshToken: "refresh", + RefreshExpAt: time.Now().Add(time.Hour), + }); err != nil { + t.Fatalf("SaveTokenData() error = %v", err) + } + if !PortableAuthTargetPopulated(configDir) { + t.Fatal("PortableAuthTargetPopulated() should be true after save") + } +} + +func TestPortableAuthBundleRoundTripPreservesRefreshToken(t *testing.T) { + t.Setenv(keychain.DisableKeychainEnv, "1") + sourceKeychain := filepath.Join(t.TempDir(), "source-keychain") + t.Setenv(keychain.StorageDirEnv, sourceKeychain) + sourceConfig := filepath.Join(t.TempDir(), ".dws") + + original := &TokenData{ + AccessToken: "access-source", + RefreshToken: "refresh-source", + ExpiresAt: time.Now().Add(-time.Hour), + RefreshExpAt: time.Now().Add(30 * 24 * time.Hour), + CorpID: "dingcorp", + ClientID: "client-from-mcp", + Source: "mcp", + } + if err := SaveTokenData(sourceConfig, original); err != nil { + t.Fatalf("SaveTokenData() error = %v", err) + } + if err := SaveAppConfig(sourceConfig, &AppConfig{ClientID: "client-from-mcp"}); err != nil { + t.Fatalf("SaveAppConfig() error = %v", err) + } + + var bundle bytes.Buffer + if err := ExportPortableAuthBundle(sourceConfig, &bundle); err != nil { + t.Fatalf("ExportPortableAuthBundle() error = %v", err) + } + if bundle.Len() == 0 { + t.Fatal("ExportPortableAuthBundle() wrote an empty bundle") + } + + targetKeychain := filepath.Join(t.TempDir(), "target-keychain") + t.Setenv(keychain.StorageDirEnv, targetKeychain) + targetConfig := filepath.Join(t.TempDir(), ".dws") + + if _, err := ImportPortableAuthBundle(targetConfig, bytes.NewReader(bundle.Bytes())); err != nil { + t.Fatalf("ImportPortableAuthBundle() error = %v", err) + } + + loaded, err := LoadTokenData(targetConfig) + if err != nil { + t.Fatalf("LoadTokenData() after import error = %v", err) + } + if loaded.AccessToken != original.AccessToken { + t.Fatalf("access token = %q, want %q", loaded.AccessToken, original.AccessToken) + } + if loaded.RefreshToken != original.RefreshToken { + t.Fatalf("refresh token = %q, want %q", loaded.RefreshToken, original.RefreshToken) + } + if !loaded.IsRefreshTokenValid() { + t.Fatal("refresh token should remain valid after import") + } + if cfg, err := LoadAppConfig(targetConfig); err != nil { + t.Fatalf("LoadAppConfig() after import error = %v", err) + } else if cfg == nil || cfg.ClientID != "client-from-mcp" { + t.Fatalf("imported app config = %#v, want client ID preserved", cfg) + } +} diff --git a/skills/mono/references/global-reference.md b/skills/mono/references/global-reference.md index b74e5d21..8089ba7f 100644 --- a/skills/mono/references/global-reference.md +++ b/skills/mono/references/global-reference.md @@ -9,6 +9,10 @@ dws auth login # 查看状态 dws auth status +# 沙箱间迁移登录态(Linux,含 refresh token) +dws auth export -o dws-auth.tar.gz +dws auth import -i dws-auth.tar.gz + # 退出 dws auth logout diff --git a/test/cli/docs_compatibility_test.go b/test/cli/docs_compatibility_test.go index 0284f10f..c6fda9ad 100644 --- a/test/cli/docs_compatibility_test.go +++ b/test/cli/docs_compatibility_test.go @@ -31,18 +31,11 @@ func TestDWSDocsCommandTreeCoverage(t *testing.T) { } index := buildCommandIndex(app.NewRootCommand()) - // Commands documented in baseline but intentionally not implemented. - excluded := map[string]struct{}{ - "dws auth export": {}, - } missing := make([]string, 0) for _, path := range docPaths { if _, ok := index[path]; ok { continue } - if _, ok := excluded[path]; ok { - continue - } missing = append(missing, path) } if len(missing) > 0 { @@ -64,10 +57,6 @@ func TestDWSDocsLocalFlagsCoverage(t *testing.T) { docLeafSet := leafCommandSet(docPaths) index := buildCommandIndex(app.NewRootCommand()) - // Commands documented in baseline but intentionally not implemented. - excluded := map[string]struct{}{ - "dws auth export": {}, - } missing := make([]string, 0) fallbackMatched := 0 explicitMatched := 0 @@ -76,9 +65,6 @@ func TestDWSDocsLocalFlagsCoverage(t *testing.T) { if _, isLeaf := docLeafSet[tc.CommandPath]; !isLeaf { continue } - if _, ok := excluded[tc.CommandPath]; ok { - continue - } cmd, ok := index[tc.CommandPath] if !ok { missing = append(missing, tc.CommandPath+" "+tc.Flag+" ("+tc.Source+")")