Files
xuanandshangguanxuan.sgx da2522c8ca feat(pat)!: A-core loop for host-owned PAT (#142)
* 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>
2026-04-28 21:17:23 +08:00

243 lines
6.1 KiB
Go

package errors
import (
"bytes"
"encoding/json"
stderrors "errors"
"fmt"
"strings"
"testing"
)
func TestError_Unwrap_NilCause(t *testing.T) {
t.Parallel()
e := &Error{Category: CategoryInternal, Message: "test"}
if e.Unwrap() != nil {
t.Fatal("expected nil cause")
}
}
func TestError_Unwrap_WithCause(t *testing.T) {
t.Parallel()
cause := fmt.Errorf("root cause")
e := &Error{Category: CategoryInternal, Message: "wrapper", Cause: cause}
if e.Unwrap() != cause {
t.Fatalf("expected cause, got %v", e.Unwrap())
}
}
func TestWithCause_Option(t *testing.T) {
t.Parallel()
cause := fmt.Errorf("underlying")
e := NewAPI("api error", WithCause(cause))
var appErr *Error
if !stderrors.As(e, &appErr) {
t.Fatal("expected *Error type")
}
if appErr.Cause != cause {
t.Fatal("missing cause")
}
}
func TestErrorsIs_ChainTraversal(t *testing.T) {
t.Parallel()
sentinel := fmt.Errorf("sentinel")
e := &Error{Category: CategoryAuth, Message: "auth failed", Cause: sentinel}
if !stderrors.Is(e, sentinel) {
t.Fatal("errors.Is should find sentinel through Unwrap")
}
}
func TestPrintJSON_WithCause(t *testing.T) {
t.Parallel()
cause := fmt.Errorf("db connection failed")
e := &Error{Category: CategoryInternal, Message: "save failed", Cause: cause}
var buf bytes.Buffer
PrintJSON(&buf, e)
if !strings.Contains(buf.String(), "db connection failed") {
t.Fatalf("expected cause in JSON output: %s", buf.String())
}
}
func TestPrintJSON_WithoutCause(t *testing.T) {
t.Parallel()
e := &Error{Category: CategoryAuth, Message: "unauthorized"}
var buf bytes.Buffer
PrintJSON(&buf, e)
if strings.Contains(buf.String(), `"cause"`) {
t.Fatalf("should not contain cause key: %s", buf.String())
}
}
func TestPrintJSON_AllFields(t *testing.T) {
t.Parallel()
rpcData := json.RawMessage(`{"detail":"oops"}`)
e := NewAPI("full error",
WithOperation("tools/call"),
WithReason("timeout"),
WithServerKey("doc"),
WithHint("retry later"),
WithRetryable(true),
WithActions("action1", "action2"),
WithSnapshot("/tmp/snap.json"),
WithRPCCode(-32600),
WithRPCData(rpcData),
WithCause(fmt.Errorf("root")),
)
var buf bytes.Buffer
PrintJSON(&buf, e)
out := buf.String()
for _, expected := range []string{"timeout", "tools/call", "doc", "retry later", "action1", "snap.json", "-32600", "oops", "root"} {
if !strings.Contains(out, expected) {
t.Fatalf("missing %q in output: %s", expected, out)
}
}
}
func TestPrintHuman_WithCause_Verbose(t *testing.T) {
t.Parallel()
cause := fmt.Errorf("timeout")
e := &Error{Category: CategoryDiscovery, Message: "discovery failed", Cause: cause}
var buf bytes.Buffer
PrintHumanAt(&buf, e, VerbosityVerbose)
if !strings.Contains(buf.String(), "timeout") {
t.Fatalf("expected cause in verbose human output: %s", buf.String())
}
}
func TestPrintHuman_WithCause_NormalHidesCause(t *testing.T) {
t.Parallel()
cause := fmt.Errorf("timeout")
e := &Error{Category: CategoryDiscovery, Message: "discovery failed", Cause: cause}
var buf bytes.Buffer
PrintHuman(&buf, e)
if strings.Contains(buf.String(), "Cause:") {
t.Fatalf("normal mode should not show Cause: %s", buf.String())
}
}
func TestPrintHuman_AllFields_Debug(t *testing.T) {
t.Parallel()
e := NewAPI("api error",
WithOperation("initialize"),
WithReason("connection_refused"),
WithServerKey("doc"),
WithHint("check network"),
WithRetryable(true),
WithActions("run again"),
WithSnapshot("/tmp/snap"),
WithRPCCode(-32601),
WithRPCData(json.RawMessage(`{"x":1}`)),
WithCause(fmt.Errorf("network")),
)
var buf bytes.Buffer
PrintHumanAt(&buf, e, VerbosityDebug)
out := buf.String()
for _, expected := range []string{"API", "initialize", "connection_refused", "doc", "check network", "run again", "snap", "-32601", "network", "Retryable"} {
if !strings.Contains(out, expected) {
t.Fatalf("missing %q in debug human output: %s", expected, out)
}
}
}
func TestPrintHuman_NilError(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
if err := PrintHuman(&buf, nil); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if buf.Len() != 0 {
t.Fatalf("expected empty output, got: %s", buf.String())
}
}
func TestPrintHuman_PlainError(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
PrintHuman(&buf, fmt.Errorf("plain error"))
if !strings.Contains(buf.String(), "plain error") {
t.Fatalf("expected plain error: %s", buf.String())
}
}
func TestPrintJSON_PlainError(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
PrintJSON(&buf, fmt.Errorf("plain error"))
if !strings.Contains(buf.String(), "plain error") {
t.Fatalf("expected plain error: %s", buf.String())
}
}
func TestExitCode_AllCategories(t *testing.T) {
t.Parallel()
tests := []struct {
cat Category
want int
}{
{CategoryAPI, 1},
{CategoryAuth, 2},
{CategoryValidation, 3},
{CategoryDiscovery, 6},
{CategoryInternal, 5},
}
for _, tt := range tests {
e := &Error{Category: tt.cat}
if got := e.ExitCode(); got != tt.want {
t.Errorf("ExitCode(%s) = %d, want %d", tt.cat, got, tt.want)
}
}
}
func TestExitCode_NonTyped(t *testing.T) {
t.Parallel()
if got := ExitCode(fmt.Errorf("plain")); got != 5 {
t.Fatalf("expected 5, got %d", got)
}
}
func TestWithRPCData_Empty(t *testing.T) {
t.Parallel()
opt := WithRPCData(json.RawMessage(""))
if opt == nil {
t.Fatal("expected non-nil no-op option for empty RPC data")
}
// Should not panic when applied
e := &Error{}
opt(e)
if len(e.RPCData) != 0 {
t.Fatal("empty RPC data option should be a no-op")
}
}
func TestWithActions_Empty(t *testing.T) {
t.Parallel()
e := NewAPI("test", WithActions("", "", ""))
var appErr *Error
stderrors.As(e, &appErr)
if len(appErr.Actions) != 0 {
t.Fatalf("expected no actions, got %v", appErr.Actions)
}
}
func TestSafePath_Valid(t *testing.T) {
t.Parallel()
if err := SafePath("/tmp/test.json"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSafePath_Traversal(t *testing.T) {
t.Parallel()
if err := SafePath("../../etc/passwd"); err == nil {
t.Fatal("expected error for path traversal")
}
}
func TestSafePath_Empty(t *testing.T) {
t.Parallel()
if err := SafePath(""); err == nil {
t.Fatal("expected error for empty path")
}
}