Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions cli/serve/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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),
Expand Down
2 changes: 1 addition & 1 deletion docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<agent>/.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/<agent>/.openclaw/openclaw.json`; do not mount an empty host directory over `/home/node/openclaw-plugins`, because that hides baked plugins.

## Sandbox Providers

Expand Down
2 changes: 1 addition & 1 deletion docs/config.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<agent>/.openclaw/openclaw.json` 提供;不要把空的宿主机目录挂载到 `/home/node/openclaw-plugins`,否则会遮住镜像内已经烘焙好的插件。
推荐镜像形态是基于 OpenClaw slim 二次封装,并把 CSGClaw 管理的插件烘焙到 `/home/node/openclaw-plugins`(例如 `csgclaw-extension` 和外部 channel 插件)。运行时状态仍由 `~/.csgclaw/agents/<agent>/.openclaw/openclaw.json` 提供;不要把空的宿主机目录挂载到 `/home/node/openclaw-plugins`,否则会遮住镜像内已经烘焙好的插件。

## Sandbox Provider

Expand Down
10 changes: 10 additions & 0 deletions internal/agent/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
9 changes: 9 additions & 0 deletions internal/api/feishu.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"io"
"net/http"
"strings"
"time"

"csgclaw/internal/apitypes"
"csgclaw/internal/channel/feishu"
Expand Down Expand Up @@ -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
Expand Down
7 changes: 3 additions & 4 deletions internal/api/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
41 changes: 38 additions & 3 deletions internal/api/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand All @@ -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)
}
Expand All @@ -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(),
Expand Down
35 changes: 35 additions & 0 deletions internal/app/runtimewiring/openclaw.go
Original file line number Diff line number Diff line change
@@ -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
}
116 changes: 4 additions & 112 deletions internal/app/runtimewiring/picoclaw.go
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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
Expand All @@ -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
}

Expand All @@ -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 == "" {
Expand All @@ -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_")
}
Loading
Loading