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+")")