// 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 --robot-client-secret \n" + " dws dev connect --unified-app-id --channel qoderwork\n" + " dws dev connect --channel claudecode --robot-client-id --robot-client-secret \n" + " dws dev connect --agent-cmd \"lobster -p\" --robot-client-id --robot-client-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 ,由 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), ) 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: / doc: 从钉钉知识库拉取并缓存为本地知识(复用 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 "}, }, }, }) 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 "" }