fix(pat): align chmod session env handling

This commit is contained in:
shangguanxuan.sgx committed 2026-06-02 20:38:38 +08:00
1 parent dd1c4e34fd
commit 7652bda320
4 files changed
+150 -26

No files matched your search

+4
View File
@@ -10,6 +10,10 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
- **`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.
### Changed
- **Breaking: `dws pat chmod` now prints a compact authorization summary by default** — scripts that parse the raw MCP JSON response from stdout must pass `--format json` or `--verbose` to preserve the previous machine-readable payload. The summary keeps the grant status, agentCode, grantType, scope counts, and next-action hint without dumping full scope detail.
## [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`.
+24
View File
@@ -328,6 +328,30 @@ func TestResolveIdentityHeadersForwardsAgentCode(t *testing.T) {
}
}
func TestResolveIdentityHeadersSessionEnvPriority(t *testing.T) {
setupRuntimeCommandTest(t)
t.Setenv(envDingtalkSessionID, "ding-session")
t.Setenv(envDWSSessionID, "dws-session")
t.Setenv(envRewindSessionID, "rewind-session")
headers := resolveIdentityHeaders()
if got := headers["x-dingtalk-session-id"]; got != "ding-session" {
t.Fatalf("x-dingtalk-session-id = %q, want DINGTALK_SESSION_ID", got)
}
t.Setenv(envDingtalkSessionID, "")
headers = resolveIdentityHeaders()
if got := headers["x-dingtalk-session-id"]; got != "dws-session" {
t.Fatalf("x-dingtalk-session-id = %q, want DWS_SESSION_ID", got)
}
t.Setenv(envDWSSessionID, "")
headers = resolveIdentityHeaders()
if got := headers["x-dingtalk-session-id"]; got != "rewind-session" {
t.Fatalf("x-dingtalk-session-id = %q, want REWIND_SESSION_ID", got)
}
}
func TestDocDownloadPreflightRejectsAXLSBeforeDownloadPAT(t *testing.T) {
setupRuntimeCommandTest(t)
t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "1")
+28 -16
View File
@@ -29,25 +29,33 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
const (
sessionIDEnvDingtalk = "DINGTALK_SESSION_ID"
sessionIDEnvDWS = "DWS_SESSION_ID"
sessionIDEnvRewind = "REWIND_SESSION_ID"
)
// resolveSessionIDFromEnv returns the effective session id from environment
// variables. Resolution order:
// 1. DWS_SESSION_ID (primary, stable env name).
// 2. REWIND_SESSION_ID (compatibility alias; kept only so hosts that
// variables. Resolution order matches the MCP header resolver:
// 1. DINGTALK_SESSION_ID (primary gateway header env).
// 2. DWS_SESSION_ID (stable CLI/session-grant env name).
// 3. REWIND_SESSION_ID (compatibility alias; kept only so hosts that
// already inject the legacy trace triple keep working without code
// churn).
//
// When both are set to different non-empty values, DWS_SESSION_ID wins
// silently. We deliberately do NOT log either raw session id value or
// any derived fingerprint: this resolver is invoked by `dws pat chmod`
// session grants, and any stderr / ~/.dws/logs capture of those
// identifiers can land verbatim in attached troubleshooting bundles.
// Hosts that need to detect a mismatch between the two env vars must do
// so on the host side before invoking the CLI.
// When multiple env vars are set to different non-empty values, the first
// one above wins silently. We deliberately do NOT log either raw session id
// value or any derived fingerprint: this resolver is invoked by `dws pat
// chmod` session grants, and any stderr / ~/.dws/logs capture of those
// identifiers can land verbatim in attached troubleshooting bundles. Hosts
// that need to detect a mismatch must do so before invoking the CLI.
func resolveSessionIDFromEnv() string {
if dws := os.Getenv("DWS_SESSION_ID"); dws != "" {
return dws
for _, key := range []string{sessionIDEnvDingtalk, sessionIDEnvDWS, sessionIDEnvRewind} {
if value := os.Getenv(key); value != "" {
return value
}
}
return os.Getenv("REWIND_SESSION_ID")
return ""
}
// agentCodeEnv is the canonical (and only) environment variable name
@@ -148,6 +156,9 @@ const (
// patGrantToolNameLegacyAlias is retained for server builds that still
// expose only the legacy Chinese display name.
patGrantToolNameLegacyAlias = "个人授权"
patBatchUnsupportedCode = "PAT_BATCH_AUTH_UNSUPPORTED"
patBatchUnsupportedCodeLower = "pat_batch_auth_unsupported"
)
var validGrantTypes = map[string]bool{
@@ -350,7 +361,8 @@ func withPATContextEnv(agentCode, sessionID string, fn func() (*edition.ToolResu
_ = os.Setenv(key, value)
}
setEnv(agentCodeEnv, agentCode)
setEnv("DWS_SESSION_ID", sessionID)
setEnv(sessionIDEnvDingtalk, sessionID)
setEnv(sessionIDEnvDWS, sessionID)
defer func() {
for key, old := range restore {
if old == nil {
@@ -478,7 +490,7 @@ func isPATBatchUnsupportedResult(result *edition.ToolResult) bool {
return false
}
for _, key := range []string{"code", "errorCode", "error_code"} {
if code, ok := body[key].(string); ok && code == "PAT_BATCH_AUTH_UNSUPPORTED" {
if code, ok := body[key].(string); ok && strings.EqualFold(strings.TrimSpace(code), patBatchUnsupportedCode) {
return true
}
}
@@ -486,7 +498,7 @@ func isPATBatchUnsupportedResult(result *edition.ToolResult) bool {
}
func isPATBatchUnsupportedError(err error) bool {
return err != nil && strings.Contains(normalizedPATErrorText(err), strings.ToLower("PAT_BATCH_AUTH_UNSUPPORTED"))
return err != nil && strings.Contains(normalizedPATErrorText(err), patBatchUnsupportedCodeLower)
}
// callPATToolWithLegacyFallback invokes the canonical PAT grant tool first,
+94 -10
View File
@@ -32,14 +32,15 @@ import (
// assert how the two-tier --agentCode / DINGTALK_DWS_AGENTCODE / error
// resolver feeds into the outgoing MCP argv.
type fakeToolCaller struct {
mu sync.Mutex
dryRun bool
gotTool string
gotArgs map[string]any
gotAgentEnv string
gotSessionEnv string
callN int
resultOK bool
mu sync.Mutex
dryRun bool
gotTool string
gotArgs map[string]any
gotAgentEnv string
gotSessionEnv string
gotDingSessionEnv string
callN int
resultOK bool
}
func (f *fakeToolCaller) CallTool(_ context.Context, _ string, toolName string, args map[string]any) (*edition.ToolResult, error) {
@@ -48,7 +49,8 @@ func (f *fakeToolCaller) CallTool(_ context.Context, _ string, toolName string,
f.callN++
f.gotTool = toolName
f.gotAgentEnv = os.Getenv(agentCodeEnv)
f.gotSessionEnv = os.Getenv("DWS_SESSION_ID")
f.gotSessionEnv = os.Getenv(sessionIDEnvDWS)
f.gotDingSessionEnv = os.Getenv(sessionIDEnvDingtalk)
// defensive copy — RunE / runApply may mutate the map after return
f.gotArgs = make(map[string]any, len(args))
for k, v := range args {
@@ -347,7 +349,7 @@ func TestChmod_productsSessionModePassesSessionIDToPlanAndGrant(t *testing.T) {
func TestChmod_productsDryRunUsesSessionIDFromEnv(t *testing.T) {
t.Setenv(agentCodeEnv, "qoderwork")
t.Setenv("DWS_SESSION_ID", "env-session-123")
t.Setenv(sessionIDEnvDWS, "env-session-123")
fake := &sequenceToolCaller{
dryRun: true,
responses: []string{
@@ -372,6 +374,69 @@ func TestChmod_productsDryRunUsesSessionIDFromEnv(t *testing.T) {
}
}
func TestResolveSessionIDFromEnvMatchesHeaderPriority(t *testing.T) {
t.Setenv(sessionIDEnvDingtalk, "ding-session")
t.Setenv(sessionIDEnvDWS, "dws-session")
t.Setenv(sessionIDEnvRewind, "rewind-session")
if got := resolveSessionIDFromEnv(); got != "ding-session" {
t.Fatalf("resolveSessionIDFromEnv() = %q, want DINGTALK_SESSION_ID", got)
}
t.Setenv(sessionIDEnvDingtalk, "")
if got := resolveSessionIDFromEnv(); got != "dws-session" {
t.Fatalf("resolveSessionIDFromEnv() = %q, want DWS_SESSION_ID", got)
}
t.Setenv(sessionIDEnvDWS, "")
if got := resolveSessionIDFromEnv(); got != "rewind-session" {
t.Fatalf("resolveSessionIDFromEnv() = %q, want REWIND_SESSION_ID", got)
}
}
func TestChmod_sessionModeUsesDingtalkSessionEnv(t *testing.T) {
t.Setenv(sessionIDEnvDingtalk, "ding-session-123")
fake := &fakeToolCaller{resultOK: true}
cmd := buildChmod(t, fake)
if err := cmd.RunE(cmd, []string{"aitable.record:read"}); err != nil {
t.Fatalf("chmod RunE error = %v", err)
}
if got := fake.gotArgs["sessionId"]; got != "ding-session-123" {
t.Fatalf("sessionId arg = %#v, want ding-session-123", got)
}
if fake.gotDingSessionEnv != "ding-session-123" {
t.Fatalf("%s during CallTool = %q, want ding-session-123", sessionIDEnvDingtalk, fake.gotDingSessionEnv)
}
if fake.gotSessionEnv != "ding-session-123" {
t.Fatalf("%s during CallTool = %q, want ding-session-123", sessionIDEnvDWS, fake.gotSessionEnv)
}
}
func TestChmod_explicitSessionIDOverridesStaleDingtalkSessionEnv(t *testing.T) {
t.Setenv(sessionIDEnvDingtalk, "stale-session")
fake := &fakeToolCaller{resultOK: true}
cmd := buildChmod(t, fake)
_ = cmd.Flags().Set("session-id", "flag-session")
if err := cmd.RunE(cmd, []string{"aitable.record:read"}); err != nil {
t.Fatalf("chmod RunE error = %v", err)
}
if got := fake.gotArgs["sessionId"]; got != "flag-session" {
t.Fatalf("sessionId arg = %#v, want flag-session", got)
}
if fake.gotDingSessionEnv != "flag-session" {
t.Fatalf("%s during CallTool = %q, want flag-session", sessionIDEnvDingtalk, fake.gotDingSessionEnv)
}
if fake.gotSessionEnv != "flag-session" {
t.Fatalf("%s during CallTool = %q, want flag-session", sessionIDEnvDWS, fake.gotSessionEnv)
}
}
func TestChmod_recommendFlagPlansThenGrantsWithoutPositionalScopes(t *testing.T) {
t.Setenv(agentCodeEnv, "qoderwork")
fake := &sequenceToolCaller{responses: []string{
@@ -729,6 +794,25 @@ func TestIsToolNotRegisteredError_ChineseGatewayDiagnostics(t *testing.T) {
}
}
func TestIsPATBatchUnsupportedResultCaseInsensitive(t *testing.T) {
result := &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"success":false,"errorCode":"pat_batch_auth_unsupported"}`}}}
if !isPATBatchUnsupportedResult(result) {
t.Fatal("isPATBatchUnsupportedResult() = false, want true")
}
}
func TestIsPATBatchUnsupportedErrorUsesNormalizedDiagnostics(t *testing.T) {
err := apperrors.NewAPI("business error: success=false",
apperrors.WithReason("business_error"),
apperrors.WithServerDiag(apperrors.ServerDiagnostics{
ServerErrorCode: "PAT_BATCH_AUTH_UNSUPPORTED",
}),
)
if !isPATBatchUnsupportedError(err) {
t.Fatal("isPATBatchUnsupportedError() = false, want true")
}
}
func TestHandleToolResult_emptyResultReturnsError(t *testing.T) {
err := handleToolResult(nil, nil, &edition.ToolResult{})
if err == nil {