* feat(pat): A-core minimum viable loop for host-owned PAT
Enables third-party agents to integrate PAT via the smallest possible
contract surface. The flow a host needs to ship end-to-end:
1. Export DINGTALK_DWS_AGENTCODE in the spawned shell.
2. Run any `dws ...` business command.
3. On PAT hit, CLI exits with code 4 and writes a single-line stderr
JSON following docs/pat/contract.md §2.
4. Host parses the JSON and invokes `dws pat chmod <scope>...
--agentCode ... --grant-type ...` to grant.
5. Host replays the original command.
This PR intentionally ships ONLY the chmod path plus the host-owned
switch; the async flow (apply / status / scopes + authRequestId
registry + PAT_SCOPE_AUTH_REQUIRED active-request branch) lands in a
follow-up stacked PR (A-ext).
Scope of A-core:
- Host-owned PAT trigger: auth.HostOwnsPATFlow() keyed exclusively on
DINGTALK_DWS_AGENTCODE (DINGTALK_AGENT / DWS_CHANNEL / claw-type do
NOT participate).
- claw-type: hard-wired to "openClaw" in pkg/edition/default.go
MergeHeaders hook, matching historical main behavior and decoupled
from DINGTALK_AGENT.
- dws pat chmod: factory-built (newChmodCommand) so the PAT subcommand
tree has no shared package-level caller.
- stderr JSON classifier: internal/errors/pat.go covers PAT_NO_PERMISSION
/ PAT_LOW_RISK / PAT_MEDIUM_RISK / PAT_HIGH_RISK / PAT_SCOPE_*
selectors and fills data.hostControl for host consumption.
- Env contract: DINGTALK_SESSION_ID / DINGTALK_TRACE_ID /
DINGTALK_MESSAGE_ID carry HTTP trace headers (Chain B);
DWS_SESSION_ID (+ REWIND_SESSION_ID alias) is the lone fallback for
`dws pat chmod --session-id` (Chain A). The two chains are
independent; aliases do not cross-pollinate.
- Docs: docs/pat/{README,contract,host-integration}.md + refreshed
docs/architecture.md "PAT Architecture" chapter + docs/reference.md
PAT section. error-catalog.md folded into contract.md §6.
- Tests: host-owned signal, stderr contract, classifier, chmod factory,
retry / poll loop for the CLI-owned chmod path.
Compatibility: no breaking changes on main. All non-PAT commands are
untouched.
Follow-up PRs (in order):
- codex/pat-ext : dws pat apply / status / scopes + AsyncRegistry.
- codex/pat-oss-refactor (aka PR B) : oauth_helpers / secure_store
refactors split out per code review.
Made-with: Cursor
* fix(pat): unify host-owned stderr contract
* revert: drop PAT docs and changelog from pat core PR
* fix(pat): pin behavior auth endpoint
* fix(pat): recognize legacy tool miss
* fix(pat): fallback on gateway diagnostics
* fix(pat): treat authorization uri as opaque
* fix(pat): accept result envelope in device-flow polling
* feat(pat): add browser policy and poll compatibility
* fix(pat): keep CLI and host PAT contracts separate
* test(pat): isolate opaque uri retry env
* fix(pat): harden PAT result routing
* fix(pat): harden host-owned flow and runtime fallback
* fix(pat): preserve chmod failure on empty grant result
* refactor(pat): drop dead doc anchors, dedupe stderr injection helpers
Address two reviewer findings on top of the PAT-core series.
Fix:
- chmod: resolveSessionIDFromEnv silently selects DWS_SESSION_ID; drop
the slog.Warn that emitted both raw session ids into stderr /
~/.dws/logs.
- Remove every docs/pat/contract.md / error-catalog.md /
host-integration.md anchor from comments, help, and tests; those
files were never added by this branch. Comments are now self-contained
or point to docs/reference.md where applicable.
Refactor:
- internal/errors/pat.go: extract lookupCodeIn so getPATErrorCode and
getDWSGatewayErrorCode share one traversal; have ClassifyPatAuthCheck
delegate to getPATErrorCode instead of repeating the enum walk.
- internal/errors/pat.go + internal/app/pat_auth_retry.go: extract
ApplyHostMutations as the single injection point for data.hostControl
+ data.openBrowser; cleanPATJSON and enrichPATErrorForHostControl now
share it so the two stderr-JSON write paths cannot drift.
- internal/app/pat_auth_retry.go: drop buildPATScopeHostJSON and
buildHostControlState dead 1-line wrappers.
- internal/pat/chmod.go: drop unused patApply/Status/Scopes constants;
derive legacyToolArgs from toolArgs by clone + scopes->scope rename so
the two payloads stay in lock-step on every other field.
Net 15 files, +176 / -193. Verification:
env -u DINGTALK_DWS_AGENTCODE go test \
./internal/errors ./internal/auth ./internal/pat ./test/unit
env -u DINGTALK_DWS_AGENTCODE go test ./internal/app \
-run 'Test(IsPat|ExtractPat|PrintPat|PollPat|HandlePat|RetryWithPat|EnrichPAT|BuildPAT|DirectRuntime|ResolveIdentityHeaders)'
env -u DINGTALK_DWS_AGENTCODE go test -race \
./internal/pat ./internal/errors ./test/unit
env -u DINGTALK_DWS_AGENTCODE go test -race ./internal/app \
-run 'Test(HandlePat|RetryWithPat|EnrichPAT|BuildPAT|ResolveIdentityHeaders|DirectRuntime)'
go vet ./... && go build ./...
All green.
Made-with: Cursor
---------
Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
417 lines
11 KiB
Go
417 lines
11 KiB
Go
// 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 errors
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
stderrors "errors"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
|
)
|
|
|
|
// Category represents a stable error class with a documented exit code.
|
|
type Category string
|
|
|
|
const (
|
|
CategoryAPI Category = "api"
|
|
CategoryAuth Category = "auth"
|
|
CategoryValidation Category = "validation"
|
|
CategoryDiscovery Category = "discovery"
|
|
CategoryInternal Category = "internal"
|
|
)
|
|
|
|
// Error is the structured repository-local error model for the Go rewrite.
|
|
type Error struct {
|
|
Category Category
|
|
Message string
|
|
Operation string
|
|
ServerKey string
|
|
Retryable bool
|
|
Reason string
|
|
Hint string
|
|
Actions []string
|
|
Snapshot string
|
|
RPCCode int `json:"rpc_code,omitempty"`
|
|
RPCData json.RawMessage `json:"rpc_data,omitempty"`
|
|
ServerDiag ServerDiagnostics `json:"-"`
|
|
Cause error `json:"-"`
|
|
}
|
|
|
|
func (e *Error) Error() string {
|
|
return e.Message
|
|
}
|
|
|
|
// Unwrap returns the underlying cause, enabling errors.Is and errors.As chains.
|
|
func (e *Error) Unwrap() error {
|
|
return e.Cause
|
|
}
|
|
|
|
// Option mutates a structured error before it is returned.
|
|
type Option func(*Error)
|
|
|
|
// ExitCode returns the documented process exit code for the error category.
|
|
// exit=4 is reserved exclusively for PATError (see internal/errors/pat.go
|
|
// ExitCodePermission and the exit-code table in docs/reference.md);
|
|
// Discovery therefore uses 6 so hosts can tell "catalog lookup broke"
|
|
// apart from "PAT permission insufficient".
|
|
func (e *Error) ExitCode() int {
|
|
switch e.Category {
|
|
case CategoryAPI:
|
|
return 1
|
|
case CategoryAuth:
|
|
return 2
|
|
case CategoryValidation:
|
|
return 3
|
|
case CategoryDiscovery:
|
|
return 6
|
|
default:
|
|
return 5
|
|
}
|
|
}
|
|
|
|
// WithOperation records the operation that failed.
|
|
func WithOperation(operation string) Option {
|
|
return func(err *Error) {
|
|
err.Operation = operation
|
|
}
|
|
}
|
|
|
|
// WithServerKey records the server identifier associated with the failure.
|
|
func WithServerKey(serverKey string) Option {
|
|
return func(err *Error) {
|
|
err.ServerKey = serverKey
|
|
}
|
|
}
|
|
|
|
// WithRetryable marks whether the error can be retried safely.
|
|
func WithRetryable(retryable bool) Option {
|
|
return func(err *Error) {
|
|
err.Retryable = retryable
|
|
}
|
|
}
|
|
|
|
// WithReason records a stable machine-readable failure reason.
|
|
func WithReason(reason string) Option {
|
|
return func(err *Error) {
|
|
err.Reason = reason
|
|
}
|
|
}
|
|
|
|
// WithHint records a short recovery hint for humans and agents.
|
|
func WithHint(hint string) Option {
|
|
return func(err *Error) {
|
|
err.Hint = hint
|
|
}
|
|
}
|
|
|
|
// WithActions records suggested next actions for recovery.
|
|
func WithActions(actions ...string) Option {
|
|
return func(err *Error) {
|
|
out := make([]string, 0, len(actions))
|
|
for _, action := range actions {
|
|
if action == "" {
|
|
continue
|
|
}
|
|
out = append(out, action)
|
|
}
|
|
if len(out) > 0 {
|
|
err.Actions = out
|
|
}
|
|
}
|
|
}
|
|
|
|
// WithSnapshot records the recovery snapshot path associated with the failure.
|
|
func WithSnapshot(path string) Option {
|
|
return func(err *Error) {
|
|
err.Snapshot = path
|
|
}
|
|
}
|
|
|
|
// WithRPCCode records the original JSON-RPC error code.
|
|
func WithRPCCode(code int) Option {
|
|
return func(err *Error) {
|
|
err.RPCCode = code
|
|
}
|
|
}
|
|
|
|
// WithRPCData records the original JSON-RPC error data payload.
|
|
func WithRPCData(data json.RawMessage) Option {
|
|
if len(bytes.TrimSpace(data)) == 0 {
|
|
return func(*Error) {} // no-op, consistent with other Options
|
|
}
|
|
return func(err *Error) {
|
|
err.RPCData = data
|
|
}
|
|
}
|
|
|
|
// WithCause wraps the original error so it can be retrieved via errors.Unwrap.
|
|
func WithCause(err error) Option {
|
|
return func(e *Error) {
|
|
e.Cause = err
|
|
}
|
|
}
|
|
|
|
func newError(category Category, message string, opts ...Option) error {
|
|
err := &Error{
|
|
Category: category,
|
|
Message: message,
|
|
}
|
|
for _, opt := range opts {
|
|
if opt != nil {
|
|
opt(err)
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
|
|
// NewAPI returns an API-category error.
|
|
func NewAPI(message string, opts ...Option) error {
|
|
return newError(CategoryAPI, message, opts...)
|
|
}
|
|
|
|
// NewAuth returns an auth-category error.
|
|
func NewAuth(message string, opts ...Option) error {
|
|
return newError(CategoryAuth, message, opts...)
|
|
}
|
|
|
|
// NewValidation returns a validation-category error.
|
|
func NewValidation(message string, opts ...Option) error {
|
|
return newError(CategoryValidation, message, opts...)
|
|
}
|
|
|
|
// NewDiscovery returns a discovery-category error.
|
|
func NewDiscovery(message string, opts ...Option) error {
|
|
return newError(CategoryDiscovery, message, opts...)
|
|
}
|
|
|
|
// NewInternal returns an internal-category error.
|
|
func NewInternal(message string, opts ...Option) error {
|
|
return newError(CategoryInternal, message, opts...)
|
|
}
|
|
|
|
// ExitCoder is implemented by errors that provide their own exit code.
|
|
// Edition-specific error types (e.g. PATError, CLIError) implement this
|
|
// so the framework can resolve exit codes without importing edition packages.
|
|
type ExitCoder interface {
|
|
ExitCode() int
|
|
}
|
|
|
|
// RawStderrError is implemented by errors that must output raw content
|
|
// directly to stderr, bypassing all CLI formatting (e.g. "Error:" prefix).
|
|
// PAT authorization errors use this to pass JSON through to the desktop runtime.
|
|
type RawStderrError interface {
|
|
error
|
|
RawStderr() string
|
|
}
|
|
|
|
// ExitCode maps any error to a stable exit code.
|
|
func ExitCode(err error) int {
|
|
var typed *Error
|
|
if stderrors.As(err, &typed) {
|
|
return typed.ExitCode()
|
|
}
|
|
var ec ExitCoder
|
|
if stderrors.As(err, &ec) {
|
|
return ec.ExitCode()
|
|
}
|
|
return 5
|
|
}
|
|
|
|
// PrintJSON writes a machine-readable JSON error object.
|
|
func PrintJSON(w io.Writer, err error) error {
|
|
errorPayload := map[string]any{
|
|
"code": ExitCode(err),
|
|
"category": category(err),
|
|
"message": err.Error(),
|
|
}
|
|
|
|
var typed *Error
|
|
if stderrors.As(err, &typed) {
|
|
if typed.Reason != "" {
|
|
errorPayload["reason"] = typed.Reason
|
|
}
|
|
if typed.Operation != "" {
|
|
errorPayload["operation"] = typed.Operation
|
|
}
|
|
if typed.ServerKey != "" {
|
|
errorPayload["server_key"] = typed.ServerKey
|
|
}
|
|
if typed.Retryable {
|
|
errorPayload["retryable"] = true
|
|
}
|
|
if typed.Hint != "" {
|
|
errorPayload["hint"] = typed.Hint
|
|
}
|
|
if len(typed.Actions) > 0 {
|
|
errorPayload["actions"] = typed.Actions
|
|
}
|
|
if typed.Snapshot != "" {
|
|
errorPayload["snapshot_path"] = typed.Snapshot
|
|
}
|
|
if typed.RPCCode != 0 {
|
|
errorPayload["rpc_code"] = typed.RPCCode
|
|
}
|
|
if len(typed.RPCData) > 0 {
|
|
var parsed any
|
|
if json.Unmarshal(typed.RPCData, &parsed) == nil {
|
|
errorPayload["rpc_data"] = parsed
|
|
}
|
|
}
|
|
if !typed.ServerDiag.IsEmpty() {
|
|
if typed.ServerDiag.TraceID != "" {
|
|
errorPayload["trace_id"] = typed.ServerDiag.TraceID
|
|
}
|
|
if typed.ServerDiag.ServerErrorCode != "" {
|
|
errorPayload["server_error_code"] = typed.ServerDiag.ServerErrorCode
|
|
// Add user-friendly hint for specific server error codes
|
|
switch typed.ServerDiag.ServerErrorCode {
|
|
case "TOKEN_VERIFIED_FAILED", "CLI_ORG_NOT_AUTHORIZED":
|
|
errorPayload["friendly_hint"] = "该组织尚未开启 CLI 数据访问权限,请联系组织主管理员开启。"
|
|
errorPayload["action_url"] = config.GetDeveloperSettingsURL()
|
|
}
|
|
}
|
|
if typed.ServerDiag.TechnicalDetail != "" {
|
|
errorPayload["technical_detail"] = typed.ServerDiag.TechnicalDetail
|
|
}
|
|
}
|
|
if typed.Cause != nil {
|
|
errorPayload["cause"] = typed.Cause.Error()
|
|
}
|
|
}
|
|
payload := map[string]any{"error": errorPayload}
|
|
|
|
data, marshalErr := json.MarshalIndent(payload, "", " ")
|
|
if marshalErr != nil {
|
|
_, writeErr := fmt.Fprintf(w, "{\"error\":{\"code\":5,\"category\":\"internal\",\"message\":\"failed to encode error output\"}}\n")
|
|
return writeErr
|
|
}
|
|
|
|
_, writeErr := fmt.Fprintln(w, string(data))
|
|
return writeErr
|
|
}
|
|
|
|
// Verbosity controls how much detail PrintHuman includes.
|
|
type Verbosity int
|
|
|
|
const (
|
|
// VerbosityNormal shows essential info: error, hint, actions, trace_id, server_code.
|
|
VerbosityNormal Verbosity = 0
|
|
// VerbosityVerbose adds technical_detail, snapshot, execution context.
|
|
VerbosityVerbose Verbosity = 1
|
|
// VerbosityDebug adds all internal diagnostics (category, operation, reason, rpc_code).
|
|
VerbosityDebug Verbosity = 2
|
|
)
|
|
|
|
// PrintHuman writes a concise human-readable error rendering at normal verbosity.
|
|
func PrintHuman(w io.Writer, err error) error {
|
|
return PrintHumanAt(w, err, VerbosityNormal)
|
|
}
|
|
|
|
// PrintHumanAt writes a human-readable error rendering at the given verbosity level.
|
|
func PrintHumanAt(w io.Writer, err error, v Verbosity) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
|
|
var typed *Error
|
|
if !stderrors.As(err, &typed) {
|
|
_, writeErr := fmt.Fprintf(w, "Error: %s\n", err.Error())
|
|
return writeErr
|
|
}
|
|
|
|
// Line 1: Error summary
|
|
lines := []string{
|
|
fmt.Sprintf("Error: [%s] %s", strings.ToUpper(string(typed.Category)), typed.Message),
|
|
}
|
|
|
|
// Always shown: hint, actions, retryable
|
|
if typed.Hint != "" {
|
|
lines = append(lines, fmt.Sprintf("Hint: %s", typed.Hint))
|
|
}
|
|
|
|
// Add user-friendly hint for specific server error codes
|
|
switch typed.ServerDiag.ServerErrorCode {
|
|
case "TOKEN_VERIFIED_FAILED", "CLI_ORG_NOT_AUTHORIZED":
|
|
lines = append(lines, "Hint: 该组织尚未开启 CLI 数据访问权限,请联系组织主管理员开启。")
|
|
lines = append(lines, "Action: 开启地址: "+config.GetDeveloperSettingsURL())
|
|
}
|
|
|
|
if len(typed.Actions) > 0 {
|
|
for _, action := range typed.Actions {
|
|
if strings.TrimSpace(action) == "" {
|
|
continue
|
|
}
|
|
lines = append(lines, fmt.Sprintf("Action: %s", action))
|
|
}
|
|
}
|
|
if typed.Retryable {
|
|
lines = append(lines, "Retryable: true")
|
|
}
|
|
|
|
// Always shown when present: Trace ID, Server Code
|
|
if typed.ServerDiag.TraceID != "" {
|
|
lines = append(lines, fmt.Sprintf("Trace ID: %s", typed.ServerDiag.TraceID))
|
|
}
|
|
if typed.ServerDiag.ServerErrorCode != "" {
|
|
lines = append(lines, fmt.Sprintf("Server Code: %s", typed.ServerDiag.ServerErrorCode))
|
|
}
|
|
|
|
// Verbose+: technical detail, snapshot, reason, server key
|
|
if v >= VerbosityVerbose {
|
|
if typed.ServerDiag.TechnicalDetail != "" {
|
|
lines = append(lines, fmt.Sprintf("Detail: %s", typed.ServerDiag.TechnicalDetail))
|
|
}
|
|
if typed.Reason != "" {
|
|
lines = append(lines, fmt.Sprintf("Reason: %s", typed.Reason))
|
|
}
|
|
if typed.ServerKey != "" {
|
|
lines = append(lines, fmt.Sprintf("Server: %s", typed.ServerKey))
|
|
}
|
|
if typed.Snapshot != "" {
|
|
lines = append(lines, fmt.Sprintf("Snapshot: %s", typed.Snapshot))
|
|
}
|
|
if typed.Cause != nil {
|
|
lines = append(lines, fmt.Sprintf("Cause: %s", typed.Cause.Error()))
|
|
}
|
|
}
|
|
|
|
// Debug: all internal diagnostics
|
|
if v >= VerbosityDebug {
|
|
if typed.Operation != "" {
|
|
lines = append(lines, fmt.Sprintf("Operation: %s", typed.Operation))
|
|
}
|
|
if typed.RPCCode != 0 {
|
|
lines = append(lines, fmt.Sprintf("RPC Code: %d", typed.RPCCode))
|
|
}
|
|
if len(typed.RPCData) > 0 {
|
|
lines = append(lines, fmt.Sprintf("RPC Data: %s", string(typed.RPCData)))
|
|
}
|
|
}
|
|
|
|
_, writeErr := fmt.Fprintln(w, strings.Join(lines, "\n"))
|
|
return writeErr
|
|
}
|
|
|
|
func category(err error) string {
|
|
var typed *Error
|
|
if stderrors.As(err, &typed) {
|
|
return string(typed.Category)
|
|
}
|
|
return string(CategoryInternal)
|
|
}
|