Files

904 lines
45 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 helpers
import (
"fmt"
"os"
"os/exec"
"strconv"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/spf13/cobra"
)
// devapp_connect wires the channel-aware "建联" (linking) capability into the
// dev command tree as `dws dev connect`. Provisioning (建号) is NOT
// duplicated here — it reuses the existing `dev app robot create/submit/result`.
// This file holds only the linking half: detect the current agent channel, then
// start a Go-native in-process Stream that forwards @-bot messages to the local
// agent CLI. The Stream forwarder internals live in connect_stream.go.
// connectChannels is the set of supported channels: the external ones plus every
// exec-type agent in agentSpecs (kept in sync automatically).
var connectChannels = func() map[string]struct{} {
m := map[string]struct{}{"openclaw": {}, "hermes": {}}
for ch := range agentSpecs {
m[ch] = struct{}{}
}
return m
}()
// These indirections keep the command's process/network boundaries testable.
// Production always uses the real implementations; unit tests replace only the
// final side-effecting step while exercising the full option/validation flow.
var (
devAppRunStreamConnector = runStreamConnector
devAppLoadConnectKnowledge = loadConnectKnowledgeSource
devAppConnectStdinInteractive = connectStdinInteractive
devAppRunConnectOnboarding = runConnectOnboarding
devAppStartConnectDaemon = startDaemon
)
// resolveConnectChannel resolves the current agent channel using "explicit wins,
// then signal fallback". Priority: --channel flag > DWS_AGENT_CHANNEL env var >
// each agent's known runtime signal. Returns the channel name and the basis for
// the decision (detectedBy, for troubleshooting).
//
// Signals (verified on real runtimes):
// - openclaw connector injects DINGTALK_AGENT=DING_DWS_CLAW.
// - WorkBuddy injects WORKBUDDY_CONFIG_DIR / WORKBUDDY_APP_NAME into spawned children.
// - QoderWork's qodercli injects QODERCLI_INTEGRATION_MODE=qoder_work (and neither QODER_CLI nor CLAUDECODE).
// - plain Qoder injects QODER_CLI=1 (it is a Claude Code fork, so also CLAUDECODE=1).
// - pure Claude Code injects only CLAUDECODE=1.
// - hermes uses the official channel, marked by HERMES_AGENT / HERMES.
func resolveConnectChannel(explicit string) (channel string, detectedBy string) {
if norm := strings.ToLower(strings.TrimSpace(explicit)); norm != "" && norm != "auto" {
return norm, "flag:--channel"
}
if v := strings.ToLower(strings.TrimSpace(os.Getenv("DWS_AGENT_CHANNEL"))); v != "" {
return v, "env:DWS_AGENT_CHANNEL"
}
// Signal fallback.
if strings.EqualFold(strings.TrimSpace(os.Getenv("DINGTALK_AGENT")), "DING_DWS_CLAW") {
return "openclaw", "signal:DINGTALK_AGENT"
}
if strings.TrimSpace(os.Getenv("OPENCLAW")) != "" || strings.TrimSpace(os.Getenv("OPENCLAW_GATEWAY")) != "" {
return "openclaw", "signal:OPENCLAW"
}
if strings.TrimSpace(os.Getenv("HERMES_AGENT")) != "" || strings.TrimSpace(os.Getenv("HERMES")) != "" {
return "hermes", "signal:HERMES"
}
// WorkBuddy(CodeBuddy) injects WORKBUDDY_CONFIG_DIR / WORKBUDDY_APP_NAME
// (verified, pointing at ~/.workbuddy) into the children it spawns. This is a
// WorkBuddy-specific runtime marker that does not leak globally, so dws can
// recognise the current host when WorkBuddy spawns it.
if strings.TrimSpace(os.Getenv("WORKBUDDY_CONFIG_DIR")) != "" || strings.TrimSpace(os.Getenv("WORKBUDDY_APP_NAME")) != "" {
return "workbuddy", "signal:WORKBUDDY_CONFIG_DIR"
}
// QoderWork's qodercli injects QODERCLI_INTEGRATION_MODE=qoder_work (verified)
// and carries neither QODER_CLI nor CLAUDECODE. Use it to split qoderwork out
// of the qoder family, avoiding "linking inside QoderWork but reaching Qoder".
// Must come before the QODER_CLI / CLAUDECODE checks below.
if strings.EqualFold(strings.TrimSpace(os.Getenv("QODERCLI_INTEGRATION_MODE")), "qoder_work") {
return "qoderwork", "signal:QODERCLI_INTEGRATION_MODE"
}
if strings.TrimSpace(os.Getenv("QODER_CLI")) != "" {
// Plain Qoder (AI coding IDE): carries QODER_CLI=1. Qoder is a Claude Code
// fork and also carries CLAUDECODE=1, so this check must precede CLAUDECODE
// below to avoid misdetecting it as claudecode.
return "qoder", "signal:QODER_CLI"
}
if strings.TrimSpace(os.Getenv("CLAUDECODE")) != "" {
// Pure Claude Code (not the qoder fork): only CLAUDECODE=1, no QODER_CLI.
return "claudecode", "signal:CLAUDECODE"
}
// Last resort: a self-built / unsupported AI tool that supplied its own
// command (via --agent-cmd, which sets DWS_AGENT_CMD, or DWS_AGENT_CMD
// directly) routes to the generic "custom" channel — so onboarding an
// unrecognised host needs no per-tool detection signal (issue #37).
if strings.TrimSpace(os.Getenv("DWS_AGENT_CMD")) != "" {
return "custom", "env:DWS_AGENT_CMD"
}
return "", "undetected"
}
// buildConnectPlan returns the linking plan that wires the bot to a channel's
// local agent. External channels (openclaw/hermes) have bespoke plans; every
// exec-type agent (agentSpecs) shares a generic Stream + headless-CLI plan. Used
// for the --dry-run preview.
func buildConnectPlan(channel, clientID, robotCode string) map[string]any {
switch channel {
case "openclaw":
return map[string]any{
"method": "openclaw-connector",
"summary": "走 dingtalk-openclaw-connector 官方建联,由连接器自己建号 + AI 卡片流式回复(dws 不代建机器人)",
"steps": []string{
"按 https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector 设备码扫码注册机器人",
"启动 openclaw gateway,由连接器处理消息收发与卡片渲染",
},
}
case "hermes":
return map[string]any{
"method": "official-channel",
"summary": "走 Hermes 官方 channel 建联,由 Hermes 自己建号 + 原生回复(dws 不代建机器人)",
"steps": []string{
"运行 `hermes gateway setup` → 选 DingTalk → QR Code Scan 扫码授权",
"`hermes gateway restart`,直接在钉钉里跟新机器人对话",
"回复打了 Done 表情却不显示/卡在'数据加载中':钉钉按 AI 助理应答窗口渲染回复,超窗的纯文本会被丢弃;回复慢的 agent 需在 hermes 侧启用 AI 卡片(config.yaml 配 platforms.dingtalk.extra.card_template_id),由卡片先占位再流式出字",
},
}
case "custom":
return map[string]any{
"method": "stream-bridge-custom",
"summary": "Go 原生 Stream 建联,转发到 --agent-cmd/DWS_AGENT_CMD 指定的自定义 AI CLI(无头/一次性:问题作为末参,stdout 作回复);用来接入未内置支持的或自研的 AI 工具",
"steps": []string{
"用 --agent-cmd \"<你的命令>\"(或 DWS_AGENT_CMD)指定 AI 工具的无头/一次性命令",
"用 clientId/clientSecret 起 Stream,注册 TOPIC_ROBOT 回调",
"收到消息 → 运行 <你的命令> \"用户问题\" → stdout 作为回复",
"经 sessionWebhook/AI 卡片把回复发回钉钉",
},
}
}
if spec, ok := agentSpecs[channel]; ok {
if channel == "codex" {
return map[string]any{
"method": "stream-bridge-codex-app-server",
"summary": "Go 原生 Stream 建联,转发到本地 Codex app-server 的 thread/turn 协议",
"steps": []string{
"自动定位 Codex CLI(PATH),启动 app-server",
"用 clientId/clientSecret 起 Stream,注册 TOPIC_ROBOT 回调",
"收到消息 → 按 conversationId 映射/恢复 Codex thread → turn/start 获取结构化增量与完成事件",
"经 AI 卡片或 sessionWebhook 把回复发回钉钉,并记录发送成功/失败",
},
}
}
if channel == "opencode" {
return map[string]any{
"method": "stream-bridge-opencode-server",
"summary": "Go 原生 Stream 建联,转发到本地 opencode serve 的 HTTP session/message 协议",
"steps": []string{
"自动定位 opencode CLI(PATH),启动 opencode serve --pure",
"用 clientId/clientSecret 起 Stream,注册 TOPIC_ROBOT 回调",
"收到消息 → 按 conversationId 映射/恢复 opencode session → /session/{id}/message 获取一次性回复",
"经 AI 卡片或 sessionWebhook 把回复发回钉钉,并记录发送成功/失败",
},
}
}
if channel == "qoder" || channel == "qoderwork" {
return map[string]any{
"method": "stream-bridge-qoder-stream",
"summary": fmt.Sprintf("Go 原生 Stream 建联,转发到本地 %s 的常驻 qodercli stream-json 子进程", spec.app),
"steps": []string{
"自动定位 qodercli(PATH 或 app 自带)",
"用 clientId/clientSecret 起 Stream,注册 TOPIC_ROBOT 回调",
"启动并复用 qodercli --print --output-format stream-json --input-format stream-json",
"收到消息 → 按 conversationId 传入 Qoder session_id → 读取 assistant/result JSON 作为回复",
"经 sessionWebhook/AI 卡片把回复发回钉钉,并记录发送成功/失败",
},
}
}
return map[string]any{
"method": "stream-bridge",
"summary": fmt.Sprintf("Go 原生 Stream 建联,转发到本地 %s 的无头 CLI(每条消息起一个新实例,可 7×24 无人值守)", spec.app),
"steps": []string{
"自动定位 agent CLI(DWS_AGENT_CMD > PATH > app 自带),缺包管理器装的会自动安装、装不了的提示安装",
"用 clientId/clientSecret 起 Stream,注册 TOPIC_ROBOT 回调",
"收到消息 → 调该 agent 的无头 CLI(如 claude -p / codebuddy -p)→ stdout 作为回复",
"经 sessionWebhook 把回复发回钉钉",
},
}
}
return map[string]any{"method": "unknown"}
}
// connectExternalCommand returns the connector command (argv) for channels that
// must be launched by an external process. Resolution priority: the
// DWS_CONNECT_CMD env var (space-separated, for customisation/testing, applies
// to all channels) > openclaw's built-in gateway. The stream-bridge channels
// (qoder/qoderwork/claudecode/workbuddy) use the Go-native in-process Stream
// (see connect_stream.go) and return no external command (nil). hermes uses the
// official channel and also has no built-in external command. Pure function,
// side-effect free, for easy unit testing.
func connectExternalCommand(channel string) []string {
if v := strings.TrimSpace(os.Getenv("DWS_CONNECT_CMD")); v != "" {
return strings.Fields(v)
}
switch channel {
case "openclaw":
// openclaw is taken over by the external connector: write credentials into
// openclaw.json, then restart the gateway.
return []string{"openclaw", "gateway", "restart"}
default:
// stream-bridge channels go Go-native; hermes etc. have no built-in command
// and need DWS_CONNECT_CMD.
return nil
}
}
// launchConnector wires the bot to the local agent per channel, running in the
// foreground until interrupted. Dispatch priority:
// 1. external connector (DWS_CONNECT_CMD override or openclaw gateway) → os/exec
// child, credentials injected via CID/SEC/DWS_AGENT_CHANNEL;
// 2. stream-bridge channels (qoder/qoderwork/claudecode/workbuddy) → Go-native
// in-process Stream + forwarder, no node/external-script dependency;
// 3. others (hermes etc.) → no built-in linking, advise DWS_CONNECT_CMD.
func launchConnector(cmd *cobra.Command, runner executor.Runner, channel, clientID, clientSecret string, opts connectAgentOptions) error {
if argv := connectExternalCommand(channel); len(argv) > 0 {
fmt.Fprintf(cmd.ErrOrStderr(), "[connect] channel=%s 启动外部连接器: %s\n", channel, strings.Join(argv, " "))
proc := exec.CommandContext(cmd.Context(), argv[0], argv[1:]...)
proc.Env = append(os.Environ(),
"CID="+clientID,
"SEC="+clientSecret,
"DWS_AGENT_CHANNEL="+channel,
)
proc.Stdout = cmd.OutOrStdout()
proc.Stderr = cmd.ErrOrStderr()
return proc.Run()
}
if isStreamBridgeChannel(channel) {
// Role config ("digital employee"): when supplied, validate it targets THIS
// bot and fold its owner / persona / knowledge into the options that were
// not set explicitly (an explicit flag always wins). Done first so the
// merged options feed the forwarder, knowledge loading and the gate below.
if opts.RoleConfigPath != "" {
role, rerr := LoadRoleConfig(opts.RoleConfigPath)
if rerr != nil {
return rerr
}
if role.ClientID != clientID {
return apperrors.NewValidation(fmt.Sprintf(
"角色配置 %q 绑定 client_id=%s,与本次连接的机器人 %s 不一致;一个角色对应一个机器人,请核对凭证或换用对应的角色配置",
opts.RoleConfigPath, role.ClientID, clientID))
}
opts = applyRoleConfig(opts, role)
fmt.Fprintf(cmd.ErrOrStderr(), "[connect] 角色已加载:%s(主人=%s|知识源 +%d|权限scope=[%s]|确认策略=%s)\n",
role.Name, opts.OwnerUserID, len(role.KnowledgeSources), strings.Join(role.AllowedScopes, ","), role.ConfirmPolicy)
}
fwd, err := forwarderForChannel(channel, clientID, opts)
if err != nil {
return err
}
var cardCli *aiCardClient
replyStyle := "text/markdown"
if opts.ReplyCard {
cardCli = newAICardClient(clientID, clientSecret, opts.CardTemplate)
if cardCli.hasTemplate() {
replyStyle = "ai-card(thinking→done, 失败回退普通消息)"
} else {
replyStyle = "text/markdown + thinking/done表态(配 --card-template 升级为卡片)"
}
}
extras := &connectExtras{persona: opts.Persona}
if opts.KnowledgeDir != "" {
kb, kerr := loadKnowledgeBase(opts.KnowledgeDir)
if kerr != nil {
return kerr
}
extras.kb = kb
fmt.Fprintf(cmd.ErrOrStderr(), "[connect] 知识库已加载:%d 个片段(%s)\n", len(kb.chunks), opts.KnowledgeDir)
}
if opts.KnowledgeSource != "" {
if kb, kerr := devAppLoadConnectKnowledge(cmd, runner, clientID, opts.KnowledgeSource); kerr != nil {
return kerr
} else if kb != nil {
extras.kb = kb
}
}
// Role-supplied knowledge sources are additive: load each and merge its
// chunks into the same retriever (knowledgeBase is just a chunk slice), so
// a role can carry several wiki/doc/dir sources without a flag per source.
for _, src := range opts.KnowledgeSources {
kb, kerr := devAppLoadConnectKnowledge(cmd, runner, clientID, src)
if kerr != nil {
return kerr
}
if kb == nil {
continue
}
if extras.kb == nil {
extras.kb = kb
} else {
extras.kb.chunks = append(extras.kb.chunks, kb.chunks...)
}
}
if len(opts.AllowedUsers) > 0 || len(opts.AllowedGroups) > 0 || opts.UserRateLimit > 0 {
extras.gate = newConnectGate(opts.AllowedUsers, opts.AllowedGroups, opts.UserRateLimit)
fmt.Fprintf(cmd.ErrOrStderr(), "[connect] 访问控制:用户白名单 %d、群白名单 %d、限流 %d 条/分钟/人\n",
len(opts.AllowedUsers), len(opts.AllowedGroups), opts.UserRateLimit)
}
// Confirmation gate ("digital twin"): when an owner + an interactive-card
// template are configured, action requests route to the owner for approval
// before executing. Needs a runner to run the approved command directly.
if opts.OwnerUserID != "" {
gate := newApprovalGate(clientID)
// Optional online-sheet audit: one row per action, reviewable in
// DingTalk. Runs under the connector (bot) identity via the runner.
var audit auditSink
if opts.AuditSheetNode != "" {
audit = &sheetAuditSink{runner: runner, nodeID: opts.AuditSheetNode, sheetID: opts.AuditSheetTab}
fmt.Fprintf(cmd.ErrOrStderr(), "[connect] 操作审计已开启:在线表格 node=%s tab=%s(每个操作追加一行)\n", opts.AuditSheetNode, opts.AuditSheetTab)
}
if len(opts.RoleScopes) > 0 {
fmt.Fprintf(cmd.ErrOrStderr(), "[connect] 角色能力边界:仅允许 [%s](其它产品的执行动作会被拒)\n", strings.Join(opts.RoleScopes, ", "))
}
if opts.ConfirmPolicy != "" && opts.ConfirmPolicy != "manual" {
fmt.Fprintf(cmd.ErrOrStderr(), "[connect] 确认策略:%s(manual=每次问 / auto=不问直接做仍留痕 / remember=同类记住上次)\n", opts.ConfirmPolicy)
}
if sender := newDingtalkApprovalCardSender(clientID, clientSecret, opts.ApprovalCardTemplate); sender != nil {
orch := newApprovalOrchestrator(gate, sender, runner, opts.OwnerUserID)
orch.audit = audit
orch.allowedScopes = opts.RoleScopes
orch.confirmPolicy = opts.ConfirmPolicy
extras.approval = orch
fmt.Fprintf(cmd.ErrOrStderr(), "[connect] 确认闸已开启:主人=%s(执行类请求先卡片审批,[同意]/[拒绝]按钮)\n", opts.OwnerUserID)
} else {
// No card template → fall back to text approval instead of disabling
// the gate: the bot privately DMs the owner, who confirms by replying
// 「同意」/「拒绝」 in their 1:1 chat — the requester never sees the
// approval. Works with zero card-platform setup. The notifier reuses
// the bot's own credentials (robot 1:1 send), so it reaches the owner
// in the bot's org.
notifier := newAICardClient(clientID, clientSecret, "")
orch := newTextApprovalOrchestrator(gate, runner, opts.OwnerUserID, notifier)
orch.audit = audit
orch.allowedScopes = opts.RoleScopes
orch.confirmPolicy = opts.ConfirmPolicy
extras.approval = orch
// Background auto-retry: a deferred backlog (e.g. queued while the
// dws login was not yet the owner) drains by itself once the identity
// is back — no manual "重试" needed. Silent unless something completes.
orch.startAutoRetry(cmd.Context(), 2*time.Minute)
fmt.Fprintf(cmd.ErrOrStderr(), "[connect] 确认闸已开启:主人=%s(文本审批:执行类请求私聊主人,主人回复「同意」/「拒绝」确认,请求人无感;积压请求每2分钟自动重试,也可回「重试」立即补做;配 --approval-card-template 可升级为卡片按钮)\n", opts.OwnerUserID)
}
}
// Quality hint (issue #39): the bot runs in a clean temp dir with no
// project knowledge by default, so it answers with less context than the
// same agent in your terminal. Surface the levers once, only when neither
// is set, so users discover why "终端答得对、机器人答不对".
if opts.WorkDir == "" && opts.KnowledgeDir == "" && opts.KnowledgeSource == "" && len(opts.KnowledgeSources) == 0 {
fmt.Fprintf(cmd.ErrOrStderr(), "[connect] 提示:机器人默认在空白临时目录里跑、不带你本地项目的上下文,回答可能不如终端准。要对齐终端:加 --agent-workdir <你的项目目录> 让它读到同样的文件,或 --knowledge-dir/--knowledge-source 挂资料。\n")
}
fmt.Fprintf(cmd.ErrOrStderr(), "[connect] channel=%s Go 原生 Stream 建联,转发到 %s,回复样式=%s(Ctrl-C 退出)\n", channel, fwd.label(), replyStyle)
return devAppRunStreamConnector(cmd.Context(), channel, clientID, clientSecret, fwd, cardCli, extras)
}
// hermes / openclaw run their own official bot provisioning + reply logic
// (device-flow QR registration, AI-card streaming). dws must not provision
// a robot for them or wrap them in the Stream bridge — their bots are a
// different type and only render through their native pipeline. Point the
// user at the official tool instead.
if guidance := officialChannelGuidance(channel); guidance != "" {
fmt.Fprint(cmd.ErrOrStderr(), guidance)
return nil
}
return apperrors.NewValidation(fmt.Sprintf("渠道 %q 暂无内置建联;用环境变量 DWS_CONNECT_CMD 指定要运行的连接器", channel))
}
// officialChannelGuidance returns onboarding instructions for channels that own
// their bot lifecycle (hermes, openclaw). dws guides the user to the official
// tool rather than provisioning a robot itself, because a dws-provisioned
// "智能体" robot does not render through these channels' native reply path.
func officialChannelGuidance(channel string) string {
switch channel {
case "hermes":
return "[connect] hermes 渠道走 Hermes 官方建联,dws 不代建机器人:\n" +
" 1. 运行 `hermes gateway setup` → 选 DingTalk → QR Code Scan,用钉钉扫码授权\n" +
" (设备码注册,自动把 client_id/secret 写入 ~/.hermes/.env)\n" +
" 2. `hermes gateway restart`,直接在钉钉里跟新机器人对话\n"
case "openclaw":
return "[connect] openclaw 渠道走 dingtalk-openclaw-connector 官方建联,dws 不代建机器人:\n" +
" 1. 按 https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector 的设备码扫码注册机器人\n" +
" 2. 启动 openclaw gateway,由连接器处理 AI 卡片流式回复\n"
}
return ""
}
const connectLocalDebugCompletionState = "LOCAL_DEBUG_ONLY"
func connectLocalDebugNotice() string {
return "[connect] 提示:本地调试,不代表线上发布完成;dev connect 只建立本地 Stream,不会提交版本发布。若机器人来自 APPROVAL_REQUIRED,仍需继续执行 version create → check-approval → publish → status。\n"
}
// connectPreviewEnvelope preserves the established streaming-command preview
// shape. `dev connect` remains legacy until a dedicated streaming contract is
// available; terminal child commands migrate independently.
func connectPreviewEnvelope(fields map[string]any) map[string]any {
fields["kind"] = "connect_preview"
fields["dry_run"] = true
fields["scope"] = "local_debug_only"
fields["doesNotPublish"] = true
fields["completionState"] = connectLocalDebugCompletionState
fields["terminal"] = false
fields["message"] = "本地建联预检只说明 Stream 调试可用,不代表线上发布完成"
return map[string]any{"invocation": fields}
}
// newDevAppRobotConnectCommand implements `dws dev connect`: the linking
// half of provisioning. It takes an existing robot's credentials — either
// directly via --client-id/--client-secret, or by --unified-app-id (reusing
// devapp 的 `get_dev_app_credentials` to fetch them) — and starts the
// channel-aware Stream connector in the foreground. It never provisions a robot;
// for 建号 run `dws dev app robot create` (or submit/result) first.
func newDevAppRobotConnectCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "connect",
Short: "建联:把现成机器人接到当前本地 agent(起 Stream,不建号)",
Long: "用已建好的机器人凭证把它接到当前本地 agent,不做建号。\n" +
"凭证两种来源:① 直接传 --robot-client-id/--robot-client-secret;② 传 --unified-app-id(复用 dev app credentials get 自动取凭证)。\n" +
"渠道由 --channel 显式指定,或运行时信号自动探测。\n" +
"缺凭证请先用 `dws dev app robot submit` 建号(随后 `robot result` 轮询)拿 clientId/clientSecret。",
Example: " dws dev connect --channel workbuddy --robot-client-id <id> --robot-client-secret <secret>\n" +
" dws dev connect --unified-app-id <ID> --channel qoderwork\n" +
" dws dev connect --channel claudecode --robot-client-id <id> --robot-client-secret <secret>\n" +
" dws dev connect --agent-cmd \"lobster -p\" --robot-client-id <id> --robot-client-secret <secret> # 自研/未支持的 AI 工具",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
// Daemon dispatch (see connect_daemon.go). --daemon-supervise runs the
// restart supervisor; --daemon (parent) re-execs into a detached
// supervisor; --daemon-worker falls through to the normal foreground
// connector below (it IS the connector, just spawned by the supervisor).
supervise, _ := cmd.Flags().GetBool(daemonSuperviseFlag)
if supervise {
return runSupervisor(cmd)
}
channelFlag := devAppStringFlag(cmd, "channel")
// --agent-cmd is sugar for the generic "custom" channel: it supplies the
// agent argv (via DWS_AGENT_CMD) so an unsupported / self-built AI tool can
// be onboarded without a detection signal (issue #37). The intent is
// unambiguous, so it forces channel=custom unless the user explicitly chose
// a different --channel (then we honour their choice but still pass the cmd).
if ac := strings.TrimSpace(devAppStringFlag(cmd, "agent-cmd")); ac != "" {
_ = os.Setenv("DWS_AGENT_CMD", ac)
if channelFlag == "" || strings.EqualFold(channelFlag, "auto") {
channelFlag = "custom"
}
}
channel, detectedBy := resolveConnectChannel(channelFlag)
if channel == "" {
return apperrors.NewValidation("无法探测 agent 渠道;请用 --channel 指定 (openclaw|qoder|qoderwork|hermes|workbuddy|claudecode|codebuddy|codex|gemini|opencode),或用 --agent-cmd \"<命令>\" 接入自研/未支持的 AI(custom 渠道),或设置 DWS_AGENT_CHANNEL")
}
if _, ok := connectChannels[channel]; !ok {
return apperrors.NewValidation(fmt.Sprintf("未知渠道 %q(支持 openclaw|qoder|qoderwork|hermes|workbuddy|claudecode|codebuddy|codex|gemini|opencode|custom)", channel))
}
clientID := devAppStringFlag(cmd, "robot-client-id")
clientSecret := devAppStringFlag(cmd, "robot-client-secret")
unifiedAppID := devAppStringFlag(cmd, "unified-app-id")
opts, err := connectAgentOptionsFromCommand(cmd)
if err != nil {
return err
}
daemonMode, _ := cmd.Flags().GetBool(daemonFlag)
// Credential resolution: explicit pair wins; otherwise reuse dev app's
// credentials get against --unified-app-id.
resolvedBy := "flag:--robot-client-id/--robot-client-secret"
if clientID == "" || clientSecret == "" {
switch {
case unifiedAppID != "":
if commandDryRun(cmd) {
// Dry-run must not call the credentials tool; just preview routing.
return writeCommandPayload(cmd, connectPreviewEnvelope(map[string]any{
"channel": channel,
"detectedBy": detectedBy,
"credentialSource": "unified-app-id (credentials get, skipped in dry-run)",
"unifiedAppId": unifiedAppID,
"agent": connectAgentOptionsPayload(channel, opts),
"cli": connectCliStatus(channel),
"connect": buildConnectPlan(channel, "", ""),
}))
}
id, secret, err := devAppFetchCredentials(runner, cmd, unifiedAppID)
if err != nil {
return err
}
if id == "" || secret == "" {
return apperrors.NewInternal("credentials get 未返回 clientId/clientSecret;clientSecret 可能仅建号时返回一次,请改用 --robot-client-id/--robot-client-secret 直接传入")
}
clientID, clientSecret = id, secret
resolvedBy = "unified-app-id:credentials get"
case !commandDryRun(cmd) && devAppConnectStdinInteractive():
// No credentials and a real terminal: guide the user through
// provisioning a new robot app or picking an existing one,
// instead of failing. Scripts/daemons/pipes and dry-run fall
// through to the explicit-flag requirement below.
creds, oerr := devAppRunConnectOnboarding(runner, cmd, cmd.InOrStdin(), cmd.OutOrStdout())
if oerr != nil {
return oerr
}
clientID, clientSecret = creds.clientID, creds.clientSecret
resolvedBy = creds.source
default:
return apperrors.NewValidation("需要 --robot-client-id/--robot-client-secret(用现成机器人凭证),或 --unified-app-id(复用 dev app credentials get 自动取凭证);或在交互终端直接运行 connect 进入建联引导")
}
}
// Security: if the caller pasted clientSecret onto argv, warn once —
// any user on the box can lift it with `ps -ef`. The safe path is
// --unified-app-id, which resolves the secret through
// `dev app credentials get` at runtime so it never touches argv.
// Emit before dry-run so both preview and real launch surface the
// warning; the warning is idempotent (one line per invocation).
if strings.HasPrefix(resolvedBy, "flag:") {
fmt.Fprintln(cmd.ErrOrStderr(), "[connect] WARNING: --robot-client-secret 出现在命令行,任何本机用户都能通过 `ps` 看到;建议改用 --unified-app-id <uappid>,由 dev app credentials get 后台取密钥。")
}
if commandDryRun(cmd) {
return writeCommandPayload(cmd, connectPreviewEnvelope(map[string]any{
"channel": channel,
"detectedBy": detectedBy,
"credentialSource": resolvedBy,
"clientId": clientID,
"agent": connectAgentOptionsPayload(channel, opts),
"cli": connectCliStatus(channel),
"connect": buildConnectPlan(channel, clientID, ""),
}))
}
// --daemon: detach into a background supervisor that keeps the
// connector alive 7x24. We resolve credentials/channel first (above) so
// the parent fails fast on bad input before forking, then re-exec.
if daemonMode {
notifyStaffID := devAppStringFlag(cmd, "notify-staff-id")
profile, _ := cmd.Root().PersistentFlags().GetString("profile")
alwaysOn, _ := cmd.Flags().GetBool("alwayson")
return devAppStartConnectDaemon(cmd, daemonDirKey(clientID, unifiedAppID), clientID, unifiedAppID, channel, notifyStaffID, strings.TrimSpace(profile), alwaysOn)
}
fmt.Fprintf(cmd.ErrOrStderr(), "[connect] channel=%s(%s)凭证来源=%s\n", channel, detectedBy, resolvedBy)
fmt.Fprint(cmd.ErrOrStderr(), connectLocalDebugNotice())
return launchConnector(cmd, runner, channel, clientID, clientSecret, opts)
},
}
preferLegacyLeaf(cmd)
// Daemon mode (see connect_daemon.go). --daemon detaches the connector into a
// self-restarting background supervisor; status/stop are sibling subcommands.
cmd.Flags().Bool(daemonFlag, false, "守护进程模式:把连接器放到后台运行(脱离终端),父进程打印 pid/日志路径后退出(Windows 暂不支持)")
cmd.Flags().Bool("alwayson", false, "常驻模式:worker 崩溃后自动重启(仅 --daemon 生效)")
// Internal re-exec mode flags, hidden from help.
cmd.Flags().Bool(daemonSuperviseFlag, false, "internal: run the daemon supervisor (set automatically by --daemon)")
cmd.Flags().Bool(daemonWorkerFlag, false, "internal: run a single supervised connector worker (set automatically by the supervisor)")
_ = cmd.Flags().MarkHidden(daemonSuperviseFlag)
_ = cmd.Flags().MarkHidden(daemonWorkerFlag)
cmd.AddCommand(
newDevAppRobotConnectStatusCommand(),
newDevAppRobotConnectStopCommand(),
newDevAppRobotConnectRestartCommand(),
newDevAppRobotConnectListCommand(runner),
)
newHybridGroupCommand(cmd)
cmd.Flags().String("channel", "auto", "渠道:auto(默认,自动探测)|openclaw|qoder|qoderwork|hermes|workbuddy|claudecode|codebuddy|codex|gemini|opencode|custom(自研/未支持的 AI,配 --agent-cmd)")
cmd.Flags().String("agent-cmd", "", "自研/未支持的 AI 工具命令(无头/一次性:问题作为最后一个参数追加,答案打到 stdout);用来接入内置渠道之外的 AI(如网易有道龙虾 LobsterAI);等价于 --channel custom + 设 DWS_AGENT_CMD;env: DWS_AGENT_CMD")
// 用 robot-client-* 而非 client-id/client-secret:后者是全局 OAuth 客户端覆盖
// 持久 flag(见 internal/app/flags.go),同名会 shadow 全局 flag。这里是要建联的
// 目标机器人凭证,与 OAuth 客户端是两码事,故独立命名避免撞名。
cmd.Flags().String("robot-client-id", "", "现成机器人 clientId(AppKey)")
cmd.Flags().String("robot-client-secret", "", "现成机器人 clientSecret(AppSecret)")
cmd.Flags().String("unified-app-id", "", "统一应用 ID:复用 dev app credentials get 自动取凭证(替代手填 robot-client-id/secret)")
cmd.Flags().String("agent-model", "", "覆盖 agent 模型(如 claude 的 sonnet/opus、gemini-2.5-pro;默认用渠道内置模型,求快);env: DWS_AGENT_MODEL")
cmd.Flags().String("agent-workdir", "", "本地 agent 的运行目录(放知识文件可给机器人上下文;默认空白临时目录,求快);env: DWS_AGENT_WORKDIR")
cmd.Flags().Bool("agent-memory", true, "按会话续聊:同一群/单聊共享 agent 会话上下文(codex/opencode/qoder/qoderwork/claudecode/codebuddy/workbuddy 支持;--agent-memory=false 关闭)")
cmd.Flags().Int("agent-timeout", 0, "每次 agent 调用的超时时间(秒),0=不限制(默认);env: DWS_AGENT_TIMEOUT_MS(毫秒)")
cmd.Flags().String("agent-permission-mode", "", "agent 权限模式:bypass(默认, 最高权限)|ask(需要确认/受限);env: DWS_AGENT_PERMISSION_MODE")
cmd.Flags().String("agent-approval-mode", "", "agent 审批模式:yolo(默认, 最高权限)|ask(需要确认/受限),兼容 Gemini/Codex 社区语义;env: DWS_AGENT_APPROVAL_MODE")
cmd.Flags().Bool("yolo", false, "最高权限模式短命令;等价于 --agent-permission-mode bypass / --agent-approval-mode yolo")
cmd.Flags().Bool("reply-card", true, "用 AI 卡片回复(思考中→完成状态,同官方渠道体验);卡片失败自动回退普通消息;--reply-card=false 关闭")
cmd.Flags().String("card-template", "", "AI 卡片模板 ID(开发者后台·本应用·AI 卡片设置里获取;模板按应用授权,强烈建议注册自己应用的模板);env: DWS_CARD_TEMPLATE")
cmd.Flags().String("knowledge-dir", "", "答疑知识目录(.md/.txt):每条消息本地检索 top-k 片段拼进 prompt,agent 仍在空目录跑、不拖慢回复;env: DWS_KNOWLEDGE_DIR")
cmd.Flags().String("knowledge-source", "", "答疑知识源:wiki:<spaceId> / doc:<docId> 从钉钉知识库拉取并缓存为本地知识(复用 dws doc 能力);裸值当作本地目录;与 --knowledge-dir 并存;env: DWS_KNOWLEDGE_SOURCE")
cmd.Flags().String("allowed-users", "", "用户白名单 staffId(逗号分隔),配置后仅名单内用户可触发;env: DWS_ALLOWED_USERS")
cmd.Flags().String("allowed-groups", "", "群白名单 openConversationId(逗号分隔),配置后仅名单内群可触发;env: DWS_ALLOWED_GROUPS")
cmd.Flags().Int("user-rate-limit", 20, "单用户每分钟消息上限(防刷;每条消息都是一次 LLM 调用),0 关闭;env: DWS_USER_RATE_LIMIT")
cmd.Flags().String("owner-user-id", "", "数字分身主人 staffId:开启确认闸后,执行类请求先发卡片给主人审批,同意才执行;env: DWS_OWNER_USER_ID")
cmd.Flags().String("approval-card-template", "", "确认闸交互卡片模板 ID(带[同意][拒绝]按钮);与 --owner-user-id 同时配置才开启确认闸;env: DWS_APPROVAL_CARD_TEMPLATE")
cmd.Flags().String("role-config", "", "数字员工角色配置 YAML:用角色的主人/人设/知识源填充未显式给出的选项(显式 flag 优先);role 的 client_id 必须与本机器人一致;env: DWS_ROLE_CONFIG")
cmd.Flags().String("audit-sheet", "", "审计在线表格 ID/URL(axls):确认闸每个操作追加一行到该表格,可在钉钉随时查看;空=仅本地审计文件;env: DWS_AUDIT_SHEET")
cmd.Flags().String("audit-sheet-tab", "Sheet1", "审计表格的工作表 ID/名称(配合 --audit-sheet);env: DWS_AUDIT_SHEET_TAB")
cmd.Flags().String("notify-staff-id", "", "状态通知 staffId:机器人启动/停止/崩溃时自动发钉钉消息通知此人;env: DWS_NOTIFY_STAFF_ID")
DeclareLeafMetadata(cmd, LeafSpec{
// Foreground connect is a long-lived stream and remains legacy until a
// dedicated streaming contract exists. Terminal child commands migrate
// independently.
OutputRollout: output.RolloutLegacyOnly,
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "dev", Name: "connect", CanonicalPath: "dev.connect",
CLIPath: "dev connect", PrimaryCLIPath: "dev connect",
},
Description: "把现有机器人连接到本地 agent;该命令同时承载前台流与 --daemon,整体暂留 legacy,终态子命令独立迁移",
Interface: &contract.InterfaceSpec{Mode: "composite", Availability: "available", Reason: "命令组合远端凭证获取、Stream 建连与本地守护进程管理,不对应单一 MCP 接口"},
Selection: contract.SelectionSpec{
AgentSummary: "把现有机器人连接到本地 agent 进行调试",
UseWhen: []string{"需要启动本地机器人 Stream 调试连接"},
AvoidWhen: []string{"创建或发布应用版本时使用 dev app"},
Examples: []string{"dws dev connect --daemon --unified-app-id <unifiedAppId>"},
},
},
})
return cmd
}
// connectAgentOptionsFromCommand reads the agent tuning flags, falling back to
// the mirrored env vars so connectors run from scripts/services can be
// configured without flags.
func connectAgentOptionsFromCommand(cmd *cobra.Command) (connectAgentOptions, error) {
model := devAppStringFlag(cmd, "agent-model")
if model == "" {
model = strings.TrimSpace(os.Getenv("DWS_AGENT_MODEL"))
}
workDir := devAppStringFlag(cmd, "agent-workdir")
if workDir == "" {
workDir = strings.TrimSpace(os.Getenv("DWS_AGENT_WORKDIR"))
}
memory, _ := cmd.Flags().GetBool("agent-memory")
agentTimeoutSec, _ := cmd.Flags().GetInt("agent-timeout")
yolo, err := resolveAgentYoloMode(cmd)
if err != nil {
return connectAgentOptions{}, err
}
replyCard, _ := cmd.Flags().GetBool("reply-card")
// Env kill-switch for scripted/service runs: DWS_REPLY_CARD=0 disables
// cards regardless of the flag default.
if v := strings.ToLower(strings.TrimSpace(os.Getenv("DWS_REPLY_CARD"))); v == "0" || v == "false" {
replyCard = false
}
cardTemplate := devAppStringFlag(cmd, "card-template")
if cardTemplate == "" {
cardTemplate = strings.TrimSpace(os.Getenv("DWS_CARD_TEMPLATE"))
}
// "public" opts into the openclaw shared template explicitly — best-effort
// only, since shared templates may not render for every app.
if strings.EqualFold(cardTemplate, "public") {
cardTemplate = defaultAICardTemplateID
}
knowledgeDir := devAppStringFlag(cmd, "knowledge-dir")
if knowledgeDir == "" {
knowledgeDir = strings.TrimSpace(os.Getenv("DWS_KNOWLEDGE_DIR"))
}
knowledgeSource := devAppStringFlag(cmd, "knowledge-source")
if knowledgeSource == "" {
knowledgeSource = strings.TrimSpace(os.Getenv("DWS_KNOWLEDGE_SOURCE"))
}
users := devAppStringFlag(cmd, "allowed-users")
if users == "" {
users = os.Getenv("DWS_ALLOWED_USERS")
}
groups := devAppStringFlag(cmd, "allowed-groups")
if groups == "" {
groups = os.Getenv("DWS_ALLOWED_GROUPS")
}
rateLimit, _ := cmd.Flags().GetInt("user-rate-limit")
if !cmd.Flags().Changed("user-rate-limit") {
if v := strings.TrimSpace(os.Getenv("DWS_USER_RATE_LIMIT")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
rateLimit = n
}
}
}
owner := devAppStringFlag(cmd, "owner-user-id")
if owner == "" {
owner = strings.TrimSpace(os.Getenv("DWS_OWNER_USER_ID"))
}
approvalTemplate := devAppStringFlag(cmd, "approval-card-template")
if approvalTemplate == "" {
approvalTemplate = strings.TrimSpace(os.Getenv("DWS_APPROVAL_CARD_TEMPLATE"))
}
roleConfig := devAppStringFlag(cmd, "role-config")
if roleConfig == "" {
roleConfig = strings.TrimSpace(os.Getenv("DWS_ROLE_CONFIG"))
}
auditSheet := devAppStringFlag(cmd, "audit-sheet")
if auditSheet == "" {
auditSheet = strings.TrimSpace(os.Getenv("DWS_AUDIT_SHEET"))
}
auditSheetTab := devAppStringFlag(cmd, "audit-sheet-tab")
if auditSheetTab == "" {
auditSheetTab = strings.TrimSpace(os.Getenv("DWS_AUDIT_SHEET_TAB"))
}
if auditSheetTab == "" {
auditSheetTab = "Sheet1"
}
return connectAgentOptions{Model: model, WorkDir: workDir, Memory: memory,
Timeout: time.Duration(agentTimeoutSec) * time.Second,
Yolo: yolo,
ReplyCard: replyCard, CardTemplate: cardTemplate,
KnowledgeDir: knowledgeDir,
KnowledgeSource: knowledgeSource,
AllowedUsers: splitCommaList(users), AllowedGroups: splitCommaList(groups),
UserRateLimit: rateLimit,
OwnerUserID: owner,
ApprovalCardTemplate: approvalTemplate,
RoleConfigPath: roleConfig,
AuditSheetNode: auditSheet,
AuditSheetTab: auditSheetTab}, nil
}
func resolveAgentYoloMode(cmd *cobra.Command) (bool, error) {
if v := strings.ToLower(strings.TrimSpace(devAppStringFlag(cmd, "agent-permission-mode"))); v != "" {
switch v {
case "ask":
return false, nil
case "bypass":
return true, nil
default:
return false, apperrors.NewValidation("--agent-permission-mode 仅支持 ask|bypass")
}
}
if v := strings.ToLower(strings.TrimSpace(os.Getenv("DWS_AGENT_PERMISSION_MODE"))); v != "" {
switch v {
case "ask":
return false, nil
case "bypass":
return true, nil
default:
return false, apperrors.NewValidation("DWS_AGENT_PERMISSION_MODE 仅支持 ask|bypass")
}
}
if v := strings.ToLower(strings.TrimSpace(devAppStringFlag(cmd, "agent-approval-mode"))); v != "" {
switch v {
case "ask":
return false, nil
case "yolo":
return true, nil
default:
return false, apperrors.NewValidation("--agent-approval-mode 仅支持 ask|yolo")
}
}
if v := strings.ToLower(strings.TrimSpace(os.Getenv("DWS_AGENT_APPROVAL_MODE"))); v != "" {
switch v {
case "ask":
return false, nil
case "yolo":
return true, nil
default:
return false, apperrors.NewValidation("DWS_AGENT_APPROVAL_MODE 仅支持 ask|yolo")
}
}
if yolo, _ := cmd.Flags().GetBool("yolo"); yolo {
return true, nil
}
return true, nil
}
// connectAgentOptionsPayload renders the effective agent tuning for the
// dry-run preview, including whether session memory actually applies to the
// chosen channel (Codex uses app-server threads, opencode uses opencode serve
// sessions, CLI session channels use --session-id/--resume, and Gemini API stays
// stateless today).
func connectAgentOptionsPayload(channel string, opts connectAgentOptions) map[string]any {
spec, ok := agentSpecs[channel]
memory := "unsupported"
if channel == "codex" {
if opts.Memory {
memory = "per-conversation-app-server"
} else {
memory = "disabled"
}
} else if channel == "opencode" {
if opts.Memory {
memory = "per-conversation-opencode-server"
} else {
memory = "disabled"
}
} else if channel == "qoder" || channel == "qoderwork" {
if opts.Memory {
memory = "per-conversation-qoder-stream"
} else {
memory = "disabled"
}
} else if ok && spec.ccSessions {
if opts.Memory {
memory = "per-conversation"
} else {
memory = "disabled"
}
}
model := opts.Model
if model == "" {
model = "(channel default)"
}
workDir := opts.WorkDir
if workDir == "" {
workDir = "(clean temp dir)"
}
replyStyle := "text/markdown"
if opts.ReplyCard {
if opts.CardTemplate != "" {
replyStyle = "ai-card"
} else {
replyStyle = "text/markdown + thinking/done表态(配 --card-template 升级为卡片)"
}
}
payload := map[string]any{"model": model, "workdir": workDir, "memory": memory, "replyStyle": replyStyle}
if opts.Yolo {
payload["yolo"] = "enabled"
}
return payload
}
// devAppFetchCredentials reuses dev app 的 get_dev_app_credentials tool to
// resolve a unified app's clientId/clientSecret, so `robot connect
// --unified-app-id` need not have the caller paste raw credentials. Note the
// open platform only returns clientSecret once (at provisioning); if the tool
// omits it the caller is told to fall back to explicit flags.
//
// TODO(verify): the clientId/clientSecret (and appKey/appSecret fallback) field
// names below are NOT yet confirmed against the real get_dev_app_credentials
// response — no fixture exists in-repo. Verify against the pre-prod gateway and
// pin the exact field names before relying on --unified-app-id auto-fetch. The
// path degrades safely today: an unrecognised shape yields empty strings and the
// caller is told to use --robot-client-id/--robot-client-secret instead.
func devAppFetchCredentials(runner executor.Runner, cmd *cobra.Command, unifiedAppID string) (clientID, clientSecret string, err error) {
invocation := executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd),
devAppProduct,
devAppCredentialsGetTool,
map[string]any{"unifiedAppId": unifiedAppID},
)
res, err := runner.Run(cmd.Context(), invocation)
if err != nil {
return "", "", err
}
payload := devAppConnectUnwrap(res.Response)
clientID = devAppConnectFirst(payload, "clientId", "appKey", "clientID")
clientSecret = devAppConnectFirst(payload, "clientSecret", "appSecret")
return clientID, clientSecret, nil
}
// devAppConnectUnwrap descends the executor/MCP envelope
// (Response{"content":{...,"result":{...}}}) to the innermost object, tolerating
// either wrapper being absent.
func devAppConnectUnwrap(resp map[string]any) map[string]any {
cur := resp
if cur == nil {
return nil
}
if inner, ok := cur["content"].(map[string]any); ok {
cur = inner
}
if inner, ok := cur["result"].(map[string]any); ok {
cur = inner
}
return cur
}
// devAppConnectFirst returns the first non-empty string value among the given
// keys, tolerating nil maps and non-string scalars.
func devAppConnectFirst(resp map[string]any, keys ...string) string {
if resp == nil {
return ""
}
for _, key := range keys {
if v, ok := resp[key].(string); ok {
if s := strings.TrimSpace(v); s != "" {
return s
}
}
}
return ""
}