diff --git a/go.mod b/go.mod index 391b1414..7555e86f 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.0 github.com/itchyny/gojq v0.12.18 + github.com/mattn/go-isatty v0.0.20 github.com/muesli/termenv v0.16.0 github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-0.20260705041131-325e7c1049ad github.com/spf13/cobra v1.10.2 @@ -40,7 +41,6 @@ require ( github.com/itchyny/timefmt-go v0.1.7 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect diff --git a/internal/cli/schema_agent_metadata/index.json b/internal/cli/schema_agent_metadata/index.json index d70f7811..a031d318 100644 --- a/internal/cli/schema_agent_metadata/index.json +++ b/internal/cli/schema_agent_metadata/index.json @@ -1,6 +1,6 @@ { "version": 1, - "source_hash": "sha256:60e91b326cc0cf87cb18ae218a0f12dce12ead46b58d418ea24bce61a74ff49f", + "source_hash": "sha256:bed6a59eebd2cab2366bbd5c49cd914f9dce805b3cd3032071ac442844360933", "surface_hash": "sha256:60eee8e2f37d6d9d60689efce85082798eb9ad38b7ba7c0b471c3de676a85a16", "coverage": { "surface_products": 26, diff --git a/internal/cli/schema_agent_metadata_audit.json b/internal/cli/schema_agent_metadata_audit.json index 569bc62b..f0795813 100644 --- a/internal/cli/schema_agent_metadata_audit.json +++ b/internal/cli/schema_agent_metadata_audit.json @@ -1,6 +1,6 @@ { "version": 1, - "source_hash": "sha256:60e91b326cc0cf87cb18ae218a0f12dce12ead46b58d418ea24bce61a74ff49f", + "source_hash": "sha256:bed6a59eebd2cab2366bbd5c49cd914f9dce805b3cd3032071ac442844360933", "surface_hash": "sha256:60eee8e2f37d6d9d60689efce85082798eb9ad38b7ba7c0b471c3de676a85a16", "source_files": 160, "hint_files": 54, diff --git a/internal/cli/schema_catalog/catalog.json b/internal/cli/schema_catalog/catalog.json index 18981a68..65e08489 100644 --- a/internal/cli/schema_catalog/catalog.json +++ b/internal/cli/schema_catalog/catalog.json @@ -1,12 +1,12 @@ { "version": 1, "surface_hash": "sha256:60eee8e2f37d6d9d60689efce85082798eb9ad38b7ba7c0b471c3de676a85a16", - "source_hash": "sha256:9c64df06c0f3204c70cf1f5e611ebaf0a6034835f6cb465d6a38d01b297322ce", + "source_hash": "sha256:13f34cab7af5a60212b6a0107cc7e42d570ebd72c4a1f3cf2c4082fd202074e3", "catalog": { "agent_metadata": { "products_with_metadata": 26, "source": "embedded-skill-metadata", - "source_hash": "sha256:60e91b326cc0cf87cb18ae218a0f12dce12ead46b58d418ea24bce61a74ff49f", + "source_hash": "sha256:bed6a59eebd2cab2366bbd5c49cd914f9dce805b3cd3032071ac442844360933", "surface_hash": "sha256:60eee8e2f37d6d9d60689efce85082798eb9ad38b7ba7c0b471c3de676a85a16", "surface_products": 26, "surface_tools": 845, diff --git a/internal/cmdcore/cmdcore.go b/internal/cmdcore/cmdcore.go index 9f000946..dbd65b2f 100644 --- a/internal/cmdcore/cmdcore.go +++ b/internal/cmdcore/cmdcore.go @@ -58,6 +58,7 @@ import ( "strconv" "strings" + "github.com/mattn/go-isatty" "github.com/spf13/cobra" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" @@ -847,8 +848,15 @@ func ConfirmRisk(cmd *cobra.Command, risk Risk) error { if risk.Effective() == RiskRead || BoolFlag(cmd, "dry-run") || BoolFlag(cmd, "yes") { return nil } - output := cmd.ErrOrStderr() - fmt.Fprintf(output, "即将执行 %s(%s),确认继续?(yes/no): ", cmd.CommandPath(), risk.Effective()) + // Only print the interactive prompt on a real terminal. In non-interactive + // environments (agent/CI: pipe, closed stdin, /dev/null) the prompt is + // noise on stderr ahead of the structured confirmation_required error — + // callers there must pass --yes/--dry-run instead of answering. A piped + // answer (printf 'yes\n' | cmd) is still honored: the read happens either + // way, only the prompt print is terminal-gated. + if stdinIsTerminal(cmd.InOrStdin()) { + fmt.Fprintf(cmd.ErrOrStderr(), "即将执行 %s(%s),确认继续?(yes/no): ", cmd.CommandPath(), risk.Effective()) + } reader := bufio.NewReader(cmd.InOrStdin()) answer, err := reader.ReadString('\n') if errors.Is(err, io.EOF) && strings.TrimSpace(answer) == "" { @@ -864,6 +872,19 @@ func ConfirmRisk(cmd *cobra.Command, risk Risk) error { return apperrors.NewValidation("用户取消了操作") } +// stdinIsTerminal reports whether the given input is a real terminal. Only +// *os.File inputs can be terminals; cobra SetIn buffers and other readers are +// treated as non-interactive. An ioctl-level TTY check is required — a plain +// character-device stat would misclassify redirects like `< /dev/null`. +func stdinIsTerminal(in io.Reader) bool { + file, ok := in.(*os.File) + if !ok { + return false + } + fd := file.Fd() + return isatty.IsTerminal(fd) || isatty.IsCygwinTerminal(fd) +} + func confirmationRequiredError(operation string) error { return apperrors.NewValidation( fmt.Sprintf("%s 是写操作,当前环境无法交互确认;加 --dry-run 预览,或确认后加 --yes 执行", operation), diff --git a/internal/cmdcore/cmdcore_test.go b/internal/cmdcore/cmdcore_test.go index 6d20ce13..174413d6 100644 --- a/internal/cmdcore/cmdcore_test.go +++ b/internal/cmdcore/cmdcore_test.go @@ -597,6 +597,36 @@ func TestCrossPlatformCoverageConfirmRisk(t *testing.T) { } } +func TestCrossPlatformCoverageConfirmRiskSuppressesPromptOffTerminal(t *testing.T) { + // Non-terminal stdin (buffer/pipe/EOF): the interactive prompt line must + // not pollute stderr — the structured error carries the semantics. Piped + // answers still confirm. + var stderr strings.Builder + cmd := newTestCommand() + cmd.PersistentFlags().Bool("yes", false, "") + cmd.PersistentFlags().Bool("dry-run", false, "") + cmd.SetIn(strings.NewReader("")) + cmd.SetErr(&stderr) + if err := ConfirmRisk(cmd, RiskWrite); err == nil { + t.Fatal("EOF must fail closed") + } + if got := stderr.String(); got != "" { + t.Fatalf("non-terminal stdin must not print a prompt, got %q", got) + } + + var answered strings.Builder + piped := newTestCommand() + piped.PersistentFlags().Bool("yes", false, "") + piped.SetIn(strings.NewReader("yes\n")) + piped.SetErr(&answered) + if err := ConfirmRisk(piped, RiskWrite); err != nil { + t.Fatalf("piped answer must confirm, got %v", err) + } + if got := answered.String(); got != "" { + t.Fatalf("piped answer path must also skip the prompt, got %q", got) + } +} + func TestCrossPlatformCoverageBoolFlag(t *testing.T) { if BoolFlag(nil, "yes") { t.Fatal("nil command must report false") diff --git a/internal/shortcut/runner.go b/internal/shortcut/runner.go index 7e9bb30e..06b783ef 100644 --- a/internal/shortcut/runner.go +++ b/internal/shortcut/runner.go @@ -19,6 +19,7 @@ import ( "errors" "fmt" "io" + "os" "strings" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" @@ -26,6 +27,7 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/mattn/go-isatty" "github.com/spf13/cobra" "github.com/spf13/pflag" ) @@ -476,7 +478,12 @@ func confirmRisk(rt *RuntimeContext, s Shortcut) (bool, error) { if s.risk() == RiskRead || rt.DryRun() || rt.Yes() { return true, nil } - fmt.Fprintf(rt.cmd.ErrOrStderr(), "即将执行 %s %s(%s),确认继续?(yes/no): ", s.Service, s.Command, s.risk()) + // Terminal-gated prompt, mirroring cmdcore.ConfirmRisk: non-interactive + // callers (agent/CI) get the structured confirmation_required error without + // a noise prompt line on stderr; piped answers are still honored. + if stdinIsTerminal(rt.cmd.InOrStdin()) { + fmt.Fprintf(rt.cmd.ErrOrStderr(), "即将执行 %s %s(%s),确认继续?(yes/no): ", s.Service, s.Command, s.risk()) + } reader := bufio.NewReader(rt.cmd.InOrStdin()) answer, err := reader.ReadString('\n') if errors.Is(err, io.EOF) && strings.TrimSpace(answer) == "" { @@ -489,6 +496,19 @@ func confirmRisk(rt *RuntimeContext, s Shortcut) (bool, error) { return answer == "yes" || answer == "y", nil } +// stdinIsTerminal reports whether the given input is a real terminal; cobra +// SetIn buffers and other non-file readers are treated as non-interactive. +// An ioctl-level TTY check is required — a plain character-device stat would +// misclassify redirects like `< /dev/null`. +func stdinIsTerminal(in io.Reader) bool { + file, ok := in.(*os.File) + if !ok { + return false + } + fd := file.Fd() + return isatty.IsTerminal(fd) || isatty.IsCygwinTerminal(fd) +} + func confirmationRequiredError(operation string) error { return apperrors.NewValidation( fmt.Sprintf("%s 是写操作,当前环境无法交互确认;加 --dry-run 预览,或确认后加 --yes 执行", operation), diff --git a/internal/shortcut/shortcut_test.go b/internal/shortcut/shortcut_test.go index ef57ea1c..e44117b4 100644 --- a/internal/shortcut/shortcut_test.go +++ b/internal/shortcut/shortcut_test.go @@ -74,8 +74,11 @@ func TestCrossPlatformCoverageConfirmRiskPromptsForStaticWrite(t *testing.T) { if ok, err := confirmRisk(rt, s); err != nil || !ok { t.Fatalf("static write risk was not confirmed: ok=%v err=%v", ok, err) } - if got := stderr.String(); !strings.Contains(got, "chat +messages-send(write)") { - t.Fatalf("confirmation prompt = %q", got) + // A pipe is not a terminal: the prompt must be suppressed (the structured + // error carries the semantics for non-interactive callers), while the + // piped answer is still honored. + if got := stderr.String(); got != "" { + t.Fatalf("non-terminal stdin must not print a prompt, got %q", got) } } diff --git a/skills/mono/SKILL.md b/skills/mono/SKILL.md index 92c326ab..756f1dd7 100644 --- a/skills/mono/SKILL.md +++ b/skills/mono/SKILL.md @@ -167,6 +167,25 @@ Step 2 → 用户明确回复确认(如 "确认" / "好的") Step 3 → 加 --yes 执行命令 ``` +### 确认门禁的识别与重试协议 + +非交互环境(Agent/CI,stdin 非 TTY)下,写命令不带 `--yes` 时 CLI **不打印交互提示语**,直接失败并输出结构化错误。识别方式: + +- `--format json` 输出(或 stderr)中 `error.reason == "confirmation_required"`,错误信息含「当前环境无法交互确认」 + +遇到 `confirmation_required` 时按以下协议处理: + +1. **不要当普通错误放弃**:把命令、风险等级(`write` / `high-risk-write`)和关键参数展示给用户,明确告知这是写/高风险操作 +2. 用户显式同意 → 在**原始命令**末尾追加 `--yes` 重试(不改动任何业务参数) +3. 用户拒绝 → 终止,不得改写参数绕过门禁 +4. 想先让用户 review 具体请求:加 `--dry-run` 重试——它**不触发确认门禁**,会输出完整调用预览(`invocation.params`),用户确认预览后再换 `--yes` 执行 + +**禁止**: + +- 看到 `confirmation_required` 就未经用户同意自动追加 `--yes` 静默重试(等于禁用门禁) +- 把 `confirmation_required` 当网络/权限错误处理或重试 +- 用 `echo yes | dws ...` 等管道方式喂答案代替 `--yes`(管道答案技术上会被接受,但违背了让用户显式知悉的设计意图) + ## 核心流程 作为一个智能助手,你的首要任务是**理解用户的真实、完整的意图**,而不是简单地执行命令。在选择 `dws` 的产品命令前,必须严格遵循以下四步流程: