From 0999f389796b473d96d79bc8a9047bcee27d6255 Mon Sep 17 00:00:00 2001 From: Jared <865332676@qq.com> Date: Mon, 1 Jun 2026 14:19:15 +0800 Subject: [PATCH] feat: support openclaw feishu runtime Add OpenClaw runtime wiring and Feishu setup assets based on the PicoClaw runtime flow. --- cli/serve/serve.go | 11 +- docs/config.md | 2 +- docs/config.zh.md | 2 +- internal/agent/service_test.go | 10 + internal/api/feishu.go | 9 + internal/api/handler.go | 7 +- internal/api/handler_test.go | 41 +- internal/app/runtimewiring/openclaw.go | 35 ++ internal/app/runtimewiring/picoclaw.go | 116 +----- internal/app/runtimewiring/sandbox.go | 108 ++++++ internal/bot/service_test.go | 2 +- internal/channel/feishu/bus.go | 8 +- internal/channel/feishu/service.go | 8 +- internal/channel/feishu/service_test.go | 6 + internal/hub/builtin_store_test.go | 2 +- internal/onboard/detect.go | 2 +- internal/onboard/onboard.go | 2 +- internal/runtime/openclawsandbox/config.go | 85 ++++- .../runtime/openclawsandbox/config_test.go | 96 ++++- .../defaults/openclaw-gateway.json | 5 +- .../runtime/openclawsandbox/provision_test.go | 32 ++ internal/runtime/openclawsandbox/runtime.go | 11 +- .../runtime/openclawsandbox/runtime_test.go | 3 + .../runtime/picoclawsandbox/provision_test.go | 5 + internal/runtime/picoclawsandbox/runtime.go | 3 + internal/runtime/sandboxgateway/runtime.go | 5 +- internal/runtime/sandboxgateway/workspace.go | 12 + .../embed/openclaw-manager/agent.toml | 4 +- .../openclaw-manager/workspace/AGENTS.md | 4 + .../workspace/skills/basics/SKILL.md | 6 +- .../skills/basics/agents/openai.yaml | 2 +- .../workspace/skills/feishu/SKILL.md | 354 ++++++++++++++++++ .../skills/feishu/scripts/feishu_register.py | 10 + .../feishu/scripts/feishu_setup/__init__.py | 1 + .../feishu/scripts/feishu_setup/commands.py | 299 +++++++++++++++ .../feishu/scripts/feishu_setup/config.py | 30 ++ .../feishu/scripts/feishu_setup/csgclaw.py | 226 +++++++++++ .../scripts/feishu_setup/registration.py | 156 ++++++++ .../feishu/scripts/feishu_setup/state.py | 107 ++++++ .../scripts/tests/test_manager_action_card.py | 239 ++++++++++++ .../embed/openclaw-worker/agent.toml | 4 +- 41 files changed, 1909 insertions(+), 161 deletions(-) create mode 100644 internal/app/runtimewiring/openclaw.go create mode 100644 internal/app/runtimewiring/sandbox.go create mode 100644 internal/templates/embed/openclaw-manager/workspace/skills/feishu/SKILL.md create mode 100755 internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/feishu_register.py create mode 100644 internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/feishu_setup/__init__.py create mode 100644 internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/feishu_setup/commands.py create mode 100644 internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/feishu_setup/config.py create mode 100644 internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/feishu_setup/csgclaw.py create mode 100644 internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/feishu_setup/registration.py create mode 100644 internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/feishu_setup/state.py create mode 100644 internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/tests/test_manager_action_card.py diff --git a/cli/serve/serve.go b/cli/serve/serve.go index 9a65da23..696faca8 100644 --- a/cli/serve/serve.go +++ b/cli/serve/serve.go @@ -526,7 +526,14 @@ func configureFeishuService(feishuSvc *feishu.Service, svc *agent.Service) { if feishuSvc == nil { return } - runtimewiring.UpdatePicoClawFeishuProvider(svc, feishuSvc.ConfigProvider()) + update := func(feishuProvider feishu.BotCredentialProvider) { + runtimewiring.UpdatePicoClawFeishuProvider(svc, feishuProvider) + runtimewiring.UpdateOpenClawFeishuProvider(svc, feishuProvider) + } + update(feishuSvc.ConfigProvider()) + feishuSvc.SetConfigReloadHook(func(feishu.Snapshot) { + update(feishuSvc.ConfigProvider()) + }) } func preflightDefaultModelProvider(ctx context.Context, cfg config.Config) error { @@ -803,7 +810,7 @@ func newAgentService(cfg config.Config, feishuProvider feishu.BotCredentialProvi } opts = append(opts, runtimewiring.WithPicoClawSandboxRuntime(feishuProvider), - runtimewiring.WithOpenClawSandboxRuntime(), + runtimewiring.WithOpenClawSandboxRuntime(feishuProvider), runtimewiring.WithCodexRuntime(), agent.WithGatewayRuntime(bootstrapDefaults.ManagerRuntimeKind), agent.WithBootstrapDefaultTemplates(cfg.Bootstrap), diff --git a/docs/config.md b/docs/config.md index fb03e73a..78ef9b5c 100644 --- a/docs/config.md +++ b/docs/config.md @@ -137,7 +137,7 @@ CSGClaw defaults to PicoClaw for the bootstrap manager. To create a sandboxed Op csgclaw agent create --name alice --runtime openclaw_sandbox ``` -The recommended image shape is a slim OpenClaw base image with the CSGClaw channel plugin baked under `/home/node/openclaw-plugins/csgclaw-extension`. Runtime state still comes from `~/.csgclaw/agents//.openclaw/openclaw.json`; do not mount an empty host directory over `/home/node/openclaw-plugins`, because that hides baked plugins. +The recommended image shape is a slim OpenClaw base image with CSGClaw-managed plugins baked under `/home/node/openclaw-plugins` (for example, `csgclaw-extension` and external channel plugins). Runtime state still comes from `~/.csgclaw/agents//.openclaw/openclaw.json`; do not mount an empty host directory over `/home/node/openclaw-plugins`, because that hides baked plugins. ## Sandbox Providers diff --git a/docs/config.zh.md b/docs/config.zh.md index e5d6e088..58b2d3c7 100644 --- a/docs/config.zh.md +++ b/docs/config.zh.md @@ -137,7 +137,7 @@ CSGClaw 的 bootstrap manager 默认使用 PicoClaw。若要创建 sandbox 中 csgclaw agent create --name alice --runtime openclaw_sandbox ``` -推荐镜像形态是基于 OpenClaw slim 二次封装,并把 CSGClaw channel plugin 烘焙到 `/home/node/openclaw-plugins/csgclaw-extension`。运行时状态仍由 `~/.csgclaw/agents//.openclaw/openclaw.json` 提供;不要把空的宿主机目录挂载到 `/home/node/openclaw-plugins`,否则会遮住镜像内已经烘焙好的插件。 +推荐镜像形态是基于 OpenClaw slim 二次封装,并把 CSGClaw 管理的插件烘焙到 `/home/node/openclaw-plugins`(例如 `csgclaw-extension` 和外部 channel 插件)。运行时状态仍由 `~/.csgclaw/agents//.openclaw/openclaw.json` 提供;不要把空的宿主机目录挂载到 `/home/node/openclaw-plugins`,否则会遮住镜像内已经烘焙好的插件。 ## Sandbox Provider diff --git a/internal/agent/service_test.go b/internal/agent/service_test.go index 92a9e7e5..e547a9d9 100644 --- a/internal/agent/service_test.go +++ b/internal/agent/service_test.go @@ -299,6 +299,16 @@ func (r *agentBoxliteCLIRunner) Run(_ context.Context, req boxlitecli.CommandReq r.boxes[box.ID] = box r.boxes[box.Name] = box return boxlitecli.CommandResult{}, nil + case "stop": + idOrName := req.Args[len(req.Args)-1] + box, ok := r.boxes[idOrName] + if !ok { + return boxlitecli.CommandResult{ExitCode: 1, Stderr: []byte("Error: no such box: " + idOrName)}, fmt.Errorf("exit status 1") + } + box.Status = "stopped" + r.boxes[box.ID] = box + r.boxes[box.Name] = box + return boxlitecli.CommandResult{}, nil case "exec": if len(req.Args) > 6 && req.Args[5] == "tail" && req.Stdout != nil { _, _ = req.Stdout.Write([]byte("gateway line\n")) diff --git a/internal/api/feishu.go b/internal/api/feishu.go index 41706ea2..a61a4e9f 100644 --- a/internal/api/feishu.go +++ b/internal/api/feishu.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "strings" + "time" "csgclaw/internal/apitypes" "csgclaw/internal/channel/feishu" @@ -55,10 +56,18 @@ func (h *Handler) handleFeishuEvents(w http.ResponseWriter, r *http.Request, bot _, _ = io.WriteString(w, ": connected\n\n") flusher.Flush() + ticker := time.NewTicker(sseHeartbeatInterval) + defer ticker.Stop() + for { select { case <-r.Context().Done(): return + case <-ticker.C: + if _, err := io.WriteString(w, ": ping\n\n"); err != nil { + return + } + flusher.Flush() case evt, ok := <-events: if !ok { return diff --git a/internal/api/handler.go b/internal/api/handler.go index a67af8f9..fd54e16d 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -52,10 +52,9 @@ type Handler struct { activityDecider ActivityDecider } -const ( - createOperationTimeout = 10 * time.Minute - sseHeartbeatInterval = 15 * time.Second -) +const createOperationTimeout = 10 * time.Minute + +var sseHeartbeatInterval = 15 * time.Second func detachedCreateContext(ctx context.Context) (context.Context, context.CancelFunc) { if ctx == nil { diff --git a/internal/api/handler_test.go b/internal/api/handler_test.go index b285cf55..2c0df7f3 100644 --- a/internal/api/handler_test.go +++ b/internal/api/handler_test.go @@ -43,7 +43,7 @@ func init() { if err := runtimewiring.WithPicoClawSandboxRuntime(nil)(s); err != nil { return err } - return runtimewiring.WithOpenClawSandboxRuntime()(s) + return runtimewiring.WithOpenClawSandboxRuntime(nil)(s) }) _ = agent.TestOnlySetResponsesAPIProbe(func(context.Context, string, string, string, map[string]string) error { return nil @@ -3006,8 +3006,10 @@ func TestHandleFeishuEventsStreamsMessageBusEvents(t *testing.T) { }, }) feishuSvc.MessageBus().Publish(feishu.MessageEvent{ - Type: feishu.MessageEventTypeMessageCreated, - RoomID: "oc_alpha", + Type: feishu.MessageEventTypeMessageCreated, + RoomID: "oc_alpha", + SenderBotID: "u-worker", + MentionBotID: "u-manager", Message: &im.Message{ ID: "om_1", SenderID: "ou_manager", @@ -3026,6 +3028,9 @@ func TestHandleFeishuEventsStreamsMessageBusEvents(t *testing.T) { if !strings.Contains(body, `"room_id":"oc_alpha"`) { t.Fatalf("body = %q, want room_id", body) } + if !strings.Contains(body, `"sender_bot_id":"u-worker"`) || !strings.Contains(body, `"mention_bot_id":"u-manager"`) { + t.Fatalf("body = %q, want bot id bridge metadata", body) + } if strings.Contains(body, "om_ignored") || strings.Contains(body, "oc_ignored") { t.Fatalf("body = %q, want only u-manager events", body) } @@ -3034,6 +3039,36 @@ func TestHandleFeishuEventsStreamsMessageBusEvents(t *testing.T) { } } +func TestHandleFeishuEventsSendsHeartbeat(t *testing.T) { + oldInterval := sseHeartbeatInterval + sseHeartbeatInterval = 5 * time.Millisecond + t.Cleanup(func() { + sseHeartbeatInterval = oldInterval + }) + + feishuSvc := feishu.NewService() + srv := &Handler{feishu: feishuSvc, serverAccessToken: "secret"} + + ctx, cancel := context.WithCancel(context.Background()) + req := httptest.NewRequest(http.MethodGet, "/api/v1/channels/feishu/bots/u-manager/events", nil).WithContext(ctx) + req.Header.Set("Authorization", "Bearer secret") + rec := httptest.NewRecorder() + + done := make(chan struct{}) + go func() { + srv.Routes().ServeHTTP(rec, req) + close(done) + }() + + time.Sleep(20 * time.Millisecond) + cancel() + <-done + + if body := rec.Body.String(); !strings.Contains(body, ": ping\n\n") { + t.Fatalf("body = %q, want heartbeat ping", body) + } +} + func TestHandleFeishuEventsRequiresAuthorization(t *testing.T) { srv := &Handler{ feishu: feishu.NewService(), diff --git a/internal/app/runtimewiring/openclaw.go b/internal/app/runtimewiring/openclaw.go new file mode 100644 index 00000000..aff8a33e --- /dev/null +++ b/internal/app/runtimewiring/openclaw.go @@ -0,0 +1,35 @@ +package runtimewiring + +import ( + "fmt" + + "csgclaw/internal/agent" + "csgclaw/internal/channel/feishu" + agentruntime "csgclaw/internal/runtime" + "csgclaw/internal/runtime/openclawsandbox" + "csgclaw/internal/runtime/sandboxgateway" +) + +func WithOpenClawSandboxRuntime(feishuProvider feishu.BotCredentialProvider) agent.ServiceOption { + return func(s *agent.Service) error { + if s == nil { + return fmt.Errorf("agent service is required") + } + host := s.OpenClawRuntimeHost() + return withSandboxRuntimeHost(host, feishuProvider, openClawBoxEnvVars, func(deps sandboxgateway.Dependencies) agentruntime.Runtime { + return openclawsandbox.New(deps) + })(s) + } +} + +func UpdateOpenClawFeishuProvider(svc *agent.Service, provider feishu.BotCredentialProvider) { + updateRuntimeFeishuProvider(svc, agentruntime.KindOpenClawSandbox, provider) +} + +func openClawBoxEnvVars(baseURL, accessToken, botID, llmBaseURL, modelID string, _ feishu.BotCredentialProvider) map[string]string { + env := bridgeLLMEnvVars(llmBaseURL, accessToken, modelID) + env["CSGCLAW_BASE_URL"] = baseURL + env["CSGCLAW_ACCESS_TOKEN"] = accessToken + env["CSGCLAW_BOT_ID"] = botID + return env +} diff --git a/internal/app/runtimewiring/picoclaw.go b/internal/app/runtimewiring/picoclaw.go index a37cb886..416b72c4 100644 --- a/internal/app/runtimewiring/picoclaw.go +++ b/internal/app/runtimewiring/picoclaw.go @@ -1,18 +1,14 @@ package runtimewiring import ( - "context" "fmt" - "log/slog" "strings" "csgclaw/internal/agent" "csgclaw/internal/channel/feishu" agentruntime "csgclaw/internal/runtime" - "csgclaw/internal/runtime/openclawsandbox" "csgclaw/internal/runtime/picoclawsandbox" "csgclaw/internal/runtime/sandboxgateway" - "csgclaw/internal/sandbox" ) func WithPicoClawSandboxRuntime(feishuProvider feishu.BotCredentialProvider) agent.ServiceOption { @@ -21,92 +17,17 @@ func WithPicoClawSandboxRuntime(feishuProvider feishu.BotCredentialProvider) age return fmt.Errorf("agent service is required") } host := s.PicoClawRuntimeHost() - return withSandboxRuntimeHost(host, feishuProvider, func(deps sandboxgateway.Dependencies) agentruntime.Runtime { + return withSandboxRuntimeHost(host, feishuProvider, picoClawRuntimeEnvVars, func(deps sandboxgateway.Dependencies) agentruntime.Runtime { return picoclawsandbox.New(deps) })(s) } } -func WithOpenClawSandboxRuntime() agent.ServiceOption { - return func(s *agent.Service) error { - if s == nil { - return fmt.Errorf("agent service is required") - } - host := s.OpenClawRuntimeHost() - return withSandboxRuntimeHost(host, nil, func(deps sandboxgateway.Dependencies) agentruntime.Runtime { - return openclawsandbox.New(deps) - })(s) - } -} - -func withSandboxRuntimeHost(host agent.PicoClawRuntimeHost, feishuProvider feishu.BotCredentialProvider, newRuntime func(sandboxgateway.Dependencies) agentruntime.Runtime) agent.ServiceOption { - return func(s *agent.Service) error { - return agent.WithRuntime(newRuntime(sandboxgateway.Dependencies{ - FeishuProvider: feishuProvider, - SandboxProviderName: host.SandboxProviderName, - EnsureRuntime: host.EnsureRuntime, - RuntimeHome: host.RuntimeHome, - CloseRuntime: host.CloseRuntime, - ResolveBox: func(ctx context.Context, rt sandbox.Runtime, got sandboxgateway.AgentRef) (sandbox.Instance, string, error) { - return host.ResolveBox(ctx, rt, agent.Agent{ - ID: got.ID, - Name: got.Name, - RuntimeID: got.RuntimeID, - BoxID: got.BoxID, - }) - }, - CreateBox: host.CreateBox, - StartBox: host.StartBox, - StopBox: host.StopBox, - BoxInfo: host.BoxInfo, - ForceRemoveBox: host.ForceRemoveBox, - CloseBox: host.CloseBox, - RunBoxCommand: host.RunBoxCommand, - ResolveAgent: func(h agentruntime.Handle) (sandboxgateway.AgentRef, error) { - got, err := host.ResolveAgent(h) - if err != nil { - return sandboxgateway.AgentRef{}, err - } - return sandboxgateway.AgentRef{ - ID: got.ID, - Name: got.Name, - RuntimeID: strings.TrimSpace(got.RuntimeID), - BoxID: got.BoxID, - }, nil - }, - SyncHandle: host.SyncHandle, - BuildRuntimeEnv: func(baseURL, accessToken, botID, llmBaseURL, modelID string, provider feishu.BotCredentialProvider) map[string]string { - env := picoClawBoxEnvVars(baseURL, accessToken, botID, llmBaseURL, modelID) - addFeishuBoxEnvVars(env, botID, provider) - return env - }, - AddProfileEnv: agentAddProfileEnv, - StreamLogs: host.StreamLogs, - }))(s) - } -} - func UpdatePicoClawFeishuProvider(svc *agent.Service, provider feishu.BotCredentialProvider) { - if svc == nil { - slog.Warn("skip picoclaw feishu provider update: agent service is nil") - return - } - rt, err := svc.Runtime(agentruntime.KindPicoClawSandbox) - if err != nil { - slog.Warn("skip picoclaw feishu provider update: runtime not available", "runtime_kind", agentruntime.KindPicoClawSandbox, "error", err) - return - } - updater, ok := rt.(interface { - SetFeishuProvider(feishu.BotCredentialProvider) - }) - if !ok { - slog.Warn("skip picoclaw feishu provider update: runtime does not support provider updates", "runtime_kind", rt.Kind()) - return - } - updater.SetFeishuProvider(provider) + updateRuntimeFeishuProvider(svc, agentruntime.KindPicoClawSandbox, provider) } -func picoClawBoxEnvVars(baseURL, accessToken, botID, llmBaseURL, modelID string) map[string]string { +func picoClawRuntimeEnvVars(baseURL, accessToken, botID, llmBaseURL, modelID string, provider feishu.BotCredentialProvider) map[string]string { env := bridgeLLMEnvVars(llmBaseURL, accessToken, modelID) picoclawModelID := picoclawBridgeModelID(modelID) env["CSGCLAW_BASE_URL"] = baseURL @@ -119,6 +40,7 @@ func picoClawBoxEnvVars(baseURL, accessToken, botID, llmBaseURL, modelID string) env["PICOCLAW_CUSTOM_MODEL_ID"] = picoclawModelID env["PICOCLAW_CUSTOM_MODEL_API_KEY"] = accessToken env["PICOCLAW_CUSTOM_MODEL_BASE_URL"] = llmBaseURL + addFeishuBoxEnvVars(env, botID, provider) return env } @@ -138,28 +60,6 @@ func addFeishuBoxEnvVars(envVars map[string]string, botID string, provider feish envVars["PICOCLAW_CHANNELS_FEISHU_APP_SECRET"] = app.AppSecret } -func agentAddProfileEnv(envVars map[string]string, profileEnv map[string]string) { - for key, value := range profileEnv { - key = strings.TrimSpace(key) - value = strings.TrimSpace(value) - if key == "" || value == "" || isReservedSandboxEnvKey(key) { - continue - } - envVars[key] = value - } -} - -func bridgeLLMEnvVars(llmBaseURL, accessToken, modelID string) map[string]string { - return map[string]string{ - "CSGCLAW_LLM_BASE_URL": llmBaseURL, - "CSGCLAW_LLM_API_KEY": accessToken, - "CSGCLAW_LLM_MODEL_ID": modelID, - "OPENAI_BASE_URL": llmBaseURL, - "OPENAI_API_KEY": accessToken, - "OPENAI_MODEL": modelID, - } -} - func picoclawBridgeModelID(modelID string) string { modelID = strings.TrimSpace(modelID) if modelID == "" { @@ -173,11 +73,3 @@ func picoclawBridgeModelID(modelID string) string { } return "openai/" + modelID } - -func isReservedSandboxEnvKey(key string) bool { - upper := strings.ToUpper(strings.TrimSpace(key)) - if upper == "HOME" || upper == "OPENAI_BASE_URL" || upper == "OPENAI_API_KEY" || upper == "OPENAI_MODEL" { - return true - } - return strings.HasPrefix(upper, "CSGCLAW_") || strings.HasPrefix(upper, "PICOCLAW_") -} diff --git a/internal/app/runtimewiring/sandbox.go b/internal/app/runtimewiring/sandbox.go new file mode 100644 index 00000000..13a0c670 --- /dev/null +++ b/internal/app/runtimewiring/sandbox.go @@ -0,0 +1,108 @@ +package runtimewiring + +import ( + "context" + "log/slog" + "strings" + + "csgclaw/internal/agent" + "csgclaw/internal/channel/feishu" + agentruntime "csgclaw/internal/runtime" + "csgclaw/internal/runtime/sandboxgateway" + "csgclaw/internal/sandbox" +) + +type sandboxRuntimeEnvBuilder func(baseURL, accessToken, botID, llmBaseURL, modelID string, provider feishu.BotCredentialProvider) map[string]string + +func withSandboxRuntimeHost(host agent.PicoClawRuntimeHost, feishuProvider feishu.BotCredentialProvider, buildRuntimeEnv sandboxRuntimeEnvBuilder, newRuntime func(sandboxgateway.Dependencies) agentruntime.Runtime) agent.ServiceOption { + return func(s *agent.Service) error { + return agent.WithRuntime(newRuntime(sandboxgateway.Dependencies{ + FeishuProvider: feishuProvider, + SandboxProviderName: host.SandboxProviderName, + EnsureRuntime: host.EnsureRuntime, + RuntimeHome: host.RuntimeHome, + CloseRuntime: host.CloseRuntime, + ResolveBox: func(ctx context.Context, rt sandbox.Runtime, got sandboxgateway.AgentRef) (sandbox.Instance, string, error) { + return host.ResolveBox(ctx, rt, agent.Agent{ + ID: got.ID, + Name: got.Name, + RuntimeID: got.RuntimeID, + BoxID: got.BoxID, + }) + }, + CreateBox: host.CreateBox, + StartBox: host.StartBox, + StopBox: host.StopBox, + BoxInfo: host.BoxInfo, + ForceRemoveBox: host.ForceRemoveBox, + CloseBox: host.CloseBox, + RunBoxCommand: host.RunBoxCommand, + ResolveAgent: func(h agentruntime.Handle) (sandboxgateway.AgentRef, error) { + got, err := host.ResolveAgent(h) + if err != nil { + return sandboxgateway.AgentRef{}, err + } + return sandboxgateway.AgentRef{ + ID: got.ID, + Name: got.Name, + RuntimeID: strings.TrimSpace(got.RuntimeID), + BoxID: got.BoxID, + }, nil + }, + SyncHandle: host.SyncHandle, + BuildRuntimeEnv: buildRuntimeEnv, + AddProfileEnv: agentAddProfileEnv, + StreamLogs: host.StreamLogs, + }))(s) + } +} + +func updateRuntimeFeishuProvider(svc *agent.Service, runtimeKind string, provider feishu.BotCredentialProvider) { + if svc == nil { + slog.Warn("skip feishu provider update: agent service is nil", "runtime_kind", runtimeKind) + return + } + rt, err := svc.Runtime(runtimeKind) + if err != nil { + slog.Warn("skip feishu provider update: runtime not available", "runtime_kind", runtimeKind, "error", err) + return + } + updater, ok := rt.(interface { + SetFeishuProvider(feishu.BotCredentialProvider) + }) + if !ok { + slog.Warn("skip feishu provider update: runtime does not support provider updates", "runtime_kind", rt.Kind()) + return + } + updater.SetFeishuProvider(provider) +} + +func agentAddProfileEnv(envVars map[string]string, profileEnv map[string]string) { + for key, value := range profileEnv { + key = strings.TrimSpace(key) + value = strings.TrimSpace(value) + if key == "" || value == "" || isReservedSandboxEnvKey(key) { + continue + } + envVars[key] = value + } +} + +func bridgeLLMEnvVars(llmBaseURL, accessToken, modelID string) map[string]string { + return map[string]string{ + "CSGCLAW_LLM_BASE_URL": llmBaseURL, + "CSGCLAW_LLM_API_KEY": accessToken, + "CSGCLAW_LLM_MODEL_ID": modelID, + "OPENAI_BASE_URL": llmBaseURL, + "OPENAI_API_KEY": accessToken, + "OPENAI_MODEL": modelID, + } +} + +func isReservedSandboxEnvKey(key string) bool { + upper := strings.ToUpper(strings.TrimSpace(key)) + if upper == "HOME" || upper == "OPENAI_BASE_URL" || upper == "OPENAI_API_KEY" || upper == "OPENAI_MODEL" { + return true + } + return strings.HasPrefix(upper, "CSGCLAW_") || strings.HasPrefix(upper, "PICOCLAW_") +} diff --git a/internal/bot/service_test.go b/internal/bot/service_test.go index c509ba02..e0f71815 100644 --- a/internal/bot/service_test.go +++ b/internal/bot/service_test.go @@ -162,7 +162,7 @@ func init() { if err := runtimewiring.WithPicoClawSandboxRuntime(nil)(s); err != nil { return err } - return runtimewiring.WithOpenClawSandboxRuntime()(s) + return runtimewiring.WithOpenClawSandboxRuntime(nil)(s) }) } diff --git a/internal/channel/feishu/bus.go b/internal/channel/feishu/bus.go index d42d463e..42961b74 100644 --- a/internal/channel/feishu/bus.go +++ b/internal/channel/feishu/bus.go @@ -9,9 +9,11 @@ import ( const MessageEventTypeMessageCreated = "message.created" type MessageEvent struct { - Type string `json:"type"` - RoomID string `json:"room_id,omitempty"` - Message *im.Message `json:"message,omitempty"` + Type string `json:"type"` + RoomID string `json:"room_id,omitempty"` + SenderBotID string `json:"sender_bot_id,omitempty"` + MentionBotID string `json:"mention_bot_id,omitempty"` + Message *im.Message `json:"message,omitempty"` } type MessageBus struct { diff --git a/internal/channel/feishu/service.go b/internal/channel/feishu/service.go index ab4e80a4..26b96a1a 100644 --- a/internal/channel/feishu/service.go +++ b/internal/channel/feishu/service.go @@ -1077,9 +1077,11 @@ func (s *Service) SendMessage(req im.CreateMessageRequest) (im.Message, error) { if len(message.Mentions) > 0 { s.messageBus.Publish(MessageEvent{ - Type: MessageEventTypeMessageCreated, - RoomID: roomID, - Message: &message, + Type: MessageEventTypeMessageCreated, + RoomID: roomID, + SenderBotID: senderID, + MentionBotID: mentionID, + Message: &message, }) } return message, nil diff --git a/internal/channel/feishu/service_test.go b/internal/channel/feishu/service_test.go index 58c0581e..ddfbfbf8 100644 --- a/internal/channel/feishu/service_test.go +++ b/internal/channel/feishu/service_test.go @@ -489,6 +489,12 @@ func TestFeishuSendMessageWithMentionPublishesMessageEvent(t *testing.T) { if evt.RoomID != "oc_alpha" { t.Fatalf("event room_id = %q, want oc_alpha", evt.RoomID) } + if evt.SenderBotID != "u-manager" { + t.Fatalf("event sender_bot_id = %q, want u-manager", evt.SenderBotID) + } + if evt.MentionBotID != "u-dev" { + t.Fatalf("event mention_bot_id = %q, want u-dev", evt.MentionBotID) + } if evt.Message == nil || evt.Message.ID != message.ID { t.Fatalf("event message = %+v, want message %q", evt.Message, message.ID) } diff --git a/internal/hub/builtin_store_test.go b/internal/hub/builtin_store_test.go index 154774d6..46ad49dd 100644 --- a/internal/hub/builtin_store_test.go +++ b/internal/hub/builtin_store_test.go @@ -13,7 +13,7 @@ import ( const ( testBuiltinPicoClawImage = "opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/picoclaw:2026.5.27" - testBuiltinOpenClawImage = "opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/openclaw:20260527.2-csgclaw" + testBuiltinOpenClawImage = "opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/openclaw:20260529.2-csgclaw" ) func TestBuiltinStoreListGetAndFetchWorkspace(t *testing.T) { diff --git a/internal/onboard/detect.go b/internal/onboard/detect.go index cda8d9f6..4499a921 100644 --- a/internal/onboard/detect.go +++ b/internal/onboard/detect.go @@ -25,7 +25,7 @@ var ( managerImage, path, runtimewiring.WithPicoClawSandboxRuntime(nil), - runtimewiring.WithOpenClawSandboxRuntime(), + runtimewiring.WithOpenClawSandboxRuntime(nil), agent.WithGatewayRuntime(config.RuntimeKindPicoClawSandbox), agent.WithBootstrapDefaultTemplates(cfg.Bootstrap), ) diff --git a/internal/onboard/onboard.go b/internal/onboard/onboard.go index 09d12b78..8fb372cd 100644 --- a/internal/onboard/onboard.go +++ b/internal/onboard/onboard.go @@ -121,7 +121,7 @@ func createManagerBot(ctx context.Context, agentsPath, imStatePath string, cfg c } opts = append(opts, runtimewiring.WithPicoClawSandboxRuntime(nil), - runtimewiring.WithOpenClawSandboxRuntime(), + runtimewiring.WithOpenClawSandboxRuntime(nil), agent.WithGatewayRuntime(bootstrapDefaults.ManagerRuntimeKind), agent.WithBootstrapDefaultTemplates(cfg.Bootstrap), agent.WithHubService(hubSvc), diff --git a/internal/runtime/openclawsandbox/config.go b/internal/runtime/openclawsandbox/config.go index fb4cac5f..6c130120 100644 --- a/internal/runtime/openclawsandbox/config.go +++ b/internal/runtime/openclawsandbox/config.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strings" + "csgclaw/internal/channel/feishu" "csgclaw/internal/config" ) @@ -42,12 +43,12 @@ func HostGatewayLogPath(agentHome string) string { return filepath.Join(Root(agentHome), "gateway.log") } -func EnsureConfig(agentHome, botID string, server config.ServerConfig, model config.ModelConfig, resolveBaseURL BaseURLResolver) (string, error) { +func EnsureConfig(agentHome, botID string, server config.ServerConfig, model config.ModelConfig, resolveBaseURL BaseURLResolver, feishuProvider feishu.BotCredentialProvider) (string, error) { hostRoot := Root(agentHome) if err := os.MkdirAll(hostRoot, 0o755); err != nil { return "", fmt.Errorf("create openclaw config dir: %w", err) } - data, err := renderConfig(botID, server, model, resolveBaseURL) + data, err := renderConfig(botID, server, model, resolveBaseURL, feishuProvider) if err != nil { return "", err } @@ -74,7 +75,9 @@ func EnsureConfig(agentHome, botID string, server config.ServerConfig, model con // writeExecApprovalsAllowAll seeds ~/.openclaw/exec-approvals.json so the // gateway-side approval daemon never prompts the agent for /approve. OpenClaw // takes the stricter of tools.exec.* and the file's defaults; without this file -// the file-side defaults (deny + on-miss) still gate every command. +// the file-side defaults (deny + on-miss) still gate every command. The +// wildcard allowlist keeps commands working even when a model-generated exec +// call explicitly narrows itself to allowlist mode. func writeExecApprovalsAllowAll(hostRoot string) error { payload := map[string]any{ "version": 1, @@ -86,9 +89,13 @@ func writeExecApprovalsAllowAll(hostRoot string) error { }, "agents": map[string]any{ "*": map[string]any{ - "security": "full", - "ask": "off", - "askFallback": "full", + "security": "full", + "ask": "off", + "askFallback": "full", + "autoAllowSkills": true, + "allowlist": []map[string]any{ + {"pattern": "*"}, + }, }, }, } @@ -107,7 +114,7 @@ func writeExecApprovalsAllowAll(hostRoot string) error { return nil } -func renderConfig(botID string, server config.ServerConfig, model config.ModelConfig, resolveBaseURL BaseURLResolver) ([]byte, error) { +func renderConfig(botID string, server config.ServerConfig, model config.ModelConfig, resolveBaseURL BaseURLResolver, feishuProvider feishu.BotCredentialProvider) ([]byte, error) { var cfg map[string]any if err := json.Unmarshal(defaultOpenClawGatewayConfig, &cfg); err != nil { return nil, fmt.Errorf("decode embedded openclaw config: %w", err) @@ -118,6 +125,9 @@ func renderConfig(botID string, server config.ServerConfig, model config.ModelCo if err := updateOpenClawCsgclawChannel(cfg, botID, server, resolveBaseURL); err != nil { return nil, err } + if err := updateOpenClawFeishuChannel(cfg, botID, feishuProvider); err != nil { + return nil, err + } if err := updateOpenClawGatewayAuth(cfg, server); err != nil { return nil, err } @@ -212,6 +222,67 @@ func updateOpenClawCsgclawChannel(cfg map[string]any, botID string, server confi return nil } +func updateOpenClawFeishuChannel(cfg map[string]any, botID string, provider feishu.BotCredentialProvider) error { + botID = strings.TrimSpace(botID) + if botID == "" || provider == nil { + return nil + } + app, ok := provider.BotConfig(botID) + if !ok { + return nil + } + appID := strings.TrimSpace(app.AppID) + appSecret := strings.TrimSpace(app.AppSecret) + if appID == "" || appSecret == "" { + return nil + } + + channels, ok := cfg["channels"].(map[string]any) + if !ok { + return fmt.Errorf("embedded openclaw config is missing channels") + } + channels["feishu"] = map[string]any{ + "enabled": true, + "connectionMode": "websocket", + "defaultAccount": botID, + "dmPolicy": "open", + "allowFrom": []any{"*"}, + "groupPolicy": "open", + "requireMention": true, + "accounts": map[string]any{ + botID: map[string]any{ + "enabled": true, + "appId": appID, + "appSecret": appSecret, + "name": botID, + }, + }, + } + if err := enableOpenClawPlugin(cfg, "feishu"); err != nil { + return err + } + return nil +} + +func enableOpenClawPlugin(cfg map[string]any, pluginID string) error { + plugins, ok := cfg["plugins"].(map[string]any) + if !ok { + return fmt.Errorf("embedded openclaw config is missing plugins") + } + entries, ok := plugins["entries"].(map[string]any) + if !ok { + entries = map[string]any{} + plugins["entries"] = entries + } + entry, _ := entries[pluginID].(map[string]any) + if entry == nil { + entry = map[string]any{} + } + entry["enabled"] = true + entries[pluginID] = entry + return nil +} + func managerBaseURL(server config.ServerConfig, resolveBaseURL BaseURLResolver) string { if resolveBaseURL == nil { return strings.TrimRight(strings.TrimSpace(server.AdvertiseBaseURL), "/") diff --git a/internal/runtime/openclawsandbox/config_test.go b/internal/runtime/openclawsandbox/config_test.go index 57b1bbfe..b87f6927 100644 --- a/internal/runtime/openclawsandbox/config_test.go +++ b/internal/runtime/openclawsandbox/config_test.go @@ -5,6 +5,7 @@ import ( "strings" "testing" + feishuchannel "csgclaw/internal/channel/feishu" "csgclaw/internal/config" ) @@ -17,7 +18,7 @@ func TestRenderAgentOpenClawConfigUsesOpenAICompatForMinimaxBaseURL(t *testing.T BaseURL: "https://api.minimaxi.com/v1", APIKey: "sk-minimax-test", ModelID: "MiniMax-M2.7", - }, testBaseURLResolver) + }, testBaseURLResolver, nil) if err != nil { t.Fatalf("renderAgentOpenClawConfig() error = %v", err) } @@ -63,7 +64,7 @@ func TestRenderAgentOpenClawConfigUsesOpenAICompatForInfiniMaaS(t *testing.T) { BaseURL: "https://cloud.infini-ai.com/maas/v1", APIKey: "sk-infini-test", ModelID: "minimax-m2.5", - }, testBaseURLResolver) + }, testBaseURLResolver, nil) if err != nil { t.Fatalf("renderAgentOpenClawConfig() error = %v", err) } @@ -110,7 +111,7 @@ func TestRenderAgentOpenClawConfigUsesBridgeWhenBaseURLEmpty(t *testing.T) { AccessToken: "shared-token", }, config.ModelConfig{ ModelID: "MiniMax-M2.7", - }, testBaseURLResolver) + }, testBaseURLResolver, nil) if err != nil { t.Fatalf("renderAgentOpenClawConfig() error = %v", err) } @@ -131,6 +132,27 @@ func TestRenderAgentOpenClawConfigUsesBridgeWhenBaseURLEmpty(t *testing.T) { } } +func TestRenderAgentOpenClawConfigDisablesStartupUpdateCheck(t *testing.T) { + data, err := renderConfig("u-manager", config.ServerConfig{ + ListenAddr: "127.0.0.1:18080", + AdvertiseBaseURL: "http://127.0.0.1:18080", + AccessToken: "shared-token", + }, config.ModelConfig{ + ModelID: "MiniMax-M2.7", + }, testBaseURLResolver, nil) + if err != nil { + t.Fatalf("renderAgentOpenClawConfig() error = %v", err) + } + var cfg map[string]any + if err := json.Unmarshal(data, &cfg); err != nil { + t.Fatalf("json.Unmarshal: %v", err) + } + update := cfg["update"].(map[string]any) + if got, want := update["checkOnStart"], false; got != want { + t.Fatalf("update.checkOnStart = %v, want %v", got, want) + } +} + func TestRenderAgentOpenClawConfigDefaultsCsgclawGroupsToMentionOnly(t *testing.T) { data, err := renderConfig("u-manager", config.ServerConfig{ ListenAddr: "127.0.0.1:18080", @@ -138,7 +160,7 @@ func TestRenderAgentOpenClawConfigDefaultsCsgclawGroupsToMentionOnly(t *testing. AccessToken: "shared-token", }, config.ModelConfig{ ModelID: "MiniMax-M2.7", - }, testBaseURLResolver) + }, testBaseURLResolver, nil) if err != nil { t.Fatalf("renderAgentOpenClawConfig() error = %v", err) } @@ -157,6 +179,61 @@ func TestRenderAgentOpenClawConfigDefaultsCsgclawGroupsToMentionOnly(t *testing. if got, want := defaultGroup["requireMention"], true; got != want { t.Fatalf("groups.*.requireMention = %v, want %v", got, want) } + if _, ok := channels["feishu"]; ok { + t.Fatalf("feishu channel should not be rendered without bot credentials") + } +} + +func TestRenderAgentOpenClawConfigAddsFeishuChannelWhenConfigured(t *testing.T) { + data, err := renderConfig("u-manager", config.ServerConfig{ + ListenAddr: "127.0.0.1:18080", + AdvertiseBaseURL: "http://127.0.0.1:18080", + AccessToken: "shared-token", + }, config.ModelConfig{ + ModelID: "MiniMax-M2.7", + }, testBaseURLResolver, staticFeishuProvider{ + bots: map[string]feishuchannel.AppConfig{ + "u-manager": { + AppID: "cli_a_test", + AppSecret: "secret-test", + }, + }, + }) + if err != nil { + t.Fatalf("renderAgentOpenClawConfig() error = %v", err) + } + var cfg map[string]any + if err := json.Unmarshal(data, &cfg); err != nil { + t.Fatalf("json.Unmarshal: %v", err) + } + channels := cfg["channels"].(map[string]any) + feishuCfg := channels["feishu"].(map[string]any) + if got, want := feishuCfg["enabled"], true; got != want { + t.Fatalf("feishu.enabled = %v, want %v", got, want) + } + if got, want := feishuCfg["connectionMode"], "websocket"; got != want { + t.Fatalf("feishu.connectionMode = %v, want %v", got, want) + } + if got, want := feishuCfg["defaultAccount"], "u-manager"; got != want { + t.Fatalf("feishu.defaultAccount = %v, want %v", got, want) + } + if got, want := feishuCfg["requireMention"], true; got != want { + t.Fatalf("feishu.requireMention = %v, want %v", got, want) + } + accounts := feishuCfg["accounts"].(map[string]any) + account := accounts["u-manager"].(map[string]any) + if got, want := account["appId"], "cli_a_test"; got != want { + t.Fatalf("feishu account appId = %v, want %v", got, want) + } + if got, want := account["appSecret"], "secret-test"; got != want { + t.Fatalf("feishu account appSecret = %v, want %v", got, want) + } + plugins := cfg["plugins"].(map[string]any) + entries := plugins["entries"].(map[string]any) + feishuPlugin := entries["feishu"].(map[string]any) + if got, want := feishuPlugin["enabled"], true; got != want { + t.Fatalf("plugins.entries.feishu.enabled = %v, want %v", got, want) + } } func TestRenderAgentOpenClawConfigPassesThroughDockerHostAlias(t *testing.T) { @@ -168,7 +245,7 @@ func TestRenderAgentOpenClawConfigPassesThroughDockerHostAlias(t *testing.T) { BaseURL: "https://api.minimaxi.com/v1", APIKey: "sk-minimax-test", ModelID: "MiniMax-M2.7", - }, testBaseURLResolver) + }, testBaseURLResolver, nil) if err != nil { t.Fatalf("renderAgentOpenClawConfig() error = %v", err) } @@ -184,3 +261,12 @@ func TestRenderAgentOpenClawConfigPassesThroughDockerHostAlias(t *testing.T) { func testBaseURLResolver(server config.ServerConfig) string { return strings.TrimRight(server.AdvertiseBaseURL, "/") } + +type staticFeishuProvider struct { + bots map[string]feishuchannel.AppConfig +} + +func (p staticFeishuProvider) BotConfig(botID string) (feishuchannel.AppConfig, bool) { + app, ok := p.bots[botID] + return app, ok +} diff --git a/internal/runtime/openclawsandbox/defaults/openclaw-gateway.json b/internal/runtime/openclawsandbox/defaults/openclaw-gateway.json index 2f4e269e..7af93000 100644 --- a/internal/runtime/openclawsandbox/defaults/openclaw-gateway.json +++ b/internal/runtime/openclawsandbox/defaults/openclaw-gateway.json @@ -27,6 +27,9 @@ } } }, + "update": { + "checkOnStart": false + }, "agents": { "defaults": { "model": { @@ -88,7 +91,7 @@ }, "plugins": { "load": { - "paths": ["/home/node/openclaw-plugins/csgclaw-extension"] + "paths": ["/home/node/openclaw-plugins"] }, "entries": { "csgclaw": { diff --git a/internal/runtime/openclawsandbox/provision_test.go b/internal/runtime/openclawsandbox/provision_test.go index 01c9c734..0e52f121 100644 --- a/internal/runtime/openclawsandbox/provision_test.go +++ b/internal/runtime/openclawsandbox/provision_test.go @@ -2,6 +2,7 @@ package openclawsandbox import ( "context" + "encoding/json" "os" "path/filepath" "testing" @@ -45,6 +46,32 @@ func TestProvisionPreparesGatewayAssets(t *testing.T) { if _, err := os.Stat(filepath.Join(agentHome, HostDir, HostExecApproval)); err != nil { t.Fatalf("stat openclaw approvals: %v", err) } + approvalsRaw, err := os.ReadFile(filepath.Join(agentHome, HostDir, HostExecApproval)) + if err != nil { + t.Fatalf("ReadFile(openclaw approvals) error = %v", err) + } + var approvals struct { + Agents map[string]struct { + Security string `json:"security"` + Ask string `json:"ask"` + Allowlist []struct { + Pattern string `json:"pattern"` + } `json:"allowlist"` + } `json:"agents"` + } + if err := json.Unmarshal(approvalsRaw, &approvals); err != nil { + t.Fatalf("json.Unmarshal(openclaw approvals) error = %v", err) + } + wildcard := approvals.Agents["*"] + if got, want := wildcard.Security, "full"; got != want { + t.Fatalf("openclaw approvals agents.*.security = %q, want %q", got, want) + } + if got, want := wildcard.Ask, "off"; got != want { + t.Fatalf("openclaw approvals agents.*.ask = %q, want %q", got, want) + } + if len(wildcard.Allowlist) != 1 || wildcard.Allowlist[0].Pattern != "*" { + t.Fatalf("openclaw approvals agents.*.allowlist = %#v, want wildcard pattern", wildcard.Allowlist) + } data, err := os.ReadFile(filepath.Join(WorkspaceRoot(agentHome), "USER.md")) if err != nil { t.Fatalf("ReadFile(USER.md) error = %v", err) @@ -52,4 +79,9 @@ func TestProvisionPreparesGatewayAssets(t *testing.T) { if got, want := string(data), "overlay user\n"; got != want { t.Fatalf("USER.md = %q, want %q", got, want) } + if info, err := os.Stat(filepath.Join(WorkspaceRoot(agentHome), "projects")); err != nil { + t.Fatalf("stat workspace projects mountpoint: %v", err) + } else if !info.IsDir() { + t.Fatalf("workspace projects mountpoint is not a directory") + } } diff --git a/internal/runtime/openclawsandbox/runtime.go b/internal/runtime/openclawsandbox/runtime.go index a922401d..71491fc8 100644 --- a/internal/runtime/openclawsandbox/runtime.go +++ b/internal/runtime/openclawsandbox/runtime.go @@ -16,7 +16,6 @@ type WorkspaceLayout = sandboxgateway.WorkspaceLayout type Runtime struct { *sandboxgateway.Runtime - deps Dependencies } var _ agentruntime.Provisioner = (*Runtime)(nil) @@ -37,10 +36,7 @@ func New(deps Dependencies) *Runtime { Args: []string{"-e", "const url='http://127.0.0.1:18789/readyz';const ctl=new AbortController();const t=setTimeout(()=>ctl.abort(),1500);fetch(url,{signal:ctl.signal}).then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1)).finally(()=>clearTimeout(t));"}, } } - return &Runtime{ - Runtime: sandboxgateway.New(deps), - deps: deps, - } + return &Runtime{Runtime: sandboxgateway.New(deps)} } func (r *Runtime) Provision(_ context.Context, req agentruntime.ProvisionRequest) error { @@ -59,13 +55,16 @@ func (r *Runtime) Provision(_ context.Context, req agentruntime.ProvisionRequest if agentHome == "" { return fmt.Errorf("gateway agent home is required") } - if _, err := EnsureConfig(agentHome, req.AgentID, gateway.Server, configModelFromProfile(profile), fixedBaseURL(gateway.ManagerBaseURL)); err != nil { + if _, err := EnsureConfig(agentHome, req.AgentID, gateway.Server, configModelFromProfile(profile), fixedBaseURL(gateway.ManagerBaseURL), r.CurrentFeishuProvider()); err != nil { return err } workspaceRoot := WorkspaceRoot(agentHome) if err := sandboxgateway.EnsureEmbeddedWorkspace(gateway.WorkspaceTemplate, workspaceRoot); err != nil { return err } + if err := sandboxgateway.EnsureWorkspaceProjectsMountpoint(workspaceRoot); err != nil { + return err + } prepared, err := sandboxgateway.FinalizePreparedGatewayProvision(req, WorkspaceLayout{ MountHostPath: Root(agentHome), MountGuestPath: BoxDir, diff --git a/internal/runtime/openclawsandbox/runtime_test.go b/internal/runtime/openclawsandbox/runtime_test.go index 5eb2280a..a4da4866 100644 --- a/internal/runtime/openclawsandbox/runtime_test.go +++ b/internal/runtime/openclawsandbox/runtime_test.go @@ -8,6 +8,9 @@ import ( func TestGatewayRunCommandWritesGatewayOutputToSingleLog(t *testing.T) { cmd := GatewayRunCommand() + if !strings.Contains(cmd, "exec node /app/openclaw.mjs gateway ") { + t.Fatalf("GatewayRunCommand() = %q, want Node to launch OpenClaw", cmd) + } if !strings.Contains(cmd, "1>"+BoxGatewayLogPath) { t.Fatalf("GatewayRunCommand() = %q, want stdout written to gateway log", cmd) } diff --git a/internal/runtime/picoclawsandbox/provision_test.go b/internal/runtime/picoclawsandbox/provision_test.go index 64fbe5cd..c3e36a8c 100644 --- a/internal/runtime/picoclawsandbox/provision_test.go +++ b/internal/runtime/picoclawsandbox/provision_test.go @@ -54,6 +54,11 @@ func TestProvisionPreparesGatewayAssets(t *testing.T) { if got, want := string(data), "overlay user\n"; got != want { t.Fatalf("USER.md = %q, want %q", got, want) } + if info, err := os.Stat(filepath.Join(WorkspaceRoot(agentHome), "projects")); err != nil { + t.Fatalf("stat workspace projects mountpoint: %v", err) + } else if !info.IsDir() { + t.Fatalf("workspace projects mountpoint is not a directory") + } } func TestGatewayCreateSpecMountsPicoClawRuntimeRoot(t *testing.T) { diff --git a/internal/runtime/picoclawsandbox/runtime.go b/internal/runtime/picoclawsandbox/runtime.go index ec5eb42e..998a13d8 100644 --- a/internal/runtime/picoclawsandbox/runtime.go +++ b/internal/runtime/picoclawsandbox/runtime.go @@ -66,6 +66,9 @@ func (r *Runtime) Provision(_ context.Context, req agentruntime.ProvisionRequest if err := sandboxgateway.EnsureEmbeddedWorkspace(gateway.WorkspaceTemplate, workspaceRoot); err != nil { return err } + if err := sandboxgateway.EnsureWorkspaceProjectsMountpoint(workspaceRoot); err != nil { + return err + } prepared, err := sandboxgateway.FinalizePreparedGatewayProvision(req, WorkspaceLayout{ MountHostPath: Root(agentHome), MountGuestPath: BoxDir, diff --git a/internal/runtime/sandboxgateway/runtime.go b/internal/runtime/sandboxgateway/runtime.go index 56fd250a..0ee0e0d7 100644 --- a/internal/runtime/sandboxgateway/runtime.go +++ b/internal/runtime/sandboxgateway/runtime.go @@ -99,7 +99,8 @@ func (r *Runtime) SetFeishuProvider(provider feishu.BotCredentialProvider) { r.deps.FeishuProvider = provider } -func (r *Runtime) feishuProvider() feishu.BotCredentialProvider { +// CurrentFeishuProvider returns the provider used when provisioning or creating gateway boxes. +func (r *Runtime) CurrentFeishuProvider() feishu.BotCredentialProvider { if r == nil { return nil } @@ -339,7 +340,7 @@ func (r *Runtime) GatewayCreateSpec(image, name, botID string, profile agentrunt profile = prepared.Profile workspaceLayout := prepared.WorkspaceLayout projectsRoot := prepared.ProjectsRoot - envVars := r.deps.BuildRuntimeEnv(managerBaseURL, prepared.Server.AccessToken, botID, llmBaseURL, modelID, r.feishuProvider()) + envVars := r.deps.BuildRuntimeEnv(managerBaseURL, prepared.Server.AccessToken, botID, llmBaseURL, modelID, r.CurrentFeishuProvider()) r.deps.AddProfileEnv(envVars, profile.Env) homeEnv := r.homeEnv() projectsGuestPath := r.projectsGuestPath() diff --git a/internal/runtime/sandboxgateway/workspace.go b/internal/runtime/sandboxgateway/workspace.go index 10d3d933..57e3ef27 100644 --- a/internal/runtime/sandboxgateway/workspace.go +++ b/internal/runtime/sandboxgateway/workspace.go @@ -47,6 +47,18 @@ func EnsureEmbeddedWorkspace(templateRoot, dstRoot string) error { return copyWorkspaceFS(templates.FS(), templates.WorkspacePath(templateRoot), dstRoot, "embedded workspace", false) } +func EnsureWorkspaceProjectsMountpoint(workspaceRoot string) error { + workspaceRoot = strings.TrimSpace(workspaceRoot) + if workspaceRoot == "" { + return fmt.Errorf("workspace root is required") + } + // Keep the nested projects bind mount target present after the runtime root mount hides image defaults. + if err := os.MkdirAll(filepath.Join(workspaceRoot, "projects"), 0o755); err != nil { + return fmt.Errorf("create workspace projects mountpoint: %w", err) + } + return nil +} + func copyWorkspaceFS(srcFS fs.FS, root, dstRoot, label string, overwrite bool) error { dstRoot = strings.TrimSpace(dstRoot) if dstRoot == "" { diff --git a/internal/templates/embed/openclaw-manager/agent.toml b/internal/templates/embed/openclaw-manager/agent.toml index bf309844..b4a2ff4d 100644 --- a/internal/templates/embed/openclaw-manager/agent.toml +++ b/internal/templates/embed/openclaw-manager/agent.toml @@ -2,7 +2,7 @@ name = "openclaw-manager" description = "Builtin OpenClaw manager template" role = "manager" runtime_kind = "openclaw_sandbox" -updated_at = "2026-05-18T10:39:05Z" +updated_at = "2026-05-29T03:10:23Z" [image] -ref = "opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/openclaw:20260527.2-csgclaw" +ref = "opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/openclaw:20260529.2-csgclaw" diff --git a/internal/templates/embed/openclaw-manager/workspace/AGENTS.md b/internal/templates/embed/openclaw-manager/workspace/AGENTS.md index 5bc8f8a1..8e465422 100644 --- a/internal/templates/embed/openclaw-manager/workspace/AGENTS.md +++ b/internal/templates/embed/openclaw-manager/workspace/AGENTS.md @@ -39,6 +39,10 @@ execution is the right path. Stay practical, accurate, and concise. - Before using a skill, check the local `skills/` directory and read the matching `SKILL.md`. - Prefer local workspace skills over external discovery. +- For CSGClaw room, bot, member, Feishu group/chat creation, or adding bots to + Feishu groups, read and use `skills/basics/SKILL.md` first and run + `csgclaw-cli`. Do not conclude group creation is unsupported just because the + native OpenClaw `feishu_chat` tool only supports read/query actions. - Treat `skills/manager-worker-dispatch/SKILL.md` as the manager routing contract when dispatching worker-owned tasks. - For registry skill search, inspect, or list versions, read diff --git a/internal/templates/embed/openclaw-manager/workspace/skills/basics/SKILL.md b/internal/templates/embed/openclaw-manager/workspace/skills/basics/SKILL.md index 2de035b5..ad5f027d 100644 --- a/internal/templates/embed/openclaw-manager/workspace/skills/basics/SKILL.md +++ b/internal/templates/embed/openclaw-manager/workspace/skills/basics/SKILL.md @@ -1,6 +1,6 @@ --- name: basics -description: Handle the most common basic CSGClaw CLI administration tasks. Use when the Manager needs to create a room, list bots, create a bot, inspect room members, add a bot into a room, or notify a worker in IM. +description: Handle the most common basic CSGClaw CLI administration tasks. Use when the Manager needs to create a room or Feishu group/chat, list bots, create a bot, inspect room members, add a bot into a room, or perform similar direct `csgclaw-cli` operations for routine room, bot, and membership management. --- # CSGClaw CLI Basics @@ -13,6 +13,7 @@ Prefer this skill whenever the user is asking for basic room, bot, or member man This skill covers direct CLI actions such as: - create a room +- create a Feishu group/chat through CSGClaw - list rooms - list all bots - create a bot @@ -43,7 +44,7 @@ Create a room: csgclaw-cli room create --title test-room --creator-id u-manager --member-ids u-manager,u-dev --channel ``` -Use CSGClaw bot IDs in room, member, and message commands. +Use CSGClaw bot IDs in room, member, and message commands. For Feishu room creation, keep the same bot ID parameters; CSGClaw converts configured bot IDs to Feishu app IDs and sends them as `bot_id_list`. List rooms and check whether a room is direct: @@ -140,6 +141,7 @@ Do **not** post `@alex` plain text in the room instead of `--mention-id`. - When creating a bot, always pass a meaningful `--description` so later matching and reuse remain clear. - Verify room membership with `member list` after adding a member when room presence matters. - A direct room cannot accept an added bot as a new member. Create a new room with `--member-ids` containing the existing DM bots and the new bot. +- For Feishu, prefer `room create --member-ids` for new groups after bot configs exist. Use `member create` only for an existing Feishu group; that path requires manager app scopes such as `im:chat.members:write_only` or `im:chat`. - Keep `csgclaw-cli` parameters bot-facing across channels: use bot IDs such as `u-manager`, `u-dev`, and `u-alex`. - Never notify a worker with plain-text `@name`; always use `message create --mention-id` and verify `` in `message list`. - Keep the response focused on the concrete CLI result instead of introducing external planning artifacts. diff --git a/internal/templates/embed/openclaw-manager/workspace/skills/basics/agents/openai.yaml b/internal/templates/embed/openclaw-manager/workspace/skills/basics/agents/openai.yaml index fab9e3f8..ad8e9319 100644 --- a/internal/templates/embed/openclaw-manager/workspace/skills/basics/agents/openai.yaml +++ b/internal/templates/embed/openclaw-manager/workspace/skills/basics/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "CSGClaw CLI Basics" short_description: "Basic CSGClaw CLI room/bot/member operations" - default_prompt: "Use $basics to handle basic CSGClaw CLI operations such as creating rooms, listing bots, creating bots, listing room members, and adding bots into rooms." + default_prompt: "Use $basics to handle basic CSGClaw CLI operations such as creating rooms or Feishu groups, listing bots, creating bots, listing room members, and adding bots into rooms." diff --git a/internal/templates/embed/openclaw-manager/workspace/skills/feishu/SKILL.md b/internal/templates/embed/openclaw-manager/workspace/skills/feishu/SKILL.md new file mode 100644 index 00000000..7b9aaadc --- /dev/null +++ b/internal/templates/embed/openclaw-manager/workspace/skills/feishu/SKILL.md @@ -0,0 +1,354 @@ +--- +name: feishu +description: Configure and troubleshoot CSGClaw Feishu/Lark channel credentials for manager or worker bots. Use when the Manager needs to generate a bot creation URL or QR code, collect App ID/App Secret through registration, write and reload channel config through csgclaw-cli bot config, ensure or recreate agents, or debug Feishu messages not reaching CSGClaw/OpenClaw bots. +--- + +# Feishu + +This skill sets up Feishu/Lark bot credentials for CSGClaw-managed OpenClaw manager and worker bots. + +## Script + +Use the bundled script at `/home/node/.openclaw/workspace/skills/feishu/scripts/feishu_register.py`: + +```bash +python /home/node/.openclaw/workspace/skills/feishu/scripts/feishu_register.py start --bot-id u-dev --role worker --bot-name dev --qr +python /home/node/.openclaw/workspace/skills/feishu/scripts/feishu_register.py finalize --registration-id +``` + +If `start`/`poll` returns a machine-mode `next` command, prefer that absolute command. + +## Script roles + +- `scripts/feishu_register.py`: User-facing CLI entrypoint. Supports `start`, `poll`, `finalize`, `status`, `recreate-agent`. +- `scripts/feishu_setup/commands.py`: Parses CLI arguments and maps them to handler functions. +- `scripts/feishu_setup/registration.py`: Implements registration flow and device-code polling state transitions. +- `scripts/feishu_setup/csgclaw.py`: Applies config to CSGClaw, triggers reload, and performs bot/agent ensure/recreate actions. +- `scripts/feishu_setup/state.py`: Stores and migrates registration state files. +- `scripts/feishu_setup/config.py`: Defines constants, env-key names, and default path constants. +- `scripts/tests/`: tests and fixtures for script behavior. + +The script uses Feishu/Lark's accounts registration flow: + +1. `action=init` +2. `action=begin`, with `archetype=PersonalAgent`, `auth_method=client_secret`, and `request_user_info=open_id` +3. return a Feishu/Lark launcher URL, usually under `https://open.feishu.cn/...`; the script appends `from=csgclaw&tp=csgclaw` +4. poll with `action=poll`, `device_code=<...>`, `tp=ob_app` +5. when the user completes app creation, receive `client_id` and `client_secret` +6. map `client_id` -> CSGClaw `app_id`, and `client_secret` -> CSGClaw `app_secret` +7. immediately write the secret to CSGClaw through `csgclaw-cli bot config --channel feishu --set` without printing it + +Do not add or require a public Feishu Open Platform HTTP webhook as the main inbound path. OpenClaw uses Feishu/Lark WebSocket mode for real inbound bot messages. CSGClaw's `/api/v1/channels/feishu/bots/{bot}/events` endpoint is an internal SSE bridge for CSGClaw manager-to-worker dispatch, not a Feishu public webhook. + +## When to Use + +Use this skill when the user asks to: + +- create/configure Feishu for `u-manager` or a worker such as `u-dev` +- generate a Feishu/Lark bot creation URL or QR code +- get Feishu AK/SK, App ID/App Secret, or client_id/client_secret for a CSGClaw bot +- reload CSGClaw channel config after setting Feishu credentials +- recreate a worker or manager after Feishu credentials are configured +- debug why Feishu messages do not reach a CSGClaw/OpenClaw bot + +Do not use this skill for generic Feishu webhook integrations or non-CSGClaw Feishu app development. + +## Terms + +- CSGClaw bot ID: usually `u-manager`, `u-dev`, `u-qa`, etc. +- Feishu `app_id` / `app_secret`: the Feishu bot application's credentials. +- AK/SK in user wording usually means Feishu `app_id/app_secret` or `client_id/client_secret` returned by the registration flow. +- Manager agent: usually `u-manager`; recreating it can interrupt the current manager skill run. +- Worker agent: any non-manager bot, for example `u-dev`; recreating it is usually safe after config succeeds. + +## Prerequisites + +1. CSGClaw server is running. +2. Confirm CSGClaw API access is available through environment variables, not command-line token flags: + - `CSGCLAW_BASE_URL`, default `http://127.0.0.1:18080` + - `CSGCLAW_ACCESS_TOKEN`, unless server auth is disabled +3. The script is run from the deployed skill directory: + - inside manager box: typically `~/.openclaw/workspace/skills/feishu` or your configured skill root + - host repo path: `internal/templates/embed/openclaw-manager/workspace/skills/feishu` +4. Server build supports: + - `csgclaw-cli bot config --channel feishu --set/--get/--reload` + - `POST /api/v1/channels/feishu/bots` + - `POST /api/v1/agents/{id}/recreate` + +## Manager Group Permissions + +CSGClaw cannot silently grant Feishu/Lark app scopes from inside the OpenClaw runtime. Feishu group operations use the manager bot's Feishu app credentials, so the tenant admin must approve the required scopes in Feishu/Lark Open Platform. + +For new Feishu groups, after the manager and worker Feishu configs exist, prefer creating the group with all bot IDs already included: + +```bash +csgclaw-cli room create --title dev-ui-group --creator-id u-manager --member-ids u-manager,u-dev --channel feishu +``` + +This uses Feishu `bot_id_list` during chat creation and avoids the separate `member create` path for new groups. + +For existing Feishu groups, `csgclaw-cli member list` and `member create` require manager app scopes such as: + +- `im:chat:read` +- `im:chat.members:read` +- `im:chat.members:write_only` +- or the broader `im:chat` + +`finalize` prints `manager_group_scopes` and `manager_group_permission_url`. Send that URL to the user/admin when Feishu returns `Access denied` for group member inspection or adding a worker bot to an existing group. + +## Safe Credential Rules + +1. Never print `app_secret`, `client_secret`, access tokens, verification tokens, encryption keys, or connection strings. +2. If a secret must be represented in examples or summaries, write `[REDACTED]`. +3. The script must print only `app_secret: present` after finalize. +4. Do not store returned `client_secret` in skill state files. `finalize` pipes it directly to `csgclaw-cli bot config --channel feishu --set --app-secret-stdin`. +5. Verify with `csgclaw-cli bot config --channel feishu --get`, not by printing the secret. + +## Choose Target Bot + +Ask for the target when it is not explicit. + +If the user does not specify an agent in the request, ask: "请明确要对接飞书的目标 Agent 名字(如 `manager`/`u-manager` 或 `dev`/`u-dev`)". +Resolve target: +1. If input is `manager` or `u-manager`, treat as manager flow. +2. Otherwise, treat input as worker flow, set `bot_id` to the input if it already starts with `u-`, otherwise prefix `u-`. +3. If only role was inferred as manager, stop using recreate path and force action-card flow. + +Example normalization: +- `dev` -> worker `u-dev` +- `u-dev` -> worker `u-dev` +- `manager` -> manager +- `u-manager` -> manager + +For worker flow, finalize writes config, reloads Feishu channel config, ensures the CSGClaw bot, then recreates the worker so runtime env/files are materialized from the updated config. + +## Primary QR/Launcher Flow + +### 1. Start registration and show URL/QR + +Run from this skill directory: + +```bash +python /home/node/.openclaw/workspace/skills/feishu/scripts/feishu_register.py start \ + --bot-id \ + --role worker \ + --bot-name \ + --description "dev worker agent" \ + --qr +``` + +Expected output includes: + +- `Registration ID: ` +- an `https://open.feishu.cn/...` or Lark launcher URL with `from=csgclaw&tp=csgclaw` +- an ASCII QR code if Python package `qrcode` is installed +- the exact finalize command + +Send the URL or QR to the user and ask them to open it in Feishu/Lark and confirm app creation. + +If `--qr` cannot render a QR code because `qrcode` is not installed, send the printed URL. Do not block setup only because QR rendering is unavailable. + +### 2. Poll/finalize after user confirms + +After the user clicks the link and completes creation: + +```bash +python /home/node/.openclaw/workspace/skills/feishu/scripts/feishu_register.py finalize --registration-id +``` + +When running `finalize` through the manager's exec tool, always set the tool timeout to at least 600 seconds. Worker setup can create or pull a BoxLite image on first use, and the default tool timeout can interrupt the create flow before CSGClaw persists the worker agent. + +By default, `finalize` will: + +1. poll Feishu/Lark until credentials are available or timeout +2. receive `client_id/client_secret` +3. write `app_id/app_secret` to CSGClaw through `csgclaw-cli bot config` + - for `u-manager`, overwrite global `admin_open_id` only with the registration `open_id` + - for worker bots, ignore registration `open_id` and do not read, preserve, write, or report `admin_open_id` +4. auto-reload channel config +5. ensure the CSGClaw bot through `POST /api/v1/channels/feishu/bots` +6. for worker targets, recreate the worker after bot ensure so the new Feishu env/files take effect + - if BoxLite reports `box with name '' already exists` while CSGClaw reports `agent "" not found`, stop and tell the user the host has a stale partial worker box; do not keep trying random API paths or host-only commands from inside manager +7. for manager targets, print a `csgclaw.action_card` JSON payload with a whitelisted `rebuild-manager` action; the CSGClaw Web chat message should render the button to complete the window-triggered manager bootstrap replace flow. +8. print JSON with `app_secret: present`, never the real secret + +For a worker, default finalize is usually enough: + +```bash +python /home/node/.openclaw/workspace/skills/feishu/scripts/feishu_register.py finalize --registration-id +``` + +Use an exec/tool timeout of at least 600 seconds for this command. For workers, finalize recreates the target worker after config reload and bot ensure; do not create a second worker or change the bot id. + +For manager, default finalize configures and ensures the bot, then prints a structured action card. Return the JSON object exactly as the chat message content: no leading sentence, no Markdown table, no bullet list, no ```json fence, and no explanatory wrapper. The CSGClaw Web frontend will render a "重建 Manager" button. +The click is handled by the browser and calls the manager bootstrap replace surface (`POST /api/v1/agents` with `{"id":"u-manager","replace":true}`), not the hazardous generic recreate route. + +Do not run `python /home/node/.openclaw/workspace/skills/feishu/scripts/feishu_register.py recreate-agent --bot-id u-manager` as a terminal self-recreate step anymore. The manager-rebuild action must be completed by clicking the rendered Web window button, which calls `POST /api/v1/agents` with `{"id":"u-manager","replace":true}`. + +For manager only, BoxLite status is not a valid post-recreate success check in this skill. The OpenClaw gateway is managed by the runtime wrapper, so BoxLite may report a transient lifecycle state while CSGClaw is replacing the manager. Do not treat that as a reason to recreate manager again from the same manager-hosted run. + +### 3. Optional status/poll commands + +Check saved state without exposing device_code or secret: + +```bash +python /home/node/.openclaw/workspace/skills/feishu/scripts/feishu_register.py status --registration-id +``` + +Check whether user has confirmed yet: + +```bash +python /home/node/.openclaw/workspace/skills/feishu/scripts/feishu_register.py poll --registration-id +``` + +`poll` never prints credentials. If credentials are available, use `finalize` to write them immediately to CSGClaw. + +## Manual Fallback + +If Feishu/Lark registration endpoint fails, expires, or tenant policy blocks scan-to-create, ask the user to create/select an internal bot app manually: + +1. Open Feishu/Lark Open Platform. +2. Create or select a self-built/internal app. +3. Enable Bot capability. +4. Publish or enable the app in the tenant as required. +5. Obtain: + - App ID, usually `cli_...` + - App Secret, provided only through a secure path. + +Use `csgclaw-cli bot config` to set manually: + +```bash +printf '%s' '[REDACTED]' | csgclaw-cli bot config --channel feishu --set \ + --bot-id u-dev \ + --app-id cli_xxx \ + --app-secret-stdin +``` + +or: + +```bash +csgclaw-cli bot config --channel feishu --set \ + --bot-id u-dev \ + --app-id cli_xxx \ + --app-secret-file /secure/path/feishu_app_secret +``` + +## CLI Workflow Used by Script + +The script writes and reloads Feishu config through `csgclaw-cli bot config` because sandboxed skills should not edit host files directly or hand-roll config API calls. + +For `u-manager`, the script passes the registration `open_id` as the global `admin_open_id` while setting config and auto-reloading: + +```bash +printf '%s' '[REDACTED]' | csgclaw-cli --output json bot config --channel feishu --set \ + --bot-id u-manager \ + --app-id cli_xxx \ + --admin-open-id ou_xxx \ + --app-secret-stdin +``` + +Expected response shape: + +```json +{ + "bot_id": "u-manager", + "configured": true, + "app_id": "cli_xxx", + "app_secret": "present", + "admin_open_id": "ou_xxx", + "reloaded": true +} +``` + +Ensure bot: + +```bash +csgclaw-cli bot create --id u-dev --name dev --description "dev worker agent" --role worker --channel feishu +``` + +Recreate the worker after config reload and bot ensure so the runtime picks up the updated Feishu credentials: + +```bash +curl -sS -X POST "$CSGCLAW_BASE_URL/api/v1/agents/u-dev/recreate" \ + -H "Authorization: Bearer [REDACTED]" +``` + +## CLI Workflow for Manual Control + +Use `csgclaw-cli bot config` for channel config. Use the helper script or the backend recreate API for agent recreate, because lite `csgclaw-cli` does not expose agent commands and manager boxes usually do not have full `csgclaw`. + +```bash +csgclaw-cli bot config --channel feishu --get --bot-id u-dev +csgclaw-cli bot config --channel feishu --reload +csgclaw-cli bot create --id u-dev --name dev --description "dev worker agent" --role worker --channel feishu +python /home/node/.openclaw/workspace/skills/feishu/scripts/feishu_register.py recreate-agent --bot-id u-dev +``` + +## Worker One-Shot Recipe + +1. Start registration: + +```bash +python /home/node/.openclaw/workspace/skills/feishu/scripts/feishu_register.py start --bot-id --role worker --bot-name --description "" --qr +``` + +2. Send the printed URL/QR to the user. +3. After user confirms creation, finalize: + +```bash +python /home/node/.openclaw/workspace/skills/feishu/scripts/feishu_register.py finalize --registration-id +``` + +Run the command with exec `timeout` at least `600`. + +4. Confirm finalize recreated the worker after reload and bot ensure. +5. Tell the user to test from Feishu by messaging or @mentioning the bot. + +## Manager One-Shot Recipe + +Run this recipe from the normal flow and render the manager rebuild action card in the web window. + +1. Start registration: + +```bash +python /home/node/.openclaw/workspace/skills/feishu/scripts/feishu_register.py start --bot-id u-manager --role manager --bot-name manager --description "manager agent" --qr +``` + +2. Send the printed URL/QR to the user. +3. After user confirms creation, finalize without recreate: + +```bash +python /home/node/.openclaw/workspace/skills/feishu/scripts/feishu_register.py finalize --registration-id +``` + +4. Return the `finalize` JSON object exactly as the chat response. Do not summarize it, translate it, add a Markdown table, or wrap it in a code fence. The object contains `type: csgclaw.action_card` and action metadata so the Web frontend can render the button. + +5. Do not call a manager recreate API or host command from this skill. The Manager rebuild must be completed by the rendered Web action-card button. + +Do not use the generic manager recreate endpoint or any terminal/host-side manager rebuild fallback. The Web action card uses `POST /api/v1/agents` with `{"id":"u-manager","replace":true}` from the browser after the user clicks the window button. + +## Common Pitfalls + +1. Using `csgclaw-cli agent ...`: lite CLI does not have agent commands. Use full `csgclaw` or API. +2. Running host-only `csgclaw` or `boxlite` commands from inside manager: manager usually only has `csgclaw-cli`; use this script/API from manager, and ask the host operator to clean stale BoxLite boxes if needed. +3. Looking for removed `csgclaw channel ...` commands: Feishu config belongs to `csgclaw-cli bot config --channel feishu`. +4. Creating the CSGClaw bot before writing/reloading Feishu config: this can create local placeholder identity. +5. Expecting reload to update an already-running OpenClaw box: recreate is still required. +6. Calling manager recreate from inside this manager-hosted skill: return the action card so the current window renders the rebuild button. +7. Checking `agent list` or `bot list` after manager recreate and treating `stopped` as failure: manager gateway runs in daemon mode, so BoxLite status is not a reliable success signal for this skill. +8. Printing secrets in summaries or logs: always mask as `[REDACTED]` or `present`. +9. Calling CSGClaw SSE endpoint a Feishu webhook: it is an internal CSGClaw-to-runtime bridge. +10. If Feishu changes the accounts registration endpoint or tenant policy blocks PersonalAgent creation, fall back to manual App ID/App Secret setup. + +## Verification Checklist + +- [ ] `start` printed a launcher URL or QR code for the user. +- [ ] `finalize` output shows `app_secret` only as `present`. +- [ ] `finalize` configured `bot_id` and `app_id` in CSGClaw. +- [ ] CSGClaw channel config was reloaded. +- [ ] CSGClaw bot exists with `channel=feishu`. +- [ ] Worker agents are recreated after config reload and bot ensure. +- [ ] New worker finalize was run with a tool timeout of at least 600 seconds. +- [ ] Manager finalize returned a raw `csgclaw.action_card` JSON object with `rebuild-manager` action metadata for the web button. +- [ ] No manager-hosted command called the generic manager recreate endpoint or any host-side manager rebuild command. +- [ ] No public Feishu webhook endpoint was added or required. diff --git a/internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/feishu_register.py b/internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/feishu_register.py new file mode 100755 index 00000000..a7244969 --- /dev/null +++ b/internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/feishu_register.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 +"""Compatibility entrypoint for the CSGClaw Feishu registration helper.""" + +from __future__ import annotations + +from feishu_setup.commands import main + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/feishu_setup/__init__.py b/internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/feishu_setup/__init__.py new file mode 100644 index 00000000..c9fa7271 --- /dev/null +++ b/internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/feishu_setup/__init__.py @@ -0,0 +1 @@ +"""CSGClaw Feishu registration helper package.""" diff --git a/internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/feishu_setup/commands.py b/internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/feishu_setup/commands.py new file mode 100644 index 00000000..e900598d --- /dev/null +++ b/internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/feishu_setup/commands.py @@ -0,0 +1,299 @@ +"""Command-line interface for CSGClaw Feishu registration.""" + +from __future__ import annotations + +import argparse +import json +import sys +import time +import uuid +from typing import Any, Optional +from urllib.parse import quote + +from .config import API_REQUEST_TIMEOUT, DEFAULT_EXPIRE_SECONDS, MANAGER_GROUP_SCOPES, ONBOARD_OPEN_URLS +from .csgclaw import ( + api_json, + configure_csgclaw, + csgclaw_cli_json, + ensure_bot, + is_box_name_conflict, + is_same_bot_name_conflict, + manager_recreate_action_card, + maybe_recreate, + path_id, + public_result, + resolve_role, + worker_box_conflict_message, +) +from .registration import ( + begin_registration, + init_registration, + poll_until_success, + render_ascii_qr, + validate_bot_id, +) +from .state import delete_state, load_state, save_state, state_path + + +def eprint(*args: Any) -> None: + print(*args, file=sys.stderr) + + +def manager_group_permission_url(domain: str, app_id: str) -> str: + base = ONBOARD_OPEN_URLS.get(domain or "feishu", ONBOARD_OPEN_URLS["feishu"]) + quoted_app_id = quote(app_id, safe="") + quoted_scopes = quote(",".join(MANAGER_GROUP_SCOPES), safe=",:") + return f"{base}/app/{quoted_app_id}/auth?q={quoted_scopes}&op_from=openapi&token_type=tenant" + + +def resolve_manager_app_id(args: argparse.Namespace, state: dict, result: dict) -> str: + if state.get("bot_id") == "u-manager": + return str(result.get("app_id") or "").strip() + try: + config = csgclaw_cli_json( + args, + ["bot", "config", "--channel", "feishu", "--get", "--bot-id", "u-manager"], + ) + except RuntimeError: + return "" + if not isinstance(config, dict): + return "" + return str(config.get("app_id") or "").strip() + + +def add_manager_group_permission_info(args: argparse.Namespace, state: dict, result: dict, output: dict) -> None: + manager_app_id = resolve_manager_app_id(args, state, result) + domain = str(result.get("domain") or state.get("domain") or "feishu") + output["manager_group_scopes"] = MANAGER_GROUP_SCOPES + output["manager_group_permission_note"] = ( + "Approve these scopes on the manager Feishu app when the manager needs to " + "create Feishu groups, inspect group members, or add worker bots to existing Feishu groups." + ) + if manager_app_id: + output["manager_group_permission_app_id"] = manager_app_id + output["manager_group_permission_url"] = manager_group_permission_url(domain, manager_app_id) + else: + output["manager_group_permission_app_id"] = "" + output["manager_group_permission_url"] = "" + + +def cmd_start(args: argparse.Namespace) -> int: + bot_id = validate_bot_id(args.bot_id) + domain = args.domain + init_registration(domain) + begin = begin_registration(domain) + registration_id = str(uuid.uuid4()) + now = int(time.time()) + role = args.role or ("manager" if bot_id == "u-manager" else "worker") + state = { + "registration_id": registration_id, + "bot_id": bot_id, + "role": role, + "bot_name": args.bot_name or bot_id.removeprefix("u-") or bot_id, + "description": args.description or "", + "domain": domain, + "device_code": begin["device_code"], + "qr_url": begin["qr_url"], + "user_code": begin.get("user_code", ""), + "interval": begin["interval"], + "expire_in": begin["expire_in"], + "created_at": now, + "expires_at": now + min(begin["expire_in"], args.timeout), + } + save_state(args, state) + output = { + "registration_id": registration_id, + "bot_id": bot_id, + "role": role, + "qr_url": begin["qr_url"], + "user_code": begin.get("user_code", ""), + "interval": begin["interval"], + "expires_in": min(begin["expire_in"], args.timeout), + "state_path": str(state_path(args, registration_id)), + "next": f"python /home/node/.openclaw/workspace/skills/feishu/scripts/feishu_register.py finalize --registration-id {registration_id}", + "next_tool_timeout_seconds": API_REQUEST_TIMEOUT, + } + if args.json: + print(json.dumps(output, ensure_ascii=False, indent=2)) + else: + print(f"Feishu registration started for {bot_id}.") + print(f"Registration ID: {registration_id}") + print() + if args.qr: + rendered = render_ascii_qr(begin["qr_url"]) + if rendered: + print() + print("Open this URL in Feishu/Lark and confirm bot creation:") + print(begin["qr_url"]) + print() + print("After the user confirms, run:") + print(output["next"]) + print(f"Use a tool timeout of at least {API_REQUEST_TIMEOUT} seconds for finalize when creating worker boxes.") + return 0 + + +def cmd_poll(args: argparse.Namespace) -> int: + state = load_state(args) + result = poll_until_success(args, state, wait=False) + if result: + print( + json.dumps( + { + "status": "confirmed", + "bot_id": state["bot_id"], + "credentials": "available", + "next": f"python /home/node/.openclaw/workspace/skills/feishu/scripts/feishu_register.py finalize --registration-id {state['registration_id']}", + "next_tool_timeout_seconds": API_REQUEST_TIMEOUT, + }, + ensure_ascii=False, + indent=2, + ) + ) + else: + print(json.dumps({"status": "pending", "bot_id": state["bot_id"]}, ensure_ascii=False, indent=2)) + return 0 + + +def cmd_finalize(args: argparse.Namespace) -> int: + state = load_state(args) + result = poll_until_success(args, state, wait=True) + if not result: + raise RuntimeError("registration has not completed") + configured = configure_csgclaw(args, state, result) if not args.no_configure else None + role = resolve_role(args, state) + worker_existed_before_ensure = None + try: + ensured = ensure_bot(args, state, result) + except RuntimeError as exc: + name = args.bot_name or state.get("bot_name") or state["bot_id"].removeprefix("u-") or state["bot_id"] + if role == "worker" and is_same_bot_name_conflict(exc, state["bot_id"]): + ensured = {"id": state["bot_id"], "already_exists": True} + elif role == "worker" and is_box_name_conflict(exc, name): + raise RuntimeError(worker_box_conflict_message(state["bot_id"], name)) from None + else: + raise + recreated = maybe_recreate(args, state, worker_existed_before_ensure) + if not args.keep_state: + delete_state(args, state["registration_id"]) + if configured is not None: + admin_open_id = str((configured or {}).get("admin_open_id") or "").strip() if state["bot_id"] == "u-manager" else "" + else: + admin_open_id = str(result.get("open_id") or "").strip() if state["bot_id"] == "u-manager" else "" + worker_recreate_policy = None + if role == "worker": + if args.recreate in ("auto", "worker"): + worker_recreate_policy = "worker_recreated_after_config" + elif args.recreate == "none": + worker_recreate_policy = "recreate_disabled" + elif args.recreate == "manager": + worker_recreate_policy = "worker_recreate_skipped_manager_mode" + else: + worker_recreate_policy = "not_checked" + output = { + "status": "configured" if configured else "credentials_received", + "bot_id": state["bot_id"], + "role": state.get("role"), + "app_id": result["app_id"], + "app_secret": "present", + "domain": result.get("domain"), + "admin_open_id": admin_open_id, + "config": public_result(configured or {}), + "bot_ensured": ensured is not None, + "worker_existed_before_ensure": worker_existed_before_ensure, + "worker_recreate_policy": worker_recreate_policy, + "recreate": public_result(recreated or {}), + } + add_manager_group_permission_info(args, state, result, output) + if isinstance(recreated, dict) and recreated.get("type") == "csgclaw.action_card": + setup_status = output["status"] + output.update(recreated) + output["setup_status"] = setup_status + output["recreate"] = public_result(recreated) + print(json.dumps(output, ensure_ascii=False, indent=2)) + return 0 + + +def cmd_status(args: argparse.Namespace) -> int: + state = load_state(args) + safe = {k: v for k, v in state.items() if k not in {"device_code"}} + safe["device_code"] = "present" + print(json.dumps(safe, ensure_ascii=False, indent=2)) + return 0 + + +def cmd_recreate_agent(args: argparse.Namespace) -> int: + bot_id = validate_bot_id(args.bot_id) + if bot_id == "u-manager": + output = manager_recreate_action_card(bot_id) + else: + result = api_json(args, "POST", f"/api/v1/agents/{path_id(bot_id)}/recreate", None) + output = {"status": "recreate_requested", "bot_id": bot_id, "result": public_result(result or {})} + print(json.dumps(output, ensure_ascii=False, indent=2)) + return 0 + + +def add_common(p: argparse.ArgumentParser) -> None: + p.add_argument("--state-dir", default="", help="State directory; default is ~/.openclaw/workspace/.feishu or ~/.cache/csgclaw-feishu") + + +def add_api_common(p: argparse.ArgumentParser) -> None: + p.add_argument("--csgclaw-base-url", default="", help="CSGClaw base URL; default $CSGCLAW_BASE_URL or http://127.0.0.1:18080") + p.add_argument("--api-timeout", type=int, default=None, help="CSGClaw API timeout in seconds; default $CSGCLAW_API_TIMEOUT or 600") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Feishu/Lark QR registration helper for CSGClaw Feishu channel setup") + sub = parser.add_subparsers(dest="command", required=True) + + start = sub.add_parser("start", help="Start QR registration and print URL/QR") + add_common(start) + start.add_argument("--bot-id", required=True, help="CSGClaw bot id, e.g. u-dev or u-manager") + start.add_argument("--role", choices=["worker", "manager"], default="", help="Bot role; inferred from bot id when omitted") + start.add_argument("--bot-name", default="", help="CSGClaw bot display name") + start.add_argument("--description", default="", help="CSGClaw bot description") + start.add_argument("--domain", choices=["feishu", "lark"], default="feishu") + start.add_argument("--timeout", type=int, default=DEFAULT_EXPIRE_SECONDS) + start.add_argument("--json", action="store_true", help="Print machine-readable JSON") + start.add_argument("--qr", action="store_true", help="Try to render an ASCII QR code if qrcode is installed") + start.set_defaults(func=cmd_start) + + poll = sub.add_parser("poll", help="Check whether the user has completed registration; does not print secrets") + add_common(poll) + poll.add_argument("--registration-id", required=True) + poll.add_argument("--timeout", type=int, default=30) + poll.set_defaults(func=cmd_poll) + + finalize = sub.add_parser("finalize", help="Wait for registration, write CSGClaw config, ensure bot, and optionally recreate agent") + add_common(finalize) + add_api_common(finalize) + finalize.add_argument("--registration-id", required=True) + finalize.add_argument("--timeout", type=int, default=DEFAULT_EXPIRE_SECONDS) + finalize.add_argument("--no-configure", action="store_true", help="Do not write CSGClaw config; for debugging only, still never prints secret") + finalize.add_argument("--no-ensure-bot", action="store_true", help="Skip POST /api/v1/channels/feishu/bots") + finalize.add_argument("--role", choices=["worker", "manager"], default="", help="Override role for ensure/recreate logic") + finalize.add_argument("--bot-name", default="", help="Override bot name for ensure") + finalize.add_argument("--description", default="", help="Override bot description for ensure") + finalize.add_argument("--recreate", choices=["none", "auto", "worker", "manager"], default="auto", help="auto recreates existing workers and returns an action card for manager") + finalize.add_argument("--keep-state", action="store_true", help="Keep registration state file after successful finalize") + finalize.set_defaults(func=cmd_finalize) + + status = sub.add_parser("status", help="Print saved registration state without secrets") + add_common(status) + status.add_argument("--registration-id", required=True) + status.set_defaults(func=cmd_status) + + recreate = sub.add_parser("recreate-agent", help="Request worker agent recreate; manager returns a browser action card") + add_api_common(recreate) + recreate.add_argument("--bot-id", required=True, help="CSGClaw bot id to recreate") + recreate.set_defaults(func=cmd_recreate_agent) + return parser + + +def main(argv: Optional[list[str]] = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + try: + return args.func(args) + except Exception as exc: + eprint(f"error: {exc}") + return 1 diff --git a/internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/feishu_setup/config.py b/internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/feishu_setup/config.py new file mode 100644 index 00000000..99a5fac1 --- /dev/null +++ b/internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/feishu_setup/config.py @@ -0,0 +1,30 @@ +"""Shared constants for Feishu/Lark registration.""" + +ONBOARD_ACCOUNTS_URLS = { + "feishu": "https://accounts.feishu.cn", + "lark": "https://accounts.larksuite.com", +} +ONBOARD_OPEN_URLS = { + "feishu": "https://open.feishu.cn", + "lark": "https://open.larksuite.com", +} +REGISTRATION_PATH = "/oauth/v1/app/registration" +REQUEST_TIMEOUT = 15 +API_REQUEST_TIMEOUT = 600 +DEFAULT_EXPIRE_SECONDS = 600 + +# The manager app performs CSGClaw's Feishu group operations. These scopes cover +# creating a group, listing members, and adding configured worker bots to an +# existing group. Feishu tenant admins still need to approve the scopes. +MANAGER_GROUP_SCOPES = [ + "im:chat:create", + "im:chat:read", + "im:chat.members:read", + "im:chat.members:write_only", +] + +STATE_DIR_ENV = "CSGCLAW_FEISHU_SETUP_STATE_DIR" +STATE_DIR_NAME = ".feishu" +LEGACY_STATE_DIR_NAME = ".feishu-channel-setup" +CACHE_STATE_DIR_NAME = "csgclaw-feishu" +LEGACY_CACHE_STATE_DIR_NAME = "csgclaw-feishu-channel-setup" diff --git a/internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/feishu_setup/csgclaw.py b/internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/feishu_setup/csgclaw.py new file mode 100644 index 00000000..677475cb --- /dev/null +++ b/internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/feishu_setup/csgclaw.py @@ -0,0 +1,226 @@ +"""CSGClaw API helpers used by the Feishu skill.""" + +from __future__ import annotations + +import json +import os +import subprocess +from typing import Any, Optional +from urllib.error import HTTPError +from urllib.parse import quote +from urllib.request import Request, urlopen + +from .config import API_REQUEST_TIMEOUT + +ACTION_CARD_TYPE = "csgclaw.action_card" +MANAGER_REBUILD_ACTION_ID = "rebuild-manager" + + +def api_base(args) -> str: + return (args.csgclaw_base_url or os.environ.get("CSGCLAW_BASE_URL") or "http://127.0.0.1:18080").rstrip("/") + + +def api_token(args) -> str: + return getattr(args, "csgclaw_access_token", "") or os.environ.get("CSGCLAW_ACCESS_TOKEN", "") + + +def api_request_timeout(args) -> int: + value = getattr(args, "api_timeout", None) + if value is None: + raw = os.environ.get("CSGCLAW_API_TIMEOUT", "").strip() + if raw: + try: + value = int(raw) + except ValueError: + value = API_REQUEST_TIMEOUT + else: + value = API_REQUEST_TIMEOUT + return max(1, int(value)) + + +def path_id(value: str) -> str: + return quote(value, safe="") + + +def api_json(args, method: str, path: str, body: Optional[dict] = None) -> Any: + data = None if body is None else json.dumps(body).encode("utf-8") + headers = {"Content-Type": "application/json"} + token = api_token(args) + if token: + headers["Authorization"] = f"Bearer {token}" + req = Request(f"{api_base(args)}{path}", data=data, headers=headers, method=method) + try: + with urlopen(req, timeout=api_request_timeout(args)) as resp: + raw = resp.read().decode("utf-8") + return json.loads(raw) if raw else None + except HTTPError as exc: + raw = exc.read().decode("utf-8", errors="replace") + raise RuntimeError(f"CSGClaw API {method} {path} failed: HTTP {exc.code}: {raw.strip()}") from None + + +def csgclaw_cli_env(args) -> dict[str, str]: + env = os.environ.copy() + base_url = getattr(args, "csgclaw_base_url", "") or os.environ.get("CSGCLAW_BASE_URL", "") + token = api_token(args) + if base_url: + env["CSGCLAW_BASE_URL"] = base_url + if token: + env["CSGCLAW_ACCESS_TOKEN"] = token + return env + + +def csgclaw_cli_json(args, cli_args: list[str], input_text: Optional[str] = None) -> Any: + command = ["csgclaw-cli", "--output", "json", *cli_args] + try: + completed = subprocess.run( + command, + input=input_text, + text=True, + capture_output=True, + timeout=api_request_timeout(args), + env=csgclaw_cli_env(args), + check=False, + ) + except FileNotFoundError: + raise RuntimeError("csgclaw-cli was not found in PATH") from None + except subprocess.TimeoutExpired as exc: + raise RuntimeError(f"csgclaw-cli timed out after {api_request_timeout(args)} seconds") from exc + if completed.returncode != 0: + detail = (completed.stderr or completed.stdout or "").strip() + raise RuntimeError(f"csgclaw-cli {' '.join(cli_args)} failed: {detail}") from None + raw = completed.stdout.strip() + if not raw: + return {} + try: + return json.loads(raw) + except json.JSONDecodeError as exc: + raise RuntimeError(f"csgclaw-cli returned invalid JSON: {raw}") from exc + + +def configure_csgclaw(args, state: dict, result: dict) -> dict: + bot_id = state["bot_id"] + cli_args = [ + "bot", + "config", + "--channel", + "feishu", + "--set", + "--bot-id", + bot_id, + "--app-id", + result["app_id"], + "--app-secret-stdin", + ] + candidate_admin_open_id = str(result.get("open_id") or "").strip() + if bot_id == "u-manager" and candidate_admin_open_id: + cli_args.extend(["--admin-open-id", candidate_admin_open_id]) + response = csgclaw_cli_json(args, cli_args, input_text=result["app_secret"] + "\n") or {} + if bot_id == "u-manager": + if candidate_admin_open_id: + response["admin_open_id"] = candidate_admin_open_id + response["admin_open_id_source"] = "manager_registration" + else: + response.pop("admin_open_id", None) + elif bot_id != "u-manager": + response.pop("admin_open_id", None) + return response + + +def resolve_role(args, state: dict) -> str: + bot_id = state["bot_id"] + return args.role or state.get("role") or ("manager" if bot_id == "u-manager" else "worker") + + +def ensure_bot(args, state: dict, result: dict) -> Optional[dict]: + if args.no_ensure_bot: + return None + bot_id = state["bot_id"] + name = args.bot_name or state.get("bot_name") or bot_id.removeprefix("u-") or bot_id + role = resolve_role(args, state) + description = args.description or state.get("description") or f"{name} Feishu {role} agent" + payload = { + "id": bot_id, + "name": name, + "description": description, + "role": role, + "channel": "feishu", + } + return api_json(args, "POST", f"/api/v1/channels/feishu/bots", payload) + + +def worker_box_conflict_message(bot_id: str, name: str) -> str: + return ( + f"worker {bot_id!r} could not be created because a residual BoxLite box named {name!r} already exists, " + "but CSGClaw has no matching agent record. Stop here and ask the host operator to clean the stale worker " + f"runtime, for example: ./bin/boxlite --home ~/.csgclaw/agents/{name}/boxlite rm -f {name}" + ) + + +def is_box_name_conflict(exc: RuntimeError, name: str) -> bool: + message = str(exc) + return "box with name" in message and f"'{name}' already exists" in message + + +def is_same_bot_name_conflict(exc: RuntimeError, bot_id: str) -> bool: + message = str(exc) + return ( + 'bot name "' in message + and 'already exists in channel "feishu"' in message + and f'with id "{bot_id}"' in message + ) + + +def bot_exists(args, bot_id: str) -> bool: + bots = csgclaw_cli_json(args, ["bot", "list", "--channel", "feishu"]) + if not isinstance(bots, list): + raise RuntimeError(f"csgclaw-cli bot list returned unexpected JSON: {bots!r}") + return any(str(bot.get("id") or "").strip() == bot_id for bot in bots if isinstance(bot, dict)) + + +def maybe_recreate(args, state: dict, worker_existed_before_ensure: Optional[bool] = None) -> Optional[dict]: + mode = args.recreate + bot_id = state["bot_id"] + role = resolve_role(args, state) + if mode == "none": + return None + if role == "manager": + if mode == "worker": + return {"skipped": True, "reason": "worker recreate requested for manager bot"} + return manager_recreate_action_card(bot_id) + if mode == "manager": + return {"skipped": True, "reason": "manager recreate requested for a worker bot"} + # Feishu credentials are materialized into runtime env/files only during provision/start. + return api_json(args, "POST", f"/api/v1/agents/{path_id(bot_id)}/recreate", None) + + +def public_result(data: dict) -> dict: + clean = dict(data) + for key in ("app_secret", "client_secret", "access_token", "tenant_access_token"): + if key in clean: + clean[key] = "present" + return clean + + +def manager_recreate_action_card(bot_id: str) -> dict: + return { + "type": ACTION_CARD_TYPE, + "status": "manager_recreate_pending", + "bot_id": bot_id, + "title": "Manager Feishu 配置已完成", + "subtitle": bot_id, + "badge": "需在窗口点击", + "summary": ( + "飞书配置已写入并重新加载。" + "Manager 需要重建后才能把新配置注入运行环境。" + "请直接点击下方按钮,由浏览器发起安全的 Manager bootstrap replace。" + ), + "actions": [ + { + "id": MANAGER_REBUILD_ACTION_ID, + "label": "重建 Manager", + "style": "danger", + "method": "manager-bootstrap-replace", + "confirm": "重建 Manager 会中断当前 Manager,会话可能需要刷新。确认继续?", + } + ], + } diff --git a/internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/feishu_setup/registration.py b/internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/feishu_setup/registration.py new file mode 100644 index 00000000..38072aff --- /dev/null +++ b/internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/feishu_setup/registration.py @@ -0,0 +1,156 @@ +"""Feishu/Lark accounts registration API helpers.""" + +from __future__ import annotations + +import json +import time +from typing import Dict, Optional +from urllib.error import HTTPError, URLError +from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse +from urllib.request import Request, urlopen + +from .config import ( + DEFAULT_EXPIRE_SECONDS, + ONBOARD_ACCOUNTS_URLS, + REGISTRATION_PATH, + REQUEST_TIMEOUT, +) + + +def accounts_base_url(domain: str) -> str: + return ONBOARD_ACCOUNTS_URLS.get(domain, ONBOARD_ACCOUNTS_URLS["feishu"]) + + +def validate_bot_id(bot_id: str) -> str: + bot_id = (bot_id or "").strip() + if not bot_id: + raise RuntimeError("--bot-id is required") + for ch in bot_id: + if not (ch.isalnum() or ch in "-_"): + raise RuntimeError(f"invalid bot id {bot_id!r}: only letters, digits, '-' and '_' are allowed") + return bot_id + + +def append_launcher_params(url: str, source: str = "csgclaw") -> str: + parsed = urlparse(url) + query = dict(parse_qsl(parsed.query, keep_blank_values=True)) + query.setdefault("from", source) + query.setdefault("tp", source) + return urlunparse(parsed._replace(query=urlencode(query))) + + +def post_form(url: str, body: Dict[str, str]) -> dict: + data = urlencode(body).encode("utf-8") + req = Request(url, data=data, headers={"Content-Type": "application/x-www-form-urlencoded"}) + try: + with urlopen(req, timeout=REQUEST_TIMEOUT) as resp: + return json.loads(resp.read().decode("utf-8")) + except HTTPError as exc: + body_bytes = exc.read() + if body_bytes: + try: + return json.loads(body_bytes.decode("utf-8")) + except (ValueError, json.JSONDecodeError): + raise exc from None + raise + + +def post_registration(domain: str, body: Dict[str, str]) -> dict: + return post_form(f"{accounts_base_url(domain)}{REGISTRATION_PATH}", body) + + +def init_registration(domain: str) -> None: + res = post_registration(domain, {"action": "init"}) + methods = res.get("supported_auth_methods") or [] + if "client_secret" not in methods: + raise RuntimeError(f"Feishu/Lark registration does not support client_secret auth; supported={methods}") + + +def begin_registration(domain: str) -> dict: + res = post_registration( + domain, + { + "action": "begin", + "archetype": "PersonalAgent", + "auth_method": "client_secret", + "request_user_info": "open_id", + }, + ) + device_code = res.get("device_code") + if not device_code: + raise RuntimeError("registration begin did not return device_code") + qr_url = append_launcher_params(res.get("verification_uri_complete", ""), "csgclaw") + if not qr_url: + raise RuntimeError("registration begin did not return verification_uri_complete") + return { + "device_code": device_code, + "qr_url": qr_url, + "user_code": res.get("user_code", ""), + "interval": int(res.get("interval") or 5), + "expire_in": int(res.get("expire_in") or DEFAULT_EXPIRE_SECONDS), + } + + +def poll_registration_once(domain: str, device_code: str) -> dict: + return post_registration( + domain, + { + "action": "poll", + "device_code": device_code, + "tp": "ob_app", + }, + ) + + +def render_ascii_qr(url: str) -> bool: + try: + import qrcode # type: ignore + except Exception: + return False + try: + qr = qrcode.QRCode() + qr.add_data(url) + qr.make(fit=True) + qr.print_ascii(invert=True) + return True + except Exception: + return False + + +def extract_success(state: dict, res: dict) -> Optional[dict]: + user_info = res.get("user_info") or {} + domain = state.get("domain", "feishu") + if user_info.get("tenant_brand") == "lark": + domain = "lark" + if res.get("client_id") and res.get("client_secret"): + return { + "app_id": res["client_id"], + "app_secret": res["client_secret"], + "domain": domain, + "open_id": user_info.get("open_id"), + } + return None + + +def poll_until_success(args, state: dict, wait: bool) -> Optional[dict]: + deadline = min(int(state.get("expires_at", 0)) or (int(time.time()) + args.timeout), int(time.time()) + args.timeout) + interval = max(1, int(state.get("interval") or 5)) + domain = state.get("domain", "feishu") + while True: + try: + res = poll_registration_once(domain, state["device_code"]) + except (URLError, OSError, json.JSONDecodeError) as exc: + if not wait: + raise RuntimeError(f"poll failed: {exc}") from exc + res = {"error": "temporary_network_error"} + success = extract_success(state, res) + if success: + return success + error = res.get("error") + if error in ("access_denied", "expired_token"): + raise RuntimeError(f"registration failed: {error}") + if not wait: + return None + if time.time() >= deadline: + raise RuntimeError("registration timed out before user confirmation") + time.sleep(interval) diff --git a/internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/feishu_setup/state.py b/internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/feishu_setup/state.py new file mode 100644 index 00000000..346fe0c0 --- /dev/null +++ b/internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/feishu_setup/state.py @@ -0,0 +1,107 @@ +"""Registration state file handling.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Iterable + +from .config import ( + CACHE_STATE_DIR_NAME, + LEGACY_CACHE_STATE_DIR_NAME, + LEGACY_STATE_DIR_NAME, + STATE_DIR_ENV, + STATE_DIR_NAME, +) + + +def _safe_registration_id(registration_id: str) -> str: + return "".join(ch for ch in registration_id if ch.isalnum() or ch in "-_") + + +def _workspace_dir(name: str) -> Path: + return Path("~/.openclaw/workspace").expanduser() / name + + +def _cache_dir(name: str) -> Path: + return Path("~/.cache").expanduser() / name + + +def _dedupe(paths: Iterable[Path]) -> list[Path]: + unique = [] + seen = set() + for path in paths: + key = str(path) + if key in seen: + continue + seen.add(key) + unique.append(path) + return unique + + +def default_state_dir() -> Path: + override = os.environ.get(STATE_DIR_ENV) + if override: + return Path(override).expanduser() + openclaw_workspace = Path("~/.openclaw/workspace").expanduser() + if openclaw_workspace.exists() or Path("~/.openclaw").expanduser().exists(): + return _workspace_dir(STATE_DIR_NAME) + return _cache_dir(CACHE_STATE_DIR_NAME) + + +def state_dir(args) -> Path: + return Path(args.state_dir).expanduser() if args.state_dir else default_state_dir() + + +def state_path(args, registration_id: str) -> Path: + return state_dir(args) / f"{_safe_registration_id(registration_id)}.json" + + +def state_paths(args, registration_id: str) -> Iterable[Path]: + safe_name = f"{_safe_registration_id(registration_id)}.json" + if args.state_dir or os.environ.get(STATE_DIR_ENV): + yield state_path(args, registration_id) + return + + for directory in _dedupe( + [ + default_state_dir(), + _workspace_dir(STATE_DIR_NAME), + _workspace_dir(LEGACY_STATE_DIR_NAME), + _cache_dir(CACHE_STATE_DIR_NAME), + _cache_dir(LEGACY_CACHE_STATE_DIR_NAME), + ] + ): + yield directory / safe_name + + +def save_state(args, state: dict) -> None: + directory = state_dir(args) + directory.mkdir(parents=True, exist_ok=True) + os.chmod(directory, 0o700) + path = state_path(args, state["registration_id"]) + tmp = path.with_suffix(".tmp") + tmp.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8") + os.chmod(tmp, 0o600) + tmp.replace(path) + + +def load_state(args) -> dict: + if not args.registration_id: + raise SystemExit("--registration-id is required") + checked = [] + for path in state_paths(args, args.registration_id): + checked.append(path) + if path.exists(): + return json.loads(path.read_text(encoding="utf-8")) + joined = ", ".join(str(path) for path in checked) + raise SystemExit(f"registration state not found: {joined}") + + +def delete_state(args, registration_id: str) -> None: + for path in state_paths(args, registration_id): + try: + path.unlink() + except FileNotFoundError: + pass diff --git a/internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/tests/test_manager_action_card.py b/internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/tests/test_manager_action_card.py new file mode 100644 index 00000000..7b5de372 --- /dev/null +++ b/internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/tests/test_manager_action_card.py @@ -0,0 +1,239 @@ +from argparse import Namespace +from contextlib import redirect_stdout +from pathlib import Path +from io import StringIO +import json +import sys +import unittest + +SCRIPTS_DIR = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(SCRIPTS_DIR)) + +from feishu_setup import commands, csgclaw # noqa: E402 + + +class ManagerActionCardTest(unittest.TestCase): + def test_manager_auto_recreate_returns_frontend_action_card_without_api_recreate(self): + calls = [] + original_api_json = csgclaw.api_json + + def fake_api_json(*args, **kwargs): + calls.append((args, kwargs)) + raise AssertionError("manager finalize must not call recreate API from the skill") + + csgclaw.api_json = fake_api_json + try: + args = Namespace(recreate="auto", role="manager") + result = csgclaw.maybe_recreate( + args, + {"bot_id": "u-manager", "role": "manager"}, + worker_existed_before_ensure=None, + ) + finally: + csgclaw.api_json = original_api_json + + self.assertEqual(calls, []) + self.assertEqual(result["type"], "csgclaw.action_card") + self.assertEqual(result["status"], "manager_recreate_pending") + self.assertEqual(result["bot_id"], "u-manager") + self.assertEqual(result["actions"][0]["id"], "rebuild-manager") + self.assertEqual(result["actions"][0]["method"], "manager-bootstrap-replace") + self.assertNotIn("fallback", result) + self.assertNotIn("non_web_instruction", result) + + def test_worker_finalize_continues_recreate_when_same_bot_already_exists(self): + originals = { + "load_state": commands.load_state, + "poll_until_success": commands.poll_until_success, + "configure_csgclaw": commands.configure_csgclaw, + "ensure_bot": commands.ensure_bot, + "maybe_recreate": commands.maybe_recreate, + "delete_state": commands.delete_state, + "add_manager_group_permission_info": commands.add_manager_group_permission_info, + } + observed = {} + + def fake_ensure_bot(args, state, result): + raise RuntimeError( + 'CSGClaw API POST /api/v1/channels/feishu/bots failed: HTTP 400: ' + 'bot name "web-dev" already exists in channel "feishu" with id "u-web-dev"' + ) + + def fake_maybe_recreate(args, state, worker_existed_before_ensure): + observed["worker_existed_before_ensure"] = worker_existed_before_ensure + return {"recreated": True} + + commands.load_state = lambda args: { + "registration_id": "reg-worker", + "bot_id": "u-web-dev", + "role": "worker", + "bot_name": "web-dev", + } + commands.poll_until_success = lambda args, state, wait: { + "app_id": "cli_worker", + "app_secret": "secret-value", + "domain": "feishu", + } + commands.configure_csgclaw = lambda args, state, result: { + "bot_id": "u-web-dev", + "app_id": "cli_worker", + "app_secret": "present", + "reloaded": True, + } + commands.ensure_bot = fake_ensure_bot + commands.maybe_recreate = fake_maybe_recreate + commands.delete_state = lambda args, registration_id: None + commands.add_manager_group_permission_info = lambda args, state, result, output: None + try: + args = Namespace( + registration_id="reg-worker", + timeout=1, + no_configure=False, + no_ensure_bot=False, + role="worker", + bot_name="", + description="", + recreate="auto", + keep_state=True, + ) + stdout = StringIO() + with redirect_stdout(stdout): + exit_code = commands.cmd_finalize(args) + finally: + for name, value in originals.items(): + setattr(commands, name, value) + + self.assertEqual(exit_code, 0) + payload = json.loads(stdout.getvalue()) + self.assertIs(observed["worker_existed_before_ensure"], None) + self.assertTrue(payload["bot_ensured"]) + self.assertEqual(payload["worker_recreate_policy"], "worker_recreated_after_config") + self.assertEqual(payload["recreate"], {"recreated": True}) + + def test_worker_finalize_recreates_after_new_bot_ensure(self): + originals = { + "load_state": commands.load_state, + "poll_until_success": commands.poll_until_success, + "configure_csgclaw": commands.configure_csgclaw, + "ensure_bot": commands.ensure_bot, + "maybe_recreate": commands.maybe_recreate, + "delete_state": commands.delete_state, + "add_manager_group_permission_info": commands.add_manager_group_permission_info, + } + observed = {} + + def fake_maybe_recreate(args, state, worker_existed_before_ensure): + observed["worker_existed_before_ensure"] = worker_existed_before_ensure + return {"recreated": True} + + commands.load_state = lambda args: { + "registration_id": "reg-worker", + "bot_id": "u-new-worker", + "role": "worker", + "bot_name": "new-worker", + } + commands.poll_until_success = lambda args, state, wait: { + "app_id": "cli_worker", + "app_secret": "secret-value", + "domain": "feishu", + } + commands.configure_csgclaw = lambda args, state, result: { + "bot_id": "u-new-worker", + "app_id": "cli_worker", + "app_secret": "present", + "reloaded": True, + } + commands.ensure_bot = lambda args, state, result: {"id": "u-new-worker"} + commands.maybe_recreate = fake_maybe_recreate + commands.delete_state = lambda args, registration_id: None + commands.add_manager_group_permission_info = lambda args, state, result, output: None + try: + args = Namespace( + registration_id="reg-worker", + timeout=1, + no_configure=False, + no_ensure_bot=False, + role="worker", + bot_name="", + description="", + recreate="auto", + keep_state=True, + ) + stdout = StringIO() + with redirect_stdout(stdout): + exit_code = commands.cmd_finalize(args) + finally: + for name, value in originals.items(): + setattr(commands, name, value) + + self.assertEqual(exit_code, 0) + payload = json.loads(stdout.getvalue()) + self.assertIs(observed["worker_existed_before_ensure"], None) + self.assertTrue(payload["bot_ensured"]) + self.assertEqual(payload["worker_recreate_policy"], "worker_recreated_after_config") + self.assertEqual(payload["recreate"], {"recreated": True}) + + def test_manager_finalize_promotes_action_card_to_top_level(self): + originals = { + "load_state": commands.load_state, + "poll_until_success": commands.poll_until_success, + "configure_csgclaw": commands.configure_csgclaw, + "ensure_bot": commands.ensure_bot, + "delete_state": commands.delete_state, + } + commands.load_state = lambda args: { + "registration_id": "reg-1", + "bot_id": "u-manager", + "role": "manager", + "bot_name": "manager", + } + commands.poll_until_success = lambda args, state, wait: { + "app_id": "cli_example", + "app_secret": "secret-value", + "domain": "feishu", + "open_id": "ou_example", + } + commands.configure_csgclaw = lambda args, state, result: { + "bot_id": "u-manager", + "app_id": "cli_example", + "app_secret": "present", + "reloaded": True, + } + commands.ensure_bot = lambda args, state, result: {"id": "u-manager"} + commands.delete_state = lambda args, registration_id: None + try: + args = Namespace( + registration_id="reg-1", + timeout=1, + no_configure=False, + no_ensure_bot=False, + role="manager", + bot_name="", + description="", + recreate="auto", + keep_state=True, + ) + stdout = StringIO() + with redirect_stdout(stdout): + exit_code = commands.cmd_finalize(args) + finally: + for name, value in originals.items(): + setattr(commands, name, value) + + self.assertEqual(exit_code, 0) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["type"], "csgclaw.action_card") + self.assertEqual(payload["status"], "manager_recreate_pending") + self.assertEqual(payload["setup_status"], "configured") + self.assertEqual(payload["actions"][0]["id"], "rebuild-manager") + self.assertEqual(payload["app_secret"], "present") + self.assertEqual(payload["manager_group_permission_app_id"], "cli_example") + self.assertIn("/app/cli_example/auth", payload["manager_group_permission_url"]) + self.assertIn("im:chat.members:write_only", payload["manager_group_permission_url"]) + self.assertNotIn("fallback", payload) + self.assertNotIn("non_web_instruction", payload) + self.assertNotIn("render_target", payload) + + +if __name__ == "__main__": + unittest.main() diff --git a/internal/templates/embed/openclaw-worker/agent.toml b/internal/templates/embed/openclaw-worker/agent.toml index 7d409d3f..de449c61 100644 --- a/internal/templates/embed/openclaw-worker/agent.toml +++ b/internal/templates/embed/openclaw-worker/agent.toml @@ -2,7 +2,7 @@ name = "openclaw-worker" description = "Builtin OpenClaw worker template" role = "worker" runtime_kind = "openclaw_sandbox" -updated_at = "2026-05-18T10:39:05Z" +updated_at = "2026-05-29T03:10:23Z" [image] -ref = "opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/openclaw:20260527.2-csgclaw" +ref = "opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/openclaw:20260529.2-csgclaw"