fix(pat): align chmod session env handling
This commit is contained in:
1 parent
dd1c4e34fd
commit
7652bda320
4 files changed
+150
-26
No files matched your search
@@ -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`.
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user