diff --git a/.gitignore b/.gitignore index e51f06e6..4df900f7 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,10 @@ web/static-dist/* # Local runtime data manager-data/ docker/ +!docker/ +docker/* +!docker/codex-sandbox/ +!docker/codex-sandbox/Dockerfile # Generated embedded runtime workspaces /internal/agent/embed/ diff --git a/cli/agent/agent.go b/cli/agent/agent.go index 781c7ac0..b1c11dd1 100644 --- a/cli/agent/agent.go +++ b/cli/agent/agent.go @@ -103,7 +103,7 @@ func (c cmd) runCreate(ctx context.Context, run *command.Context, args []string, description := fs.String("description", "", "agent description") image := fs.String("image", "", "agent image") profile := fs.String("profile", "", "agent llm profile") - runtimeKind := fs.String("runtime", "", "agent runtime kind (for example: picoclaw_sandbox, openclaw_sandbox, codex)") + runtimeKind := fs.String("runtime", "", "agent runtime kind (for example: picoclaw_sandbox, openclaw_sandbox, codex_sandbox, codex)") fromTemplate := fs.String("from-template", "", "hub template to use as creation defaults and workspace overlay") fs.Usage = func() { fmt.Fprintln(run.Stderr, "Create an agent.") @@ -120,7 +120,7 @@ func (c cmd) runCreate(ctx context.Context, run *command.Context, args []string, fmt.Fprintln(run.Stderr, " --description string agent description") fmt.Fprintln(run.Stderr, " --image string agent image") fmt.Fprintln(run.Stderr, " --profile string agent llm profile") - fmt.Fprintln(run.Stderr, " --runtime string agent runtime kind (for example: picoclaw_sandbox, openclaw_sandbox, codex)") + fmt.Fprintln(run.Stderr, " --runtime string agent runtime kind (for example: picoclaw_sandbox, openclaw_sandbox, codex_sandbox, codex)") fmt.Fprintln(run.Stderr, " --from-template string hub template to use as creation defaults and workspace overlay") } if err := fs.Parse(args); err != nil { diff --git a/cli/app_test.go b/cli/app_test.go index 110f2da0..f55c6988 100644 --- a/cli/app_test.go +++ b/cli/app_test.go @@ -2092,7 +2092,7 @@ func TestAgentCreateSubcommandHelpShowsReplaceAndForceFlags(t *testing.T) { "--description string agent description", "--image string agent image", "--profile string agent llm profile", - "--runtime string agent runtime kind (for example: picoclaw_sandbox, openclaw_sandbox, codex)", + "--runtime string agent runtime kind (for example: picoclaw_sandbox, openclaw_sandbox, codex_sandbox, codex)", "--from-template string hub template to use as creation defaults and workspace overlay", } { if !strings.Contains(got, want) { diff --git a/cli/serve/serve.go b/cli/serve/serve.go index 51c6bcc9..f059b539 100644 --- a/cli/serve/serve.go +++ b/cli/serve/serve.go @@ -697,6 +697,7 @@ func configureFeishuService(feishuSvc *feishu.Service, svc *agent.Service) { } runtimewiring.UpdatePicoClawFeishuProvider(svc, provider) runtimewiring.UpdateOpenClawFeishuProvider(svc, provider) + runtimewiring.UpdateCodexSandboxFeishuProvider(svc, provider) } func preflightDefaultModelProvider(ctx context.Context, cfg config.Config) error { @@ -988,6 +989,7 @@ func newAgentService(cfg config.Config, feishuProvider feishu.AgentCredentialPro opts = append(opts, runtimewiring.WithPicoClawSandboxRuntime(feishuProvider), runtimewiring.WithOpenClawSandboxRuntime(feishuProvider), + runtimewiring.WithCodexSandboxRuntime(feishuProvider), runtimewiring.WithCodexRuntime(), agent.WithGatewayRuntime(bootstrapDefaults.ManagerRuntimeKind), agent.WithBootstrapDefaultTemplates(cfg.Bootstrap), diff --git a/cmd/csgclaw-codex-gateway/main.go b/cmd/csgclaw-codex-gateway/main.go new file mode 100644 index 00000000..e8853d6e --- /dev/null +++ b/cmd/csgclaw-codex-gateway/main.go @@ -0,0 +1,551 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strings" + "sync/atomic" + "syscall" + "time" + + "csgclaw/internal/channelbridge/codexbridge" + agentruntime "csgclaw/internal/runtime" + runtimecodex "csgclaw/internal/runtime/codex" +) + +const ( + defaultHome = "/home/codex/.codex-sandbox" + defaultProfile = "csgclaw" + defaultConfig = defaultHome + "/config.json" + defaultWorkspace = defaultHome + "/workspace/projects" + defaultCodexHome = defaultHome + "/codex-home" + defaultBridgeBin = "/usr/local/bin/lark-channel-bridge" + defaultCodexBin = "/usr/local/bin/codex" + defaultHealthAddr = "127.0.0.1:18791" + appSecretEnvKey = "APP_SECRET" + startupGracePeriod = 2 * time.Second +) + +var ( + startCSGClawBridgeFunc = startCSGClawBridge + startLarkBridgeFunc = startLarkBridge +) + +func main() { + log.SetFlags(log.LstdFlags | log.LUTC) + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + state := &gatewayState{} + healthSrv := startHealthServer(envString("CSGCLAW_CODEX_GATEWAY_HEALTH_ADDR", defaultHealthAddr), state) + defer func() { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = healthSrv.Shutdown(shutdownCtx) + }() + + if err := run(ctx, state); err != nil && !errors.Is(err, context.Canceled) { + log.Printf("gateway stopped: %v", err) + os.Exit(1) + } +} + +type gatewayState struct { + ready atomic.Bool + csgclawStarted atomic.Bool + larkBridgeAlive atomic.Bool +} + +func (s *gatewayState) setReady(ready bool) { + if s == nil { + return + } + s.ready.Store(ready) +} + +func (s *gatewayState) isReady() bool { + return s != nil && s.ready.Load() +} + +func (s *gatewayState) setBridgeStarted(started bool) { + if s == nil { + return + } + s.larkBridgeAlive.Store(started) +} + +func (s *gatewayState) isBridgeStarted() bool { + return s != nil && s.larkBridgeAlive.Load() +} + +func (s *gatewayState) setCSGClawStarted(started bool) { + if s == nil { + return + } + s.csgclawStarted.Store(started) +} + +func (s *gatewayState) isCSGClawStarted() bool { + return s != nil && s.csgclawStarted.Load() +} + +func run(ctx context.Context, state *gatewayState) error { + home := envString("LARK_CHANNEL_HOME", defaultHome) + profile := envString("LARK_CHANNEL_PROFILE", defaultProfile) + configPath := envString("LARK_CHANNEL_CONFIG", filepath.Join(home, "config.json")) + workspace := envString("CSGCLAW_CODEX_GATEWAY_WORKSPACE", defaultWorkspace) + bridgeBin := envString("CSGCLAW_CODEX_GATEWAY_BRIDGE_BIN", defaultBridgeBin) + codexBin := envString("LARK_CHANNEL_CODEX_BIN", defaultCodexBin) + + runtimeCfg := gatewayRuntimeConfigFromEnv(home, workspace, codexBin) + csgclawBridge, err := startCSGClawBridgeFunc(ctx, runtimeCfg) + if err != nil { + return err + } + defer csgclawBridge.Close() + state.setCSGClawStarted(true) + defer state.setCSGClawStarted(false) + + ready, reason := credentialsReady(configPath, profile) + var larkDone <-chan error + if !ready { + log.Printf("Feishu app config is not ready; lark-channel-bridge is disabled for this container: %s", reason) + } else { + larkDone, err = startLarkBridgeFunc(ctx, larkBridgeConfig{ + Home: home, + Profile: profile, + ConfigPath: configPath, + Workspace: workspace, + BridgeBin: bridgeBin, + CodexBin: codexBin, + }) + if err != nil { + return err + } + state.setBridgeStarted(true) + defer state.setBridgeStarted(false) + } + + state.setReady(true) + defer func() { + state.setReady(false) + }() + + for { + select { + case err, ok := <-larkDone: + if !ok { + larkDone = nil + state.setBridgeStarted(false) + continue + } + state.setBridgeStarted(false) + if ctx.Err() != nil { + return ctx.Err() + } + if err != nil { + log.Printf("lark-channel-bridge stopped; Feishu/Lark channel is disabled until container restart: %v", err) + } else { + log.Printf("lark-channel-bridge stopped; Feishu/Lark channel is disabled until container restart") + } + larkDone = nil + case <-ctx.Done(): + return ctx.Err() + } + } +} + +type larkBridgeConfig struct { + Home string + Profile string + ConfigPath string + Workspace string + BridgeBin string + CodexBin string +} + +func startLarkBridge(ctx context.Context, cfg larkBridgeConfig) (<-chan error, error) { + args := []string{ + "run", + "--profile", cfg.Profile, + "--agent", "codex", + "--config", cfg.ConfigPath, + "--workspace", cfg.Workspace, + "--skip-check-lark-cli", + } + cmd := exec.CommandContext(ctx, cfg.BridgeBin, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + cmd.Env = append(os.Environ(), + "LARK_CHANNEL_HOME="+cfg.Home, + "LARK_CHANNEL_PROFILE="+cfg.Profile, + "LARK_CHANNEL_CONFIG="+cfg.ConfigPath, + "LARK_CHANNEL_CODEX_BIN="+cfg.CodexBin, + ) + + log.Printf("starting lark-channel-bridge profile=%s config=%s workspace=%s", cfg.Profile, cfg.ConfigPath, cfg.Workspace) + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("start lark-channel-bridge: %w", err) + } + waitCh := make(chan error, 1) + go func() { + waitCh <- cmd.Wait() + close(waitCh) + }() + + select { + case err := <-waitCh: + if ctx.Err() != nil { + return nil, ctx.Err() + } + if err != nil { + return nil, fmt.Errorf("lark-channel-bridge exited during startup: %w", err) + } + return nil, fmt.Errorf("lark-channel-bridge exited during startup") + case <-time.After(startupGracePeriod): + case <-ctx.Done(): + <-waitCh + return nil, ctx.Err() + } + return waitCh, nil +} + +type gatewayRuntimeConfig struct { + Home string + Workspace string + CodexHome string + CodexBin string + BaseURL string + AccessToken string + ParticipantID string + AgentID string + AgentName string + RuntimeID string + LLMBaseURL string + LLMAPIKey string + ModelID string +} + +func gatewayRuntimeConfigFromEnv(home, workspace, codexBin string) gatewayRuntimeConfig { + baseURL := envString("CSGCLAW_BASE_URL", "") + agentID := envString("CSGCLAW_AGENT_ID", "") + participantID := envString("CSGCLAW_PARTICIPANT_ID", "") + if participantID == "" { + participantID = agentID + } + if agentID == "" { + agentID = participantID + } + llmBaseURL := envString("CSGCLAW_LLM_BASE_URL", "") + if llmBaseURL == "" && baseURL != "" && agentID != "" { + llmBaseURL = strings.TrimRight(baseURL, "/") + "/api/v1/agents/" + agentID + "/llm" + } + return gatewayRuntimeConfig{ + Home: home, + Workspace: workspace, + CodexHome: envString("CODEX_HOME", defaultCodexHome), + CodexBin: codexBin, + BaseURL: baseURL, + AccessToken: envString("CSGCLAW_ACCESS_TOKEN", ""), + ParticipantID: participantID, + AgentID: agentID, + AgentName: envString("CSGCLAW_AGENT_NAME", participantID), + RuntimeID: envString("CSGCLAW_RUNTIME_ID", runtimeIDForAgentID(agentID)), + LLMBaseURL: llmBaseURL, + LLMAPIKey: envString("CSGCLAW_LLM_API_KEY", envString("CSGCLAW_ACCESS_TOKEN", "")), + ModelID: envString("CSGCLAW_LLM_MODEL_ID", envString("OPENAI_MODEL", "")), + } +} + +func runtimeIDForAgentID(agentID string) string { + agentID = strings.TrimSpace(agentID) + if agentID == "" { + return "" + } + if strings.HasPrefix(agentID, "rt-") { + return agentID + } + return "rt-" + agentID +} + +type runningCSGClawBridge struct { + service *codexbridge.Service + runtime *runtimecodex.Runtime + handle agentruntime.Handle +} + +func (b *runningCSGClawBridge) Close() { + if b == nil { + return + } + if b.service != nil { + b.service.Close() + } + if b.runtime != nil && strings.TrimSpace(b.handle.RuntimeID) != "" { + _, _ = b.runtime.Stop(context.Background(), b.handle) + } +} + +func startCSGClawBridge(ctx context.Context, cfg gatewayRuntimeConfig) (*runningCSGClawBridge, error) { + cfg = cfg.normalized() + if err := cfg.validate(); err != nil { + return nil, err + } + if err := os.Setenv("CODEX_HOME", cfg.CodexHome); err != nil { + return nil, fmt.Errorf("set CODEX_HOME: %w", err) + } + events := runtimecodex.NewEventSink() + rt := runtimecodex.New(runtimecodex.Dependencies{ + BinaryProvider: staticCodexBinaryProvider{path: cfg.CodexBin}, + EventSink: events, + AgentHome: func(string) (string, error) { + return filepath.Join(cfg.Home, "codex-runtime", cfg.AgentID), nil + }, + ResolveAgent: func(h agentruntime.Handle) (runtimecodex.AgentRef, error) { + runtimeID := strings.TrimSpace(h.RuntimeID) + if runtimeID != "" && runtimeID != cfg.RuntimeID { + return runtimecodex.AgentRef{}, fmt.Errorf("unknown runtime id %q", runtimeID) + } + return runtimecodex.AgentRef{ + ID: cfg.AgentID, + Name: cfg.AgentName, + RuntimeID: cfg.RuntimeID, + RuntimeOptions: map[string]any{ + "local_workspace_dir": cfg.Workspace, + }, + Profile: agentruntime.Profile{ + BaseURL: cfg.LLMBaseURL, + APIKey: cfg.LLMAPIKey, + ModelID: cfg.ModelID, + }, + }, nil + }, + }) + handle, err := rt.New(ctx, agentruntime.Spec{ + RuntimeID: cfg.RuntimeID, + AgentID: cfg.AgentID, + AgentName: cfg.AgentName, + Profile: agentruntime.Profile{ + BaseURL: cfg.LLMBaseURL, + APIKey: cfg.LLMAPIKey, + ModelID: cfg.ModelID, + }, + }) + if err != nil { + return nil, fmt.Errorf("start codex app-server: %w", err) + } + session, err := rt.SessionManager().Session(runtimecodex.SessionHandle{RuntimeID: handle.RuntimeID}) + if err != nil { + _, _ = rt.Stop(context.Background(), handle) + return nil, fmt.Errorf("resolve codex app-server session: %w", err) + } + + client := &codexbridge.HTTPClient{ + BaseURL: cfg.BaseURL, + Token: cfg.AccessToken, + MentionOnly: true, + } + service := codexbridge.NewService(client, rt.SessionManager(), events) + if err := service.StartBot(ctx, codexbridge.Binding{ + BotID: cfg.ParticipantID, + RuntimeID: cfg.RuntimeID, + SessionID: strings.TrimSpace(session.SessionID), + PromptMeta: map[string]any{ + "channel": "csgclaw", + "participant_id": cfg.ParticipantID, + "agent_id": cfg.AgentID, + }, + }); err != nil { + service.Close() + _, _ = rt.Stop(context.Background(), handle) + return nil, fmt.Errorf("start csgclaw codex bridge: %w", err) + } + log.Printf("started CSGClaw Codex bridge base_url=%s participant_id=%s runtime_id=%s", cfg.BaseURL, cfg.ParticipantID, cfg.RuntimeID) + return &runningCSGClawBridge{service: service, runtime: rt, handle: handle}, nil +} + +func (cfg gatewayRuntimeConfig) normalized() gatewayRuntimeConfig { + cfg.Home = strings.TrimSpace(cfg.Home) + cfg.Workspace = strings.TrimSpace(cfg.Workspace) + cfg.CodexHome = strings.TrimSpace(cfg.CodexHome) + cfg.CodexBin = strings.TrimSpace(cfg.CodexBin) + cfg.BaseURL = strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/") + cfg.AccessToken = strings.TrimSpace(cfg.AccessToken) + cfg.ParticipantID = strings.TrimSpace(cfg.ParticipantID) + cfg.AgentID = strings.TrimSpace(cfg.AgentID) + cfg.AgentName = strings.TrimSpace(cfg.AgentName) + cfg.RuntimeID = strings.TrimSpace(cfg.RuntimeID) + cfg.LLMBaseURL = strings.TrimRight(strings.TrimSpace(cfg.LLMBaseURL), "/") + cfg.LLMAPIKey = strings.TrimSpace(cfg.LLMAPIKey) + cfg.ModelID = strings.TrimSpace(cfg.ModelID) + if cfg.ParticipantID == "" { + cfg.ParticipantID = cfg.AgentID + } + if cfg.AgentID == "" { + cfg.AgentID = cfg.ParticipantID + } + if cfg.AgentName == "" { + cfg.AgentName = cfg.ParticipantID + } + if cfg.RuntimeID == "" { + cfg.RuntimeID = runtimeIDForAgentID(cfg.AgentID) + } + return cfg +} + +func (cfg gatewayRuntimeConfig) validate() error { + missing := make([]string, 0, 6) + if cfg.Home == "" { + missing = append(missing, "LARK_CHANNEL_HOME") + } + if cfg.Workspace == "" { + missing = append(missing, "CSGCLAW_CODEX_GATEWAY_WORKSPACE") + } + if cfg.CodexHome == "" { + missing = append(missing, "CODEX_HOME") + } + if cfg.CodexBin == "" { + missing = append(missing, "LARK_CHANNEL_CODEX_BIN") + } + if cfg.BaseURL == "" { + missing = append(missing, "CSGCLAW_BASE_URL") + } + if cfg.ParticipantID == "" { + missing = append(missing, "CSGCLAW_PARTICIPANT_ID") + } + if cfg.AgentID == "" { + missing = append(missing, "CSGCLAW_AGENT_ID") + } + if cfg.RuntimeID == "" { + missing = append(missing, "CSGCLAW_RUNTIME_ID") + } + if cfg.LLMBaseURL == "" { + missing = append(missing, "CSGCLAW_LLM_BASE_URL") + } + if cfg.ModelID == "" { + missing = append(missing, "CSGCLAW_LLM_MODEL_ID") + } + if len(missing) > 0 { + return fmt.Errorf("csgclaw codex bridge config is incomplete: %s", strings.Join(missing, ", ")) + } + return nil +} + +type staticCodexBinaryProvider struct { + path string +} + +func (p staticCodexBinaryProvider) Ensure(context.Context) (string, error) { + path := strings.TrimSpace(p.path) + if path == "" { + return "", fmt.Errorf("codex binary path is required") + } + return path, nil +} + +func startHealthServer(addr string, state *gatewayState) *http.Server { + srv := &http.Server{Addr: addr, Handler: newHealthHandler(state)} + go func() { + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Printf("health server failed: %v", err) + } + }() + return srv +} + +func newHealthHandler(state *gatewayState) http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = w.Write([]byte("ok\n")) + }) + mux.HandleFunc("/readyz", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + if !state.isReady() { + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte("starting\n")) + return + } + _, _ = w.Write([]byte("ok\n")) + }) + return mux +} + +func credentialsReady(configPath, profile string) (bool, string) { + data, err := os.ReadFile(configPath) + if err != nil { + return false, err.Error() + } + var cfg bridgeRootConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return false, "decode config: " + err.Error() + } + profile = strings.TrimSpace(profile) + if profile == "" { + profile = strings.TrimSpace(cfg.ActiveProfile) + } + if profile == "" { + return false, "active profile is empty" + } + prof, ok := cfg.Profiles[profile] + if !ok { + return false, "profile not found: " + profile + } + if strings.TrimSpace(prof.Accounts.App.ID) == "" { + return false, "app id is empty" + } + secret := strings.TrimSpace(prof.Accounts.App.Secret) + if secret == "" { + return false, "app secret is empty" + } + if envName, ok := secretEnvName(secret); ok { + if strings.TrimSpace(os.Getenv(envName)) == "" { + return false, "env var " + envName + " is empty" + } + } + return true, "" +} + +func secretEnvName(value string) (string, bool) { + if !strings.HasPrefix(value, "${") || !strings.HasSuffix(value, "}") { + return "", false + } + name := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(value, "${"), "}")) + return name, name != "" +} + +func envString(key, fallback string) string { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback + } + return value +} + +type bridgeRootConfig struct { + ActiveProfile string `json:"activeProfile"` + Profiles map[string]bridgeProfileConfig `json:"profiles"` +} + +type bridgeProfileConfig struct { + Accounts bridgeAccounts `json:"accounts"` +} + +type bridgeAccounts struct { + App bridgeAppCredentials `json:"app"` +} + +type bridgeAppCredentials struct { + ID string `json:"id"` + Secret string `json:"secret"` +} diff --git a/cmd/csgclaw-codex-gateway/main_test.go b/cmd/csgclaw-codex-gateway/main_test.go new file mode 100644 index 00000000..7372bccd --- /dev/null +++ b/cmd/csgclaw-codex-gateway/main_test.go @@ -0,0 +1,201 @@ +package main + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestReadyzWaitsForGatewayReady(t *testing.T) { + state := &gatewayState{} + handler := newHealthHandler(state) + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/health", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("/health status = %d, want %d", rec.Code, http.StatusOK) + } + + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil)) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("/readyz before ready status = %d, want %d", rec.Code, http.StatusServiceUnavailable) + } + + state.setReady(true) + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("/readyz after ready status = %d, want %d", rec.Code, http.StatusOK) + } +} + +func TestRunReadyWithoutFeishuConfigDoesNotStartBridge(t *testing.T) { + origCSGClaw := startCSGClawBridgeFunc + origLark := startLarkBridgeFunc + t.Cleanup(func() { + startCSGClawBridgeFunc = origCSGClaw + startLarkBridgeFunc = origLark + }) + startCSGClawBridgeFunc = func(_ context.Context, cfg gatewayRuntimeConfig) (*runningCSGClawBridge, error) { + if got, want := cfg.BaseURL, "http://127.0.0.1:18080"; got != want { + t.Fatalf("CSGClaw bridge base URL = %q, want %q", got, want) + } + if got, want := cfg.ParticipantID, "pt-dev"; got != want { + t.Fatalf("CSGClaw bridge participant ID = %q, want %q", got, want) + } + return &runningCSGClawBridge{}, nil + } + startLarkBridgeFunc = func(context.Context, larkBridgeConfig) (<-chan error, error) { + t.Fatal("lark-channel-bridge should not start without Feishu config") + return nil, nil + } + + t.Setenv("LARK_CHANNEL_CONFIG", filepath.Join(t.TempDir(), "missing-config.json")) + t.Setenv("LARK_CHANNEL_PROFILE", "csgclaw") + t.Setenv(appSecretEnvKey, "") + t.Setenv("CSGCLAW_BASE_URL", "http://127.0.0.1:18080") + t.Setenv("CSGCLAW_ACCESS_TOKEN", "token") + t.Setenv("CSGCLAW_PARTICIPANT_ID", "pt-dev") + t.Setenv("CSGCLAW_AGENT_ID", "u-dev") + t.Setenv("CSGCLAW_LLM_BASE_URL", "http://127.0.0.1:18080/api/v1/agents/u-dev/llm") + t.Setenv("CSGCLAW_LLM_API_KEY", "token") + t.Setenv("CSGCLAW_LLM_MODEL_ID", "gpt-5.5") + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + state := &gatewayState{} + done := make(chan error, 1) + go func() { + done <- run(ctx, state) + }() + + deadline := time.After(2 * time.Second) + for !state.isReady() { + select { + case err := <-done: + t.Fatalf("run() returned before ready: %v", err) + case <-deadline: + t.Fatal("gateway did not become ready without Feishu config") + case <-time.After(10 * time.Millisecond): + } + } + if state.isBridgeStarted() { + t.Fatal("bridge started without Feishu config") + } + if !state.isCSGClawStarted() { + t.Fatal("CSGClaw bridge did not start") + } + + cancel() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("run() error = %v, want context.Canceled", err) + } + case <-time.After(2 * time.Second): + t.Fatal("run() did not stop after context cancellation") + } +} + +func TestRunDoesNotBecomeReadyWhenCSGClawBridgeFails(t *testing.T) { + origCSGClaw := startCSGClawBridgeFunc + t.Cleanup(func() { + startCSGClawBridgeFunc = origCSGClaw + }) + startCSGClawBridgeFunc = func(context.Context, gatewayRuntimeConfig) (*runningCSGClawBridge, error) { + return nil, fmt.Errorf("boom") + } + + state := &gatewayState{} + err := run(context.Background(), state) + if err == nil || !strings.Contains(err.Error(), "boom") { + t.Fatalf("run() error = %v, want boom", err) + } + if state.isReady() { + t.Fatal("gateway became ready after CSGClaw bridge failure") + } +} + +func TestGatewayRuntimeConfigFromEnvDefaultsLLMBaseURL(t *testing.T) { + t.Setenv("CSGCLAW_BASE_URL", "http://127.0.0.1:18080/") + t.Setenv("CSGCLAW_ACCESS_TOKEN", "token") + t.Setenv("CSGCLAW_AGENT_ID", "u-dev") + t.Setenv("CSGCLAW_PARTICIPANT_ID", "pt-dev") + t.Setenv("CSGCLAW_LLM_MODEL_ID", "gpt-5.5") + + cfg := gatewayRuntimeConfigFromEnv("/home/codex/.codex-sandbox", "/workspace", "/usr/local/bin/codex").normalized() + if got, want := cfg.LLMBaseURL, "http://127.0.0.1:18080/api/v1/agents/u-dev/llm"; got != want { + t.Fatalf("LLMBaseURL = %q, want %q", got, want) + } + if got, want := cfg.LLMAPIKey, "token"; got != want { + t.Fatalf("LLMAPIKey = %q, want %q", got, want) + } + if got, want := cfg.RuntimeID, "rt-u-dev"; got != want { + t.Fatalf("RuntimeID = %q, want %q", got, want) + } + if err := cfg.validate(); err != nil { + t.Fatalf("validate() error = %v", err) + } +} + +func TestGatewayRuntimeConfigAllowsEmptyLLMAPIKey(t *testing.T) { + cfg := gatewayRuntimeConfig{ + Home: "/home/codex/.codex-sandbox", + Workspace: "/home/codex/.codex-sandbox/workspace/projects", + CodexHome: "/home/codex/.codex-sandbox/codex-home", + CodexBin: "/usr/local/bin/codex", + BaseURL: "http://127.0.0.1:18080", + ParticipantID: "pt-dev", + AgentID: "u-dev", + RuntimeID: "rt-u-dev", + LLMBaseURL: "http://127.0.0.1:18080/api/v1/agents/u-dev/llm", + ModelID: "gpt-5.5", + }.normalized() + if err := cfg.validate(); err != nil { + t.Fatalf("validate() error = %v", err) + } +} + +func TestCredentialsReadyRequiresEnvBackedAppSecret(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + data := `{ + "activeProfile": "csgclaw", + "profiles": { + "csgclaw": { + "accounts": { + "app": { + "id": "cli_dev", + "secret": "${APP_SECRET}" + } + } + } + } +}` + if err := os.WriteFile(configPath, []byte(data), 0o600); err != nil { + t.Fatalf("WriteFile(config.json) error = %v", err) + } + + t.Setenv(appSecretEnvKey, "") + ready, reason := credentialsReady(configPath, "csgclaw") + if ready { + t.Fatalf("credentialsReady() ready = true, want false") + } + if !strings.Contains(reason, appSecretEnvKey) { + t.Fatalf("credentialsReady() reason = %q, want %s", reason, appSecretEnvKey) + } + + t.Setenv(appSecretEnvKey, "dev-secret") + ready, reason = credentialsReady(configPath, "csgclaw") + if !ready { + t.Fatalf("credentialsReady() ready = false, reason = %q", reason) + } +} diff --git a/docker/codex-sandbox/Dockerfile b/docker/codex-sandbox/Dockerfile new file mode 100644 index 00000000..53106326 --- /dev/null +++ b/docker/codex-sandbox/Dockerfile @@ -0,0 +1,83 @@ +ARG GO_IMAGE=golang:1.26.2-alpine +ARG RUNTIME_IMAGE=node:22.13.0-alpine +ARG ALPINE_MIRROR=https://mirrors.aliyun.com/alpine + +FROM ${GO_IMAGE} AS gateway-build + +WORKDIR /src + +ARG GOPROXY=https://goproxy.cn,direct +ARG ALPINE_MIRROR +ENV GOPROXY=${GOPROXY} + +RUN if [ -n "${ALPINE_MIRROR}" ]; then \ + sed -i "s|https://dl-cdn.alpinelinux.org/alpine|${ALPINE_MIRROR}|g" /etc/apk/repositories; \ + fi && \ + apk add --no-cache ca-certificates + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +ARG TARGETOS=linux +ARG TARGETARCH=amd64 +RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ + go build -trimpath -ldflags="-s -w" \ + -o /out/csgclaw-codex-gateway ./cmd/csgclaw-codex-gateway + +FROM ${RUNTIME_IMAGE} + +ARG LARK_CODING_AGENT_BRIDGE_PACKAGE=lark-channel-bridge@0.4.1 +ARG CODEX_PACKAGE=@openai/codex@0.142.5 +ARG NPM_REGISTRY=https://registry.npmmirror.com +ARG ALPINE_MIRROR + +RUN if [ -n "${ALPINE_MIRROR}" ]; then \ + sed -i "s|https://dl-cdn.alpinelinux.org/alpine|${ALPINE_MIRROR}|g" /etc/apk/repositories; \ + fi && \ + apk add --no-cache \ + bash \ + ca-certificates \ + git \ + openssh-client \ + tini \ + wget && \ + npm config set registry ${NPM_REGISTRY} && \ + npm install -g ${LARK_CODING_AGENT_BRIDGE_PACKAGE} ${CODEX_PACKAGE} && \ + npm cache clean --force && \ + command -v lark-channel-bridge && \ + command -v codex + +COPY --from=gateway-build /out/csgclaw-codex-gateway /usr/local/bin/csgclaw-codex-gateway + +RUN cat > /usr/local/bin/csgclaw-codex-entrypoint <<'EOF' +#!/bin/sh +set -eu +if [ "$#" -gt 0 ]; then + exec "$@" +fi +exec /usr/local/bin/csgclaw-codex-gateway +EOF + +RUN chmod 755 /usr/local/bin/csgclaw-codex-gateway /usr/local/bin/csgclaw-codex-entrypoint && \ + command -v csgclaw-codex-gateway && \ + command -v csgclaw-codex-entrypoint && \ + mkdir -p \ + /home/codex/.codex-sandbox/workspace/projects \ + /home/codex/.codex-sandbox/codex-home && \ + chmod -R 0777 /home/codex + +ENV HOME=/home/codex \ + LARK_CHANNEL_HOME=/home/codex/.codex-sandbox \ + LARK_CHANNEL_PROFILE=csgclaw \ + LARK_CHANNEL_CONFIG=/home/codex/.codex-sandbox/config.json \ + LARK_CHANNEL_CODEX_BIN=/usr/local/bin/codex \ + LARK_CHANNEL_TENANT=feishu \ + CODEX_HOME=/home/codex/.codex-sandbox/codex-home \ + CSGCLAW_CODEX_GATEWAY_WORKSPACE=/home/codex/.codex-sandbox/workspace/projects + +WORKDIR /home/codex/.codex-sandbox/workspace/projects + +ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/csgclaw-codex-entrypoint"] +CMD ["/usr/local/bin/csgclaw-codex-gateway"] diff --git a/docs/build.md b/docs/build.md index cfa55e73..bf9a2631 100644 --- a/docs/build.md +++ b/docs/build.md @@ -60,10 +60,12 @@ Manager and worker templates have different embedded workspaces but share one im | Runtime | Fixed image | |---|---| +| Codex Sandbox | `opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/csgclaw-codex-sandbox:20260701` | | OpenClaw | `opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/openclaw:20260610.2-csgclaw` | | PicoClaw | `opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/picoclaw:2026.6.10` | -These refs are stored directly in the builtin `agent.toml` files. They have no template `version` field, no generated tag, and no CSGClaw CI image-build workflow. +These refs are stored directly in the builtin `agent.toml` files. CSGClaw CI does not build these runtime images. +The Codex Sandbox image material is `docker/codex-sandbox/Dockerfile`. ## Web UI diff --git a/docs/build.zh.md b/docs/build.zh.md index e83d315e..9bfd76cd 100644 --- a/docs/build.zh.md +++ b/docs/build.zh.md @@ -60,10 +60,12 @@ Manager 与 Worker 模板保留不同的内置 workspace,但同一种 runtime | Runtime | 固定镜像 | |---|---| +| Codex Sandbox | `opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/csgclaw-codex-sandbox:20260701` | | OpenClaw | `opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/openclaw:20260610.2-csgclaw` | | PicoClaw | `opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/picoclaw:2026.6.10` | -固定引用直接写在内置 `agent.toml` 中,不再包含模板 `version` 字段,不再生成镜像 tag,也不再由 CSGClaw CI 构建这些运行时镜像。 +固定引用直接写在内置 `agent.toml` 中,CSGClaw CI 不构建这些运行时镜像。 +Codex Sandbox 镜像物料位于 `docker/codex-sandbox/Dockerfile`。 ## Web UI diff --git a/docs/cli.md b/docs/cli.md index 3cb0b241..818c215d 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -284,7 +284,7 @@ Flags: - `--description string`: agent description. - `--image string`: agent image. - `--profile string`: agent LLM profile. -- `--runtime string`: agent runtime kind, for example `picoclaw_sandbox` or `codex`. +- `--runtime string`: agent runtime kind, for example `picoclaw_sandbox`, `openclaw_sandbox`, `codex_sandbox`, or `codex`. Behavior: diff --git a/docs/cli.zh.md b/docs/cli.zh.md index 0fc8b126..4dee6713 100644 --- a/docs/cli.zh.md +++ b/docs/cli.zh.md @@ -284,7 +284,7 @@ csgclaw agent create [-r|--replace] --id [flags] - `--description string`:Agent 描述。 - `--image string`:Agent 镜像。 - `--profile string`:Agent 使用的 LLM profile。 -- `--runtime string`:Agent runtime kind,例如 `picoclaw_sandbox` 或 `codex`。 +- `--runtime string`:Agent runtime kind,例如 `picoclaw_sandbox`、`openclaw_sandbox`、`codex_sandbox` 或 `codex`。 行为说明: diff --git a/docs/config.md b/docs/config.md index abff6dd2..124620bf 100644 --- a/docs/config.md +++ b/docs/config.md @@ -105,10 +105,10 @@ provider = "boxlite" Codex and Claude Code profiles are configured in agent state through the Web UI. CSGClaw starts an embedded CLIProxyAPI on a private localhost port at serve time, so static CLIProxy base URLs are not required. -Workers can also select an explicit runtime kind when they are created. The default runtime kind is `picoclaw_sandbox`. To create a sandboxed OpenClaw worker, use `csgclaw agent create --runtime openclaw_sandbox ...`; to create a Codex worker, use `csgclaw agent create --runtime codex ...`. The API accepts the same values through `runtime_kind` on `POST /api/v1/agents`. +Workers can also select an explicit runtime kind when they are created. The default runtime kind is `picoclaw_sandbox`. To create a sandboxed OpenClaw worker, use `csgclaw agent create --runtime openclaw_sandbox ...`; to create a sandboxed Codex worker, use `csgclaw agent create --runtime codex_sandbox ...`; to create a host-local Codex worker, use `csgclaw agent create --runtime codex ...`. The API accepts the same values through `runtime_kind` on `POST /api/v1/agents`. Leave `[bootstrap].manager_image_override` empty to use the built-in default manager image. Set it only when you need to override that default. -The bootstrap manager currently runs on `picoclaw_sandbox`; `openclaw_sandbox` is supported for workers, not as the manager runtime. +The bootstrap manager currently runs on `picoclaw_sandbox`; `openclaw_sandbox` and `codex_sandbox` are supported for workers, not as the manager runtime. Auth is also managed locally: diff --git a/docs/config.zh.md b/docs/config.zh.md index 34542637..69f37bf8 100644 --- a/docs/config.zh.md +++ b/docs/config.zh.md @@ -105,10 +105,10 @@ provider = "boxlite" Codex 和 Claude Code Profile 通过 Web UI 写入 agent state。CSGClaw 在 `serve` 时会嵌入启动 CLIProxyAPI,并绑定到私有 localhost 端口,因此不再需要配置固定的 CLIProxy base URL。 -Worker 在创建时也可以显式选择 runtime kind。默认值是 `picoclaw_sandbox`。如果要创建 sandbox 里的 OpenClaw worker,可以使用 `csgclaw agent create --runtime openclaw_sandbox ...`;如果要创建 Codex worker,可以使用 `csgclaw agent create --runtime codex ...`。API 在 `POST /api/v1/agents` 的 `runtime_kind` 中接受同样的值。 +Worker 在创建时也可以显式选择 runtime kind。默认值是 `picoclaw_sandbox`。如果要创建 sandbox 里的 OpenClaw worker,可以使用 `csgclaw agent create --runtime openclaw_sandbox ...`;如果要创建 sandbox 里的 Codex worker,可以使用 `csgclaw agent create --runtime codex_sandbox ...`;如果要创建宿主机本地 Codex worker,可以使用 `csgclaw agent create --runtime codex ...`。API 在 `POST /api/v1/agents` 的 `runtime_kind` 中接受同样的值。 `[bootstrap].manager_image_override` 留空时会使用代码内置的默认 manager image;只有在需要覆盖默认值时才设置它。 -bootstrap manager 当前固定使用 `picoclaw_sandbox`;`openclaw_sandbox` 支持用于 worker,不支持作为 manager runtime。 +bootstrap manager 当前固定使用 `picoclaw_sandbox`;`openclaw_sandbox` 和 `codex_sandbox` 支持用于 worker,不支持作为 manager runtime。 本地鉴权由 CSGClaw 统一管理: diff --git a/internal/agent/model.go b/internal/agent/model.go index 205ff00f..fd07f6d8 100644 --- a/internal/agent/model.go +++ b/internal/agent/model.go @@ -282,6 +282,7 @@ func (s *CreateAgentSpec) UnmarshalJSON(data []byte) error { Instructions string `json:"instructions,omitempty"` Image string `json:"image,omitempty"` Avatar string `json:"-"` + RuntimeKind string `json:"runtime_kind,omitempty"` RuntimeName string `json:"runtime_name,omitempty"` SandboxEnabled *bool `json:"sandbox_enabled,omitempty"` Runtime *runtimeJSON `json:"runtime,omitempty"` @@ -305,6 +306,7 @@ func (s *CreateAgentSpec) UnmarshalJSON(data []byte) error { Description: decoded.Description, Instructions: decoded.Instructions, Image: decoded.Image, + RuntimeKind: strings.TrimSpace(decoded.RuntimeKind), RuntimeName: decoded.RuntimeName, FromTemplate: decoded.FromTemplate, Role: decoded.Role, diff --git a/internal/agent/runtime_record.go b/internal/agent/runtime_record.go index f8b29842..5db87d00 100644 --- a/internal/agent/runtime_record.go +++ b/internal/agent/runtime_record.go @@ -12,6 +12,7 @@ import ( const ( RuntimeKindPicoClawSandbox = agentruntime.KindPicoClawSandbox RuntimeKindOpenClawSandbox = agentruntime.KindOpenClawSandbox + RuntimeKindCodexSandbox = agentruntime.KindCodexSandbox RuntimeKindCodex = agentruntime.KindCodex RuntimeNamePicoClaw = agentruntime.NamePicoClaw RuntimeNameOpenClaw = agentruntime.NameOpenClaw @@ -110,7 +111,7 @@ func runtimeIDLookupAliases(runtimeID string) []string { func isGatewayRuntimeKind(kind string) bool { switch kind { - case RuntimeKindPicoClawSandbox, RuntimeKindOpenClawSandbox: + case RuntimeKindPicoClawSandbox, RuntimeKindOpenClawSandbox, RuntimeKindCodexSandbox: return true default: return false @@ -139,7 +140,7 @@ func resolveRuntimeSelection(kind, name string, sandboxEnabled bool) (string, st func runtimeKindForGatewayRuntime(runtime string) string { switch agentruntime.RuntimeConfigForKind(runtime).LegacyKind() { - case RuntimeKindPicoClawSandbox, RuntimeKindOpenClawSandbox: + case RuntimeKindPicoClawSandbox, RuntimeKindOpenClawSandbox, RuntimeKindCodexSandbox: return agentruntime.RuntimeConfigForKind(runtime).LegacyKind() default: return "" diff --git a/internal/agent/runtime_record_codex_sandbox_test.go b/internal/agent/runtime_record_codex_sandbox_test.go new file mode 100644 index 00000000..e363d217 --- /dev/null +++ b/internal/agent/runtime_record_codex_sandbox_test.go @@ -0,0 +1,21 @@ +package agent + +import "testing" + +const codexSandboxRuntimeKind = "codex_sandbox" + +func TestGatewayRuntimeKindClassifiesCodexSandboxButNotHostCodex(t *testing.T) { + if !isGatewayRuntimeKind(codexSandboxRuntimeKind) { + t.Fatalf("isGatewayRuntimeKind(%q) = false, want true", codexSandboxRuntimeKind) + } + if got := runtimeKindForGatewayRuntime(codexSandboxRuntimeKind); got != codexSandboxRuntimeKind { + t.Fatalf("runtimeKindForGatewayRuntime(%q) = %q, want %q", codexSandboxRuntimeKind, got, codexSandboxRuntimeKind) + } + + if isGatewayRuntimeKind(RuntimeKindCodex) { + t.Fatalf("host-local %q runtime must not be classified as a gateway sandbox runtime", RuntimeKindCodex) + } + if got := runtimeKindForGatewayRuntime(RuntimeKindCodex); got != "" { + t.Fatalf("runtimeKindForGatewayRuntime(%q) = %q, want empty", RuntimeKindCodex, got) + } +} diff --git a/internal/agent/runtime_selection_test.go b/internal/agent/runtime_selection_test.go index 57684bc0..b7ebb375 100644 --- a/internal/agent/runtime_selection_test.go +++ b/internal/agent/runtime_selection_test.go @@ -1,6 +1,7 @@ package agent import ( + "encoding/json" "strings" "testing" @@ -61,6 +62,16 @@ func TestAgentRuntimeConfigRoundTrip(t *testing.T) { } } +func TestCreateAgentSpecUnmarshalSupportsLegacyRuntimeKind(t *testing.T) { + var got CreateAgentSpec + if err := json.Unmarshal([]byte(`{"name":"alice","role":"worker","runtime_kind":"codex_sandbox"}`), &got); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if got.RuntimeKind != RuntimeKindCodexSandbox || got.RuntimeName != RuntimeNameCodex || !got.SandboxEnabled { + t.Fatalf("runtime = %q/%q/%t, want %q/%q/true", got.RuntimeKind, got.RuntimeName, got.SandboxEnabled, RuntimeKindCodexSandbox, RuntimeNameCodex) + } +} + func TestMergeReplaceSpecNormalizesRuntimeFields(t *testing.T) { existing := Agent{ ID: "agent-alice", diff --git a/internal/agent/service.go b/internal/agent/service.go index fbb57c02..251990e4 100644 --- a/internal/agent/service.go +++ b/internal/agent/service.go @@ -1853,7 +1853,7 @@ func (s *Service) CreateWorker(ctx context.Context, spec CreateAgentSpec) (Agent return Agent{}, fmt.Errorf("runtime_kind is required") case !sandboxed && runtimeName != RuntimeNameCodex: return Agent{}, fmt.Errorf("runtime_name %q requires sandbox_enabled=true", runtimeName) - case sandboxed && runtimeName != RuntimeNameOpenClaw && runtimeName != RuntimeNamePicoClaw: + case sandboxed && runtimeName != RuntimeNameOpenClaw && runtimeName != RuntimeNamePicoClaw && runtimeName != RuntimeNameCodex: return Agent{}, fmt.Errorf("runtime_name %q is not supported with sandbox_enabled=true", runtimeName) } if !sandboxed { diff --git a/internal/agent/service_profiles.go b/internal/agent/service_profiles.go index 7d4dabd2..f1cbac53 100644 --- a/internal/agent/service_profiles.go +++ b/internal/agent/service_profiles.go @@ -10,6 +10,7 @@ import ( "csgclaw/internal/channel/feishu" "csgclaw/internal/identity" agentruntime "csgclaw/internal/runtime" + "csgclaw/internal/runtime/codexsandbox" "csgclaw/internal/runtime/openclawsandbox" "csgclaw/internal/sandbox" "csgclaw/internal/utils" @@ -134,6 +135,9 @@ func gatewayProfileRuntimeRestartRequired(current Agent, next AgentProfile) bool if !isGatewayRuntimeKind(strings.TrimSpace(current.RuntimeKind)) { return false } + if strings.TrimSpace(current.RuntimeKind) == RuntimeKindCodexSandbox { + return false + } previous := normalizeProfileForAgentRuntime(current.AgentProfile, current.RuntimeOptions, current.Name, current.Description, current.RuntimeKind, nil) return !profileRuntimeInputsEqual(previous, next) } @@ -164,10 +168,21 @@ func (s *Service) syncGatewayAfterProfileChange(ctx context.Context, id string, _, err := s.EnsureManager(ctx, false) return err } + if strings.TrimSpace(got.RuntimeKind) == RuntimeKindCodexSandbox && restartRequired { + runtimeRunning := isRuntimeRunning(got) + if err := s.syncGatewayHostConfigWithRestartFlag(got, runtimeNormalized, !runtimeRunning); err != nil { + return err + } + if runtimeRunning { + _, err := s.Recreate(ctx, id) + return err + } + return nil + } if gatewayProfileRuntimeRestartRequired(previous, normalized) { return s.syncGatewayHostConfig(got, runtimeNormalized) } - if restartRequired { + if restartRequired && isRuntimeRunning(got) { _, err := s.Recreate(ctx, id) return err } @@ -175,6 +190,10 @@ func (s *Service) syncGatewayAfterProfileChange(ctx context.Context, id string, } func (s *Service) syncGatewayHostConfig(got Agent, profile AgentProfile) error { + return s.syncGatewayHostConfigWithRestartFlag(got, profile, true) +} + +func (s *Service) syncGatewayHostConfigWithRestartFlag(got Agent, profile AgentProfile, clearEnvRestartRequired bool) error { if s == nil { return nil } @@ -195,10 +214,23 @@ func (s *Service) syncGatewayHostConfig(got Agent, profile AgentProfile) error { if _, err := openclawsandbox.EnsureConfig(agentHome, participantID, got.ID, s.server, modelCfg, resolveManagerBaseURL, feishuProvider); err != nil { return fmt.Errorf("sync gateway openclaw config: %w", err) } + case RuntimeKindCodexSandbox: + agentHome, err := s.agentHomeDir(got.ID) + if err != nil { + return err + } + feishuProvider := s.currentFeishuProviderForRuntime(RuntimeKindCodexSandbox) + if _, err := codexsandbox.EnsureConfig(agentHome, participantID, got.ID, s.server, modelCfg, resolveManagerBaseURL, feishuProvider); err != nil { + return fmt.Errorf("sync gateway codex sandbox config: %w", err) + } default: return nil } + if !clearEnvRestartRequired { + return nil + } + s.mu.Lock() defer s.mu.Unlock() current, ok := s.agents[got.ID] diff --git a/internal/agent/service_test.go b/internal/agent/service_test.go index ea924ae4..bf8aef7d 100644 --- a/internal/agent/service_test.go +++ b/internal/agent/service_test.go @@ -17,6 +17,7 @@ import ( "csgclaw/internal/channel/feishu" "csgclaw/internal/config" agentruntime "csgclaw/internal/runtime" + "csgclaw/internal/runtime/codexsandbox" "csgclaw/internal/runtime/openclawsandbox" "csgclaw/internal/runtime/picoclawsandbox" "csgclaw/internal/runtime/sandboxgateway" @@ -178,6 +179,13 @@ func (f fakeAgentRuntime) Layout(agentHome string) agentruntime.Layout { SkillsRoot: filepath.Join(agentHome, ".codex", "home", "skills"), HostLogPaths: []string{filepath.Join(agentHome, ".codex", "home", "stderr.log")}, } + case RuntimeKindCodexSandbox: + workspace := filepath.Join(codexsandbox.Root(agentHome), codexsandbox.HostWorkspaceDir) + return agentruntime.Layout{ + WorkspaceRoot: workspace, + SkillsRoot: filepath.Join(workspace, "skills"), + HostLogPaths: []string{codexsandbox.HostGatewayLogPath(agentHome)}, + } default: return agentruntime.Layout{} } @@ -731,6 +739,63 @@ func TestCreateWorkerUsesCodexRuntimeWhenRequested(t *testing.T) { } } +func TestCreateWorkerUsesCodexSandboxRuntimeFromSplitFields(t *testing.T) { + svc, err := NewService( + testModelConfig(), + config.ServerConfig{ + ListenAddr: "0.0.0.0:18080", + AdvertiseBaseURL: "http://127.0.0.1:18080", + AccessToken: "shared-token", + }, "manager-image:test", "", + WithRuntime(fakeAgentRuntime{ + kind: RuntimeKindCodexSandbox, + new: func(_ context.Context, spec agentruntime.Spec) (agentruntime.Handle, error) { + if spec.AgentID != "agent-alice" { + t.Fatalf("Create() agent id = %q, want %q", spec.AgentID, "agent-alice") + } + if spec.AgentName != "alice" { + t.Fatalf("Create() agent name = %q, want %q", spec.AgentName, "alice") + } + return agentruntime.Handle{RuntimeID: spec.RuntimeID, HandleID: "codex-sandbox-alice"}, nil + }, + }), + ) + if err != nil { + t.Fatalf("NewService() error = %v", err) + } + + got, err := svc.CreateWorker(context.Background(), CreateAgentSpec{ + ID: "u-alice", + Name: "alice", + RuntimeName: RuntimeNameCodex, + SandboxEnabled: true, + Image: "codex-sandbox:test", + AgentProfile: AgentProfile{ + Name: "alice", + Provider: ProviderAPI, + BaseURL: "https://api.example/v1", + APIKey: "api-key", + ModelID: "gpt-5.5", + ProfileComplete: true, + }, + }) + if err != nil { + t.Fatalf("CreateWorker() error = %v", err) + } + if got.RuntimeKind != RuntimeKindCodexSandbox { + t.Fatalf("CreateWorker().RuntimeKind = %q, want %q", got.RuntimeKind, RuntimeKindCodexSandbox) + } + if got.RuntimeName != RuntimeNameCodex || !got.SandboxEnabled { + t.Fatalf("CreateWorker() runtime = %q/%t, want %q/true", got.RuntimeName, got.SandboxEnabled, RuntimeNameCodex) + } + if got.BoxID != "codex-sandbox-alice" { + t.Fatalf("CreateWorker().BoxID = %q, want %q", got.BoxID, "codex-sandbox-alice") + } + if rt := svc.runtimeRecords[got.RuntimeID]; rt.Kind != RuntimeKindCodexSandbox { + t.Fatalf("runtime record kind = %q, want %q", rt.Kind, RuntimeKindCodexSandbox) + } +} + func TestCreateWorkerPersistsCodexProfileBeforeRuntimeNew(t *testing.T) { t.Setenv("HOME", t.TempDir()) @@ -7674,6 +7739,29 @@ func TestGatewayProvisionRequestBuildsOpenClawWorkerAssets(t *testing.T) { } } +func TestGatewayProvisionRequestRejectsCodexSandboxManager(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + + svc, err := NewService( + testModelConfig(), + config.ServerConfig{ListenAddr: ":18080", AccessToken: "shared-token"}, + "manager-image:test", + "", + ) + if err != nil { + t.Fatalf("NewService() error = %v", err) + } + + _, err = svc.gatewayProvisionRequest(RuntimeKindCodexSandbox, ManagerName, ManagerUserID) + if err == nil { + t.Fatal("gatewayProvisionRequest() error = nil, want codex_sandbox manager rejection") + } + if !strings.Contains(err.Error(), "supports worker templates only") { + t.Fatalf("gatewayProvisionRequest() error = %v, want worker-only message", err) + } +} + func TestGatewayProvisionRequestUsesDockerHostAliasForImplicitAdvertiseURL(t *testing.T) { homeDir := t.TempDir() t.Setenv("HOME", homeDir) @@ -7799,6 +7887,13 @@ func testBuiltinLayout(agentName, runtimeKind string) (agentruntime.Layout, erro SkillsRoot: filepath.Join(workspace, "skills"), HostLogPaths: []string{openclawsandbox.HostGatewayLogPath(agentHome)}, }, nil + case RuntimeKindCodexSandbox: + workspace := filepath.Join(codexsandbox.Root(agentHome), codexsandbox.HostWorkspaceDir) + return agentruntime.Layout{ + WorkspaceRoot: workspace, + SkillsRoot: filepath.Join(workspace, "skills"), + HostLogPaths: []string{codexsandbox.HostGatewayLogPath(agentHome)}, + }, nil case RuntimeKindCodex: return agentruntime.Layout{ WorkspaceRoot: filepath.Join(agentHome, ".codex", "workspace"), @@ -8339,6 +8434,128 @@ func TestGatewayProfileRuntimeRestartNotRequiredForCodex(t *testing.T) { } } +func TestGatewayProfileRuntimeRestartNotRequiredForCodexSandbox(t *testing.T) { + current := Agent{ + RuntimeKind: RuntimeKindCodexSandbox, + Name: "alice", + AgentProfile: AgentProfile{ + Name: "alice", + Provider: ProviderAPI, + BaseURL: "https://api.example/v1", + APIKey: "api-key", + ModelID: "gpt-5.4", + ProfileComplete: true, + }, + } + next := normalizeProfile(AgentProfile{ + Name: "alice", + Provider: ProviderAPI, + BaseURL: "https://api.example/v1", + APIKey: "api-key", + ModelID: "gpt-5.5", + ProfileComplete: true, + }, "alice", "") + if gatewayProfileRuntimeRestartRequired(current, next) { + t.Fatal("gatewayProfileRuntimeRestartRequired() = true, want false because codex_sandbox handles host config sync before recreate") + } +} + +func TestUpdateAgentProfileCodexSandboxSyncsHostConfigBeforeRecreate(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + + rt := &fakeRuntime{} + recreateCalled := false + SetTestHooks( + func(_ *Service, _ string) (sandbox.Runtime, error) { return rt, nil }, + func(_ *Service, _ context.Context, _ sandbox.Runtime, _, _, _ string, profile AgentProfile) (sandbox.Instance, sandbox.Info, error) { + recreateCalled = true + if profile.ModelProviderID != ModelProviderIDOpenCSG || profile.ModelID != "qwen3.6-plus" { + t.Fatalf("recreate profile = %+v, want opencsg qwen3.6-plus", profile) + } + return nil, sandbox.Info{}, fmt.Errorf("sandbox exited before ready") + }, + ) + defer ResetTestHooks() + + svc, err := NewService(testModelConfig(), config.ServerConfig{ + ListenAddr: ":18080", + AdvertiseBaseURL: "http://127.0.0.1:18080", + AccessToken: "token", + }, "manager-image:test", "", WithRuntime(fakeAgentRuntime{ + kind: RuntimeKindCodexSandbox, + validate: func(_ context.Context, current agentruntime.RuntimeConfigSnapshot) error { + if current.Profile.ModelID != "qwen3.6-plus" { + t.Fatalf("ValidateConfig() model = %q, want qwen3.6-plus", current.Profile.ModelID) + } + return nil + }, + restart: func(change agentruntime.RuntimeConfigChange) (bool, error) { + return change.Previous.Profile.ModelID != change.Current.Profile.ModelID, nil + }, + info: func(context.Context, agentruntime.Handle) (agentruntime.Info, error) { + return agentruntime.Info{ + HandleID: "box-alice", + State: agentruntime.StateRunning, + }, nil + }, + })) + if err != nil { + t.Fatalf("NewService() error = %v", err) + } + agentID := "agent-alice" + svc.agents[agentID] = Agent{ + ID: agentID, + Name: "alice", + Role: RoleWorker, + RuntimeID: runtimeIDForAgentID(agentID), + RuntimeKind: RuntimeKindCodexSandbox, + BoxID: "box-alice", + Status: string(sandbox.StateRunning), + AgentProfile: AgentProfile{ + Name: "alice", + Provider: ProviderCSGHub, + ModelProviderID: ModelProviderIDOpenCSG, + ModelID: "gpt-5.5", + ProfileComplete: true, + }, + ProfileComplete: true, + } + + _, err = svc.UpdateAgentProfile(agentID, AgentProfile{ + Name: "alice", + ModelProviderID: ModelProviderIDOpenCSG, + ModelID: "qwen3.6-plus", + ProfileComplete: true, + }) + if err == nil || !strings.Contains(err.Error(), "sandbox exited before ready") { + t.Fatalf("UpdateAgentProfile() error = %v, want sandbox startup failure", err) + } + if !recreateCalled { + t.Fatal("UpdateAgentProfile() did not attempt codex_sandbox recreate") + } + + agentHome, err := agentHomeDir(agentID) + if err != nil { + t.Fatalf("agentHomeDir() error = %v", err) + } + configPath := filepath.Join(agentHome, codexsandbox.HostDir, codexsandbox.HostCodexHomeDir, "config.toml") + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("ReadFile(codex config) error = %v", err) + } + if !strings.Contains(string(data), `model = "qwen3.6-plus"`) { + t.Fatalf("codex config missing updated model:\n%s", data) + } + got, ok := svc.Agent(agentID) + if !ok { + t.Fatal("Agent() ok = false, want true") + } + if !got.AgentProfile.EnvRestartRequired { + t.Fatal("Agent().AgentProfile.EnvRestartRequired = false, want true after failed recreate") + } +} + func TestUpdateAgentProfileSyncsGatewayHostConfigWithoutRecreate(t *testing.T) { homeDir := t.TempDir() t.Setenv("HOME", homeDir) diff --git a/internal/agent/workspace.go b/internal/agent/workspace.go index ec747db2..0986f5e2 100644 --- a/internal/agent/workspace.go +++ b/internal/agent/workspace.go @@ -34,6 +34,11 @@ func workspaceTemplateForAgent(name, botID string) (string, error) { } func resolveRuntimeTemplateRoot(runtimeKind, role string) (string, error) { + runtimeKind = strings.TrimSpace(runtimeKind) + role = strings.TrimSpace(role) + if runtimeKind == RuntimeKindCodexSandbox && strings.EqualFold(role, RoleManager) { + return "", fmt.Errorf("runtime_kind %q supports worker templates only; use %q or %q for manager", runtimeKind, RuntimeKindPicoClawSandbox, RuntimeKindOpenClawSandbox) + } return templateembed.Resolve(runtimeKind, role) } diff --git a/internal/agent/workspace_test.go b/internal/agent/workspace_test.go index 0275a651..b40f4517 100644 --- a/internal/agent/workspace_test.go +++ b/internal/agent/workspace_test.go @@ -5,6 +5,7 @@ import ( "io/fs" "os" "path/filepath" + "strings" "testing" templateembed "csgclaw/internal/template/embed" @@ -98,11 +99,14 @@ func TestResolveRuntimeTemplateRoot(t *testing.T) { role string want string wantErr bool + wantErrText string }{ {name: "openclaw manager", runtimeKind: RuntimeKindOpenClawSandbox, role: RoleManager, want: templateembed.OpenClawManagerRoot}, {name: "picoclaw manager", runtimeKind: RuntimeKindPicoClawSandbox, role: RoleManager, want: templateembed.PicoClawManagerRoot}, {name: "picoclaw worker", runtimeKind: RuntimeKindPicoClawSandbox, role: RoleWorker, want: templateembed.PicoClawWorkerRoot}, {name: "openclaw worker", runtimeKind: RuntimeKindOpenClawSandbox, role: RoleWorker, want: templateembed.OpenClawWorkerRoot}, + {name: "codex sandbox worker", runtimeKind: RuntimeKindCodexSandbox, role: RoleWorker, want: templateembed.CodexSandboxWorkerRoot}, + {name: "codex sandbox manager", runtimeKind: RuntimeKindCodexSandbox, role: RoleManager, wantErr: true, wantErrText: "supports worker templates only"}, {name: "unknown runtime", runtimeKind: "missing", role: RoleWorker, wantErr: true}, } @@ -113,6 +117,9 @@ func TestResolveRuntimeTemplateRoot(t *testing.T) { if err == nil { t.Fatalf("resolveRuntimeTemplateRoot(%q, %q) error = nil, want non-nil", tt.runtimeKind, tt.role) } + if tt.wantErrText != "" && !strings.Contains(err.Error(), tt.wantErrText) { + t.Fatalf("resolveRuntimeTemplateRoot(%q, %q) error = %v, want text %q", tt.runtimeKind, tt.role, err, tt.wantErrText) + } return } if err != nil { diff --git a/internal/api/handler.go b/internal/api/handler.go index 791c6986..f2b82939 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -472,6 +472,7 @@ func bootstrapConfigView(ctx context.Context, cfg config.Config, hubSvc *hub.Ser SupportedRuntimeKinds: []string{ agentruntime.RuntimeConfigForKind(agent.RuntimeKindPicoClawSandbox).Kind(), agentruntime.RuntimeConfigForKind(agent.RuntimeKindOpenClawSandbox).Kind(), + agentruntime.RuntimeConfigForKind(agent.RuntimeKindCodexSandbox).Kind(), agentruntime.RuntimeConfigForKind(agent.RuntimeKindCodex).Kind(), }, RuntimeDefaultImages: map[string]string{}, @@ -509,6 +510,12 @@ func workerRuntimeChoices() []workerRuntimeChoiceResponse { SandboxEnabled: true, Installed: true, }, + { + Name: agent.RuntimeNameCodex, + Label: "Codex Sandbox", + SandboxEnabled: true, + Installed: true, + }, { Name: agent.RuntimeNamePicoClaw, Label: "PicoClaw", @@ -561,6 +568,7 @@ func fillBuiltinWorkerRuntimeDefaultImages(ctx context.Context, resp *bootstrapC builtinWorkerTemplates := map[string]string{ agentruntime.RuntimeConfigForKind(agent.RuntimeKindPicoClawSandbox).Kind(): "builtin.picoclaw-worker", agentruntime.RuntimeConfigForKind(agent.RuntimeKindOpenClawSandbox).Kind(): "builtin.openclaw-worker", + agentruntime.RuntimeConfigForKind(agent.RuntimeKindCodexSandbox).Kind(): "builtin.codex-sandbox-worker", } for runtimeKind, templateID := range builtinWorkerTemplates { if strings.TrimSpace(resp.RuntimeDefaultImages[runtimeKind]) != "" { @@ -585,6 +593,7 @@ func (h *Handler) bootstrapConfigView(ctx context.Context, cfg config.Config) bo schemas = h.runtimeOptionSchemasByKind([]string{ agent.RuntimeKindPicoClawSandbox, agent.RuntimeKindOpenClawSandbox, + agent.RuntimeKindCodexSandbox, agent.RuntimeKindCodex, }) } @@ -1229,6 +1238,10 @@ func agentCreateRequestFromAPI(req apitypes.CreateAgentRequest) agent.CreateRequ profileReq = req.AgentProfile } prof := agentProfileFromAPI(profileReq) + runtimeKind := strings.TrimSpace(req.Runtime.Kind) + if runtimeKind == "" { + runtimeKind = strings.TrimSpace(req.RuntimeKind) + } runtimeName := strings.TrimSpace(req.Runtime.Name) sandboxEnabled := req.Runtime.SandboxEnabled if runtimeName == "" { @@ -1246,6 +1259,7 @@ func agentCreateRequestFromAPI(req apitypes.CreateAgentRequest) agent.CreateRequ Description: req.Description, Instructions: req.Instructions, Image: req.Image, + RuntimeKind: runtimeKind, RuntimeName: runtimeName, SandboxEnabled: sandboxEnabled, FromTemplate: req.FromTemplate, diff --git a/internal/api/handler_test.go b/internal/api/handler_test.go index aeee409a..17454820 100644 --- a/internal/api/handler_test.go +++ b/internal/api/handler_test.go @@ -28,6 +28,7 @@ import ( "csgclaw/internal/participant" agentruntime "csgclaw/internal/runtime" codexruntime "csgclaw/internal/runtime/codex" + "csgclaw/internal/runtime/codexsandbox" "csgclaw/internal/runtime/openclawsandbox" "csgclaw/internal/runtime/picoclawsandbox" "csgclaw/internal/sandbox" @@ -51,7 +52,10 @@ func init() { if err := runtimewiring.WithPicoClawSandboxRuntime(nil)(s); err != nil { return err } - return runtimewiring.WithOpenClawSandboxRuntime(nil)(s) + if err := runtimewiring.WithOpenClawSandboxRuntime(nil)(s); err != nil { + return err + } + return runtimewiring.WithCodexSandboxRuntime(nil)(s) }) _ = codexruntime.TestOnlySetResponsesAPIProbe(func(context.Context, string, string, string, map[string]string) error { return nil @@ -92,6 +96,13 @@ func (f fakeCompatRuntime) Layout(agentHome string) agentruntime.Layout { SkillsRoot: filepath.Join(workspace, "skills"), HostLogPaths: []string{openclawsandbox.HostGatewayLogPath(agentHome)}, } + case agent.RuntimeKindCodexSandbox: + workspace := filepath.Join(codexsandbox.Root(agentHome), codexsandbox.HostWorkspaceDir) + return agentruntime.Layout{ + WorkspaceRoot: workspace, + SkillsRoot: filepath.Join(workspace, "skills"), + HostLogPaths: []string{codexsandbox.HostGatewayLogPath(agentHome)}, + } case agent.RuntimeKindCodex: return agentruntime.Layout{ WorkspaceRoot: filepath.Join(agentHome, ".codex", "workspace"), @@ -380,6 +391,27 @@ func TestBootstrapConfigIncludesBuiltinOpenClawRuntimeDefaultImage(t *testing.T) if !strings.Contains(openclawImage, "/opencsghq/openclaw:") { t.Fatalf("RuntimeDefaultImages[%q] = %q, want builtin OpenClaw image", agent.RuntimeNameOpenClaw, openclawImage) } + codexSandboxImage := got.RuntimeDefaultImages[agent.RuntimeKindCodexSandbox] + if codexSandboxImage == "" { + t.Fatalf("RuntimeDefaultImages[%q] = empty; defaults=%#v", agent.RuntimeKindCodexSandbox, got.RuntimeDefaultImages) + } + if !strings.Contains(codexSandboxImage, "/opencsghq/csgclaw-codex-sandbox:") { + t.Fatalf("RuntimeDefaultImages[%q] = %q, want builtin Codex Sandbox image", agent.RuntimeKindCodexSandbox, codexSandboxImage) + } +} + +func TestBootstrapConfigIncludesCodexSandboxWorkerChoice(t *testing.T) { + got := bootstrapConfigView(context.Background(), config.Config{}, nil, nil) + + for _, choice := range got.WorkerRuntimeChoices { + if choice.Name == agent.RuntimeNameCodex && choice.SandboxEnabled { + if choice.Label != "Codex Sandbox" || !choice.Installed { + t.Fatalf("codex sandbox choice = %#v, want installed Codex Sandbox", choice) + } + return + } + } + t.Fatalf("WorkerRuntimeChoices = %#v, want codex sandbox choice", got.WorkerRuntimeChoices) } func TestHandleAgentIncludesRuntimeOptionSchemas(t *testing.T) { @@ -2124,6 +2156,28 @@ func TestAgentCreateRequestFromAPIIncludesFromTemplate(t *testing.T) { } } +func TestAgentCreateRequestFromJSONIncludesProfile(t *testing.T) { + var req apitypes.CreateAgentRequest + if err := json.Unmarshal([]byte(`{"name":"alice","runtime_kind":"codex_sandbox","from_template":"local.codex","profile":"codex.gpt-5.5"}`), &req); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + + got := agentCreateRequestFromAPI(req) + + if got.Spec.Name != "alice" { + t.Fatalf("Spec.Name = %q, want %q", got.Spec.Name, "alice") + } + if got.Spec.RuntimeKind != agent.RuntimeKindCodexSandbox { + t.Fatalf("Spec.RuntimeKind = %q, want %q", got.Spec.RuntimeKind, agent.RuntimeKindCodexSandbox) + } + if got.Spec.FromTemplate != "local.codex" { + t.Fatalf("Spec.FromTemplate = %q, want %q", got.Spec.FromTemplate, "local.codex") + } + if got.Spec.Profile != "codex.gpt-5.5" { + t.Fatalf("Spec.Profile = %q, want %q", got.Spec.Profile, "codex.gpt-5.5") + } +} + func TestHandleHubTemplatesListsAggregatedTemplates(t *testing.T) { hubSvc := mustNewLocalTemplateHubService(t, "review-bot", hub.Template{ ID: "review-bot", diff --git a/internal/api/participant_test.go b/internal/api/participant_test.go index 5924cf21..09e107fa 100644 --- a/internal/api/participant_test.go +++ b/internal/api/participant_test.go @@ -47,7 +47,7 @@ func TestCreateCSGClawAgentParticipantViaAPI(t *testing.T) { "agent": { "name": "QA-Display-Name", "role": "worker", - "runtime_kind": "picoclaw_sandbox", + "runtime_kind": "openclaw_sandbox", "image": "agent-image:test", "avatar": "avatar/3D-5.png" } @@ -78,6 +78,9 @@ func TestCreateCSGClawAgentParticipantViaAPI(t *testing.T) { if createdAgent.Avatar != "" { t.Fatalf("agent avatar = %q, want empty", createdAgent.Avatar) } + if createdAgent.RuntimeKind != agent.RuntimeKindOpenClawSandbox || createdAgent.RuntimeName != agent.RuntimeNameOpenClaw || !createdAgent.SandboxEnabled { + t.Fatalf("agent runtime = %q/%q/%t, want %q/%q/true", createdAgent.RuntimeKind, createdAgent.RuntimeName, createdAgent.SandboxEnabled, agent.RuntimeKindOpenClawSandbox, agent.RuntimeNameOpenClaw) + } if _, ok := agentSvc.Agent("u-qa-display-name"); ok { t.Fatal("agent ID was derived from display name") } @@ -88,6 +91,60 @@ func TestCreateCSGClawAgentParticipantViaAPI(t *testing.T) { } } +func TestCreateCSGClawAgentParticipantViaAPICreatesCodexSandbox(t *testing.T) { + agentSvc, _ := mustNewSeededServiceWithPathAndOptions(t, nil, + agent.WithRuntime(fakeCompatRuntime{kind: agent.RuntimeKindCodexSandbox}), + ) + imSvc := im.NewService() + participantSvc := participant.NewService( + participant.NewMemoryStore(nil), + participant.WithAgentService(agentSvc), + participant.WithIMService(imSvc), + ) + srv := &Handler{ + svc: agentSvc, + im: imSvc, + participant: participantSvc, + } + + body := `{ + "id": "codex-sandbox", + "type": "agent", + "name": "codex-sandbox", + "agent_binding": { + "mode": "create", + "agent": { + "name": "codex-sandbox-worker", + "role": "worker", + "runtime_kind": "codex_sandbox", + "runtime": { + "name": "codex", + "sandbox_enabled": true + }, + "image": "codex-sandbox:test" + } + } + }` + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/participants", strings.NewReader(body)) + + srv.Routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusCreated, rec.Body.String()) + } + createdAgent, ok := agentSvc.Agent("agent-codex-sandbox") + if !ok { + t.Fatal("agent agent-codex-sandbox was not created") + } + if createdAgent.RuntimeKind != agent.RuntimeKindCodexSandbox || createdAgent.RuntimeName != agent.RuntimeNameCodex || !createdAgent.SandboxEnabled { + t.Fatalf("agent runtime = %q/%q/%t, want %q/%q/true", createdAgent.RuntimeKind, createdAgent.RuntimeName, createdAgent.SandboxEnabled, agent.RuntimeKindCodexSandbox, agent.RuntimeNameCodex) + } + if createdAgent.BoxID != "box-codex-sandbox-worker" { + t.Fatalf("agent BoxID = %q, want codex sandbox runtime handle", createdAgent.BoxID) + } +} + func TestCreateCSGClawAgentParticipantViaAPIReturnsConflictForDuplicateAgentName(t *testing.T) { agentSvc, _ := mustNewSeededServiceWithPath(t, []agent.Agent{{ ID: "u-existing", diff --git a/internal/apitypes/agent.go b/internal/apitypes/agent.go index 4984a81c..161b7979 100644 --- a/internal/apitypes/agent.go +++ b/internal/apitypes/agent.go @@ -17,6 +17,9 @@ func runtimeKindFromSelection(name string, sandboxEnabled bool) string { return "openclaw" } case "codex": + if sandboxEnabled { + return "codex_sandbox" + } if !sandboxEnabled { return "codex" } @@ -30,6 +33,8 @@ func runtimeSelectionForKind(kind string) (string, bool) { return "picoclaw", true case "openclaw", "openclaw_sandbox": return "openclaw", true + case "codex_sandbox": + return "codex", true case "codex": return "codex", false default: @@ -234,28 +239,29 @@ type CreateAgentRequest struct { AgentProfile *CreateAgentProfile `json:"agent_profile,omitempty"` } +type createAgentRequestJSON struct { + ID string `json:"id,omitempty"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Instructions string `json:"instructions,omitempty"` + Image string `json:"image,omitempty"` + RuntimeKind string `json:"runtime_kind,omitempty"` + RuntimeName string `json:"runtime_name,omitempty"` + SandboxEnabled bool `json:"sandbox_enabled,omitempty"` + FromTemplate string `json:"from_template,omitempty"` + Replace bool `json:"replace,omitempty"` + FieldMask []string `json:"field_mask,omitempty"` + Role string `json:"role,omitempty"` + Status string `json:"status,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` + Runtime AgentRuntime `json:"runtime,omitempty"` + RuntimeOptions map[string]any `json:"runtime_options,omitempty"` + ModelConfig *CreateAgentProfile `json:"model_config,omitempty"` + Profile json.RawMessage `json:"profile,omitempty"` + AgentProfile *CreateAgentProfile `json:"agent_profile,omitempty"` +} + func (r CreateAgentRequest) MarshalJSON() ([]byte, error) { - type createAgentRequestJSON struct { - ID string `json:"id,omitempty"` - Name string `json:"name"` - Description string `json:"description,omitempty"` - Instructions string `json:"instructions,omitempty"` - Image string `json:"image,omitempty"` - RuntimeName string `json:"runtime_name,omitempty"` - SandboxEnabled bool `json:"sandbox_enabled,omitempty"` - FromTemplate string `json:"from_template,omitempty"` - Replace bool `json:"replace,omitempty"` - FieldMask []string `json:"field_mask,omitempty"` - Role string `json:"role,omitempty"` - Status string `json:"status,omitempty"` - CreatedAt time.Time `json:"created_at,omitempty"` - Runtime AgentRuntime `json:"runtime,omitempty"` - RuntimeOptions map[string]any `json:"runtime_options,omitempty"` - ModelConfig *CreateAgentProfile `json:"model_config,omitempty"` - Profile string `json:"profile,omitempty"` - AgentProfile *CreateAgentProfile `json:"agent_profile,omitempty"` - } - profile := strings.TrimSpace(r.Profile) runtime := r.Runtime if strings.TrimSpace(runtime.Kind) == "" { runtime.Kind = strings.TrimSpace(r.RuntimeKind) @@ -272,6 +278,14 @@ func (r CreateAgentRequest) MarshalJSON() ([]byte, error) { if !runtime.SandboxEnabled { runtime.SandboxEnabled = r.SandboxEnabled } + var profile json.RawMessage + if selector := strings.TrimSpace(r.Profile); selector != "" { + payload, err := json.Marshal(selector) + if err != nil { + return nil, err + } + profile = payload + } return json.Marshal(createAgentRequestJSON{ ID: r.ID, Name: r.Name, @@ -295,16 +309,12 @@ func (r CreateAgentRequest) MarshalJSON() ([]byte, error) { } func (r *CreateAgentRequest) UnmarshalJSON(data []byte) error { - type createAgentRequestAlias CreateAgentRequest - type createAgentRequestJSON struct { - createAgentRequestAlias - Profile string `json:"profile,omitempty"` - RuntimeKind string `json:"runtime_kind,omitempty"` - RuntimeName string `json:"runtime_name,omitempty"` - SandboxEnabled *bool `json:"sandbox_enabled,omitempty"` - RuntimeOptions map[string]any `json:"runtime_options,omitempty"` - ModelConfig *CreateAgentProfile `json:"model_config,omitempty"` - AgentProfile *CreateAgentProfile `json:"agent_profile,omitempty"` + var decoded createAgentRequestJSON + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + var runtimeFields struct { + SandboxEnabled *bool `json:"sandbox_enabled,omitempty"` Runtime struct { Kind string `json:"kind,omitempty"` Name string `json:"name,omitempty"` @@ -312,58 +322,85 @@ func (r *CreateAgentRequest) UnmarshalJSON(data []byte) error { Options map[string]any `json:"options,omitempty"` } `json:"runtime,omitempty"` } - var decoded createAgentRequestJSON - if err := json.Unmarshal(data, &decoded); err != nil { + if err := json.Unmarshal(data, &runtimeFields); err != nil { return err } - *r = CreateAgentRequest(decoded.createAgentRequestAlias) - r.Profile = strings.TrimSpace(decoded.Profile) - r.RuntimeKind = strings.TrimSpace(decoded.RuntimeKind) - r.RuntimeName = strings.TrimSpace(decoded.RuntimeName) - r.RuntimeOptions = decoded.RuntimeOptions - r.ProfileConfig = decoded.ModelConfig - r.AgentProfile = decoded.AgentProfile - r.Runtime.Kind = strings.TrimSpace(decoded.Runtime.Kind) - r.Runtime.Name = strings.TrimSpace(decoded.Runtime.Name) - if len(decoded.Runtime.Options) > 0 { - r.Runtime.Options = decoded.Runtime.Options - } - if decoded.SandboxEnabled != nil { - r.SandboxEnabled = *decoded.SandboxEnabled - } - if strings.TrimSpace(r.RuntimeKind) == "" { - r.RuntimeKind = strings.TrimSpace(decoded.Runtime.Kind) - } - if strings.TrimSpace(r.RuntimeName) == "" { - r.RuntimeName = strings.TrimSpace(decoded.Runtime.Name) + out := CreateAgentRequest{ + ID: decoded.ID, + Name: decoded.Name, + Description: decoded.Description, + Instructions: decoded.Instructions, + Image: decoded.Image, + RuntimeKind: strings.TrimSpace(decoded.RuntimeKind), + RuntimeName: strings.TrimSpace(decoded.RuntimeName), + SandboxEnabled: decoded.SandboxEnabled, + FromTemplate: decoded.FromTemplate, + Replace: decoded.Replace, + FieldMask: decoded.FieldMask, + Role: decoded.Role, + Status: decoded.Status, + CreatedAt: decoded.CreatedAt, + Runtime: decoded.Runtime, + RuntimeOptions: decoded.RuntimeOptions, + ProfileConfig: decoded.ModelConfig, + AgentProfile: decoded.AgentProfile, + } + if runtimeFields.SandboxEnabled != nil { + out.SandboxEnabled = *runtimeFields.SandboxEnabled + } + if strings.TrimSpace(runtimeFields.Runtime.Kind) != "" { + out.Runtime.Kind = strings.TrimSpace(runtimeFields.Runtime.Kind) + } + if strings.TrimSpace(runtimeFields.Runtime.Name) != "" { + out.Runtime.Name = strings.TrimSpace(runtimeFields.Runtime.Name) + } + if runtimeFields.Runtime.SandboxEnabled != nil && !out.SandboxEnabled { + out.SandboxEnabled = *runtimeFields.Runtime.SandboxEnabled + } + if len(out.Runtime.Options) == 0 && len(runtimeFields.Runtime.Options) > 0 { + out.Runtime.Options = runtimeFields.Runtime.Options + } + if len(decoded.Profile) > 0 && string(decoded.Profile) != "null" { + var selector string + if err := json.Unmarshal(decoded.Profile, &selector); err == nil { + out.Profile = strings.TrimSpace(selector) + } else { + var profile CreateAgentProfile + if err := json.Unmarshal(decoded.Profile, &profile); err != nil { + return err + } + if out.ProfileConfig == nil { + out.ProfileConfig = &profile + } + } } - if !r.SandboxEnabled && decoded.Runtime.SandboxEnabled != nil { - r.SandboxEnabled = *decoded.Runtime.SandboxEnabled + out.Runtime.Kind = strings.TrimSpace(out.Runtime.Kind) + out.Runtime.Name = strings.TrimSpace(out.Runtime.Name) + if strings.TrimSpace(out.RuntimeKind) == "" { + out.RuntimeKind = strings.TrimSpace(out.Runtime.Kind) } - r.Runtime.SandboxEnabled = r.SandboxEnabled - if len(r.Runtime.Options) == 0 && len(decoded.Runtime.Options) > 0 { - r.Runtime.Options = decoded.Runtime.Options + if strings.TrimSpace(out.RuntimeName) == "" { + out.RuntimeName = strings.TrimSpace(out.Runtime.Name) } - if strings.TrimSpace(r.RuntimeName) == "" { - r.RuntimeName = strings.TrimSpace(r.Runtime.Name) + if !out.SandboxEnabled { + out.SandboxEnabled = out.Runtime.SandboxEnabled } - if strings.TrimSpace(r.RuntimeName) == "" { - if name, _ := runtimeSelectionForKind(r.RuntimeKind); name != "" { - r.RuntimeName = name + if strings.TrimSpace(out.RuntimeName) == "" { + if name, _ := runtimeSelectionForKind(out.RuntimeKind); name != "" { + out.RuntimeName = name } } - if !r.SandboxEnabled { - r.SandboxEnabled = r.Runtime.SandboxEnabled - } - if !r.SandboxEnabled { - _, r.SandboxEnabled = runtimeSelectionForKind(r.RuntimeKind) + if !out.SandboxEnabled { + _, out.SandboxEnabled = runtimeSelectionForKind(out.RuntimeKind) } - if len(r.RuntimeOptions) == 0 && len(r.Runtime.Options) > 0 { - r.RuntimeOptions = r.Runtime.Options + if len(out.RuntimeOptions) == 0 && len(out.Runtime.Options) > 0 { + out.RuntimeOptions = out.Runtime.Options } - if strings.TrimSpace(r.RuntimeKind) == "" { - r.RuntimeKind = runtimeKindFromSelection(r.RuntimeName, r.SandboxEnabled) + if strings.TrimSpace(out.RuntimeKind) == "" { + out.RuntimeKind = runtimeKindFromSelection(out.RuntimeName, out.SandboxEnabled) } + out.Runtime.SandboxEnabled = out.SandboxEnabled + *r = out return nil } diff --git a/internal/apitypes/agent_test.go b/internal/apitypes/agent_test.go index 9b4604f2..102af230 100644 --- a/internal/apitypes/agent_test.go +++ b/internal/apitypes/agent_test.go @@ -55,3 +55,68 @@ func TestCreateAgentRequestUnmarshalJSONSupportsBareSandboxRuntimeKind(t *testin t.Fatal("SandboxEnabled = false, want true") } } + +func TestCreateAgentRequestMarshalUsesSplitRuntimeFields(t *testing.T) { + data, err := json.Marshal(CreateAgentRequest{ + Name: "alice", + RuntimeKind: "codex_sandbox", + }) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + + var got map[string]any + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("decode marshaled request: %v", err) + } + if _, ok := got["runtime_kind"]; ok { + t.Fatalf("runtime_kind = %#v, want omitted", got["runtime_kind"]) + } + if got["runtime_name"] != "codex" { + t.Fatalf("runtime_name = %#v, want %q", got["runtime_name"], "codex") + } + if got["sandbox_enabled"] != true { + t.Fatalf("sandbox_enabled = %#v, want true", got["sandbox_enabled"]) + } + runtime, ok := got["runtime"].(map[string]any) + if !ok { + t.Fatalf("runtime = %#v, want object", got["runtime"]) + } + if runtime["name"] != "codex" || runtime["sandbox_enabled"] != true { + t.Fatalf("runtime = %#v, want codex sandbox selection", runtime) + } +} + +func TestCreateAgentRequestUnmarshalProfileSelector(t *testing.T) { + var got CreateAgentRequest + if err := json.Unmarshal([]byte(`{"name":"alice","profile":" codex.gpt-5.5 "}`), &got); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + + if got.Profile != "codex.gpt-5.5" { + t.Fatalf("Profile = %q, want %q", got.Profile, "codex.gpt-5.5") + } + if got.ProfileConfig != nil { + t.Fatalf("ProfileConfig = %+v, want nil for selector profile", got.ProfileConfig) + } +} + +func TestCreateAgentRequestUnmarshalProfileObject(t *testing.T) { + var got CreateAgentRequest + if err := json.Unmarshal([]byte(`{"name":"alice","profile":{"model_provider_id":"codex","model_id":"gpt-5.5","env":{"OPENAI_API_KEY":"from-env"}}}`), &got); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + + if got.Profile != "" { + t.Fatalf("Profile = %q, want empty selector for object profile", got.Profile) + } + if got.ProfileConfig == nil { + t.Fatalf("ProfileConfig = nil, want decoded object profile") + } + if got.ProfileConfig.ModelProviderID != "codex" || got.ProfileConfig.ModelID != "gpt-5.5" { + t.Fatalf("ProfileConfig = %+v, want codex/gpt-5.5", got.ProfileConfig) + } + if got.ProfileConfig.Env["OPENAI_API_KEY"] != "from-env" { + t.Fatalf("ProfileConfig.Env = %+v, want OPENAI_API_KEY", got.ProfileConfig.Env) + } +} diff --git a/internal/app/runtimewiring/codexsandbox.go b/internal/app/runtimewiring/codexsandbox.go new file mode 100644 index 00000000..4b3c4682 --- /dev/null +++ b/internal/app/runtimewiring/codexsandbox.go @@ -0,0 +1,67 @@ +package runtimewiring + +import ( + "fmt" + "strings" + + "csgclaw/internal/agent" + "csgclaw/internal/channel/feishu" + agentruntime "csgclaw/internal/runtime" + "csgclaw/internal/runtime/codexsandbox" + "csgclaw/internal/runtime/sandboxgateway" +) + +func WithCodexSandboxRuntime(feishuProvider feishu.AgentCredentialProvider) agent.ServiceOption { + return func(s *agent.Service) error { + if s == nil { + return fmt.Errorf("agent service is required") + } + host := s.PicoClawRuntimeHost() + return withSandboxRuntimeHost(host, feishuProvider, codexSandboxRuntimeEnvVars, func(deps sandboxgateway.Dependencies) agentruntime.Runtime { + return codexsandbox.New(deps) + })(s) + } +} + +func UpdateCodexSandboxFeishuProvider(svc *agent.Service, provider feishu.AgentCredentialProvider) { + updateRuntimeFeishuProvider(svc, agentruntime.KindCodexSandbox, provider) +} + +func codexSandboxRuntimeEnvVars(baseURL, accessToken, participantID, agentID, llmBaseURL, modelID string, provider feishu.AgentCredentialProvider) map[string]string { + env := bridgeLLMEnvVars(llmBaseURL, accessToken, modelID) + env["CSGCLAW_BASE_URL"] = baseURL + env["CSGCLAW_ACCESS_TOKEN"] = accessToken + env["CSGCLAW_PARTICIPANT_ID"] = participantID + env["CSGCLAW_AGENT_ID"] = agentID + env["LARK_CHANNEL_HOME"] = codexsandbox.BoxDir + env["LARK_CHANNEL_PROFILE"] = codexsandbox.ProfileName + env["LARK_CHANNEL_CONFIG"] = codexsandbox.BoxConfigPath + env["LARK_CHANNEL_CODEX_BIN"] = "/usr/local/bin/codex" + env["CODEX_HOME"] = codexsandbox.BoxCodexHomeDir + env["CSGCLAW_CODEX_GATEWAY_WORKSPACE"] = codexsandbox.BoxProjectsDir + addCodexSandboxFeishuEnvVars(env, agentID, provider) + return env +} + +func addCodexSandboxFeishuEnvVars(envVars map[string]string, agentID string, provider feishu.AgentCredentialProvider) { + if envVars == nil { + return + } + agentID = strings.TrimSpace(agentID) + if agentID == "" || provider == nil { + return + } + participantID, app, ok := provider.BotConfigForAgent(agentID) + if !ok { + return + } + appID := strings.TrimSpace(app.AppID) + appSecret := strings.TrimSpace(app.AppSecret) + if appID == "" || appSecret == "" { + return + } + envVars["LARK_CHANNEL_APP_ID"] = appID + envVars["LARK_CHANNEL_PARTICIPANT_ID"] = strings.TrimSpace(participantID) + envVars["LARK_CHANNEL_TENANT"] = "feishu" + envVars["APP_SECRET"] = appSecret +} diff --git a/internal/app/runtimewiring/codexsandbox_test.go b/internal/app/runtimewiring/codexsandbox_test.go new file mode 100644 index 00000000..61e52751 --- /dev/null +++ b/internal/app/runtimewiring/codexsandbox_test.go @@ -0,0 +1,109 @@ +package runtimewiring + +import ( + "strings" + "testing" + + "csgclaw/internal/channel/feishu" + "csgclaw/internal/runtime/codexsandbox" +) + +func TestCodexSandboxRuntimeEnvVarsExposeCSGClawAndFeishuTenantContract(t *testing.T) { + env := codexSandboxRuntimeEnvVars( + "http://10.0.0.8:18080", + "shared-token", + "pt-dev-local", + "u-dev", + "http://10.0.0.8:18080/api/v1/agents/u-dev/llm", + "gpt-5.5", + staticFeishuProvider{apps: map[string]feishu.AppConfig{ + "dev": {AppID: "cli_dev", AppSecret: "dev-secret"}, + }}, + ) + + want := map[string]string{ + "CSGCLAW_BASE_URL": "http://10.0.0.8:18080", + "CSGCLAW_ACCESS_TOKEN": "shared-token", + "CSGCLAW_PARTICIPANT_ID": "pt-dev-local", + "CSGCLAW_AGENT_ID": "u-dev", + "CSGCLAW_LLM_BASE_URL": "http://10.0.0.8:18080/api/v1/agents/u-dev/llm", + "CSGCLAW_LLM_API_KEY": "shared-token", + "CSGCLAW_LLM_MODEL_ID": "gpt-5.5", + "OPENAI_BASE_URL": "http://10.0.0.8:18080/api/v1/agents/u-dev/llm", + "OPENAI_API_KEY": "shared-token", + "OPENAI_MODEL": "gpt-5.5", + "LARK_CHANNEL_HOME": codexsandbox.BoxDir, + "LARK_CHANNEL_PROFILE": codexsandbox.ProfileName, + "LARK_CHANNEL_CONFIG": codexsandbox.BoxConfigPath, + "LARK_CHANNEL_CODEX_BIN": "/usr/local/bin/codex", + "CODEX_HOME": codexsandbox.BoxCodexHomeDir, + "CSGCLAW_CODEX_GATEWAY_WORKSPACE": codexsandbox.BoxProjectsDir, + "LARK_CHANNEL_APP_ID": "cli_dev", + "LARK_CHANNEL_PARTICIPANT_ID": "dev", + "LARK_CHANNEL_TENANT": "feishu", + "APP_SECRET": "dev-secret", + } + for key, wantValue := range want { + if got := env[key]; got != wantValue { + t.Errorf("%s = %q, want %q", key, got, wantValue) + } + } + for key, value := range env { + if key == "APP_SECRET" { + continue + } + if strings.Contains(value, "dev-secret") { + t.Errorf("%s leaked app secret outside APP_SECRET", key) + } + } +} + +func TestCodexSandboxRuntimeEnvVarsEnableFeishuOnlyForConfiguredAgent(t *testing.T) { + env := codexSandboxRuntimeEnvVars( + "http://10.0.0.8:18080", + "shared-token", + "missing", + "u-missing", + "http://10.0.0.8:18080/api/v1/agents/u-missing/llm", + "gpt-5.5", + staticFeishuProvider{apps: map[string]feishu.AppConfig{ + "dev": {AppID: "cli_dev", AppSecret: "dev-secret"}, + }}, + ) + for _, key := range []string{ + "LARK_CHANNEL_APP_ID", + "LARK_CHANNEL_PARTICIPANT_ID", + "APP_SECRET", + } { + if _, ok := env[key]; ok { + t.Fatalf("%s should not be emitted for an unconfigured Feishu bot", key) + } + } + + env = codexSandboxRuntimeEnvVars( + "http://10.0.0.8:18080", + "shared-token", + "dev", + "u-dev", + "http://10.0.0.8:18080/api/v1/agents/u-dev/llm", + "gpt-5.5", + staticFeishuProvider{apps: map[string]feishu.AppConfig{ + "dev": {AppID: "cli_dev", AppSecret: "dev-secret"}, + }}, + ) + if got, want := env["LARK_CHANNEL_APP_ID"], "cli_dev"; got != want { + t.Fatalf("LARK_CHANNEL_APP_ID = %q, want %q", got, want) + } + if got, want := env["LARK_CHANNEL_PARTICIPANT_ID"], "dev"; got != want { + t.Fatalf("LARK_CHANNEL_PARTICIPANT_ID = %q, want %q", got, want) + } + if got, want := env["APP_SECRET"], "dev-secret"; got != want { + t.Fatalf("APP_SECRET = %q, want %q", got, want) + } + if got, want := env["OPENAI_BASE_URL"], "http://10.0.0.8:18080/api/v1/agents/u-dev/llm"; got != want { + t.Fatalf("OPENAI_BASE_URL = %q, want %q", got, want) + } + if got, want := env["CODEX_HOME"], codexsandbox.BoxCodexHomeDir; got != want { + t.Fatalf("CODEX_HOME = %q, want %q", got, want) + } +} diff --git a/internal/app/runtimewiring/sandbox.go b/internal/app/runtimewiring/sandbox.go index fb6df0a3..f276c43c 100644 --- a/internal/app/runtimewiring/sandbox.go +++ b/internal/app/runtimewiring/sandbox.go @@ -125,8 +125,8 @@ func bridgeLLMEnvVars(llmBaseURL, accessToken, modelID string) map[string]string 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" { + if upper == "HOME" || upper == "APP_SECRET" || upper == "CODEX_HOME" || upper == "OPENAI_BASE_URL" || upper == "OPENAI_API_KEY" || upper == "OPENAI_MODEL" { return true } - return strings.HasPrefix(upper, "CSGCLAW_") || strings.HasPrefix(upper, "PICOCLAW_") + return strings.HasPrefix(upper, "CSGCLAW_") || strings.HasPrefix(upper, "PICOCLAW_") || strings.HasPrefix(upper, "LARK_CHANNEL_") || strings.HasPrefix(upper, "LARKSUITE_") } diff --git a/internal/config/config.go b/internal/config/config.go index 0f541e11..2b8e482e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -71,6 +71,7 @@ func (c BootstrapConfig) ResolvedDefaultWorkerTemplate() string { const ( RuntimeKindPicoClawSandbox = "picoclaw_sandbox" RuntimeKindOpenClawSandbox = "openclaw_sandbox" + RuntimeKindCodexSandbox = "codex_sandbox" ) func (b BootstrapConfig) Validate() error { diff --git a/internal/runtime/codex_sandbox_kind_test.go b/internal/runtime/codex_sandbox_kind_test.go new file mode 100644 index 00000000..4c287756 --- /dev/null +++ b/internal/runtime/codex_sandbox_kind_test.go @@ -0,0 +1,12 @@ +package runtime + +import "testing" + +func TestCodexSandboxKindIsDistinctFromHostLocalCodex(t *testing.T) { + if got, want := KindCodexSandbox, "codex_sandbox"; got != want { + t.Fatalf("KindCodexSandbox = %q, want %q", got, want) + } + if KindCodexSandbox == KindCodex { + t.Fatalf("KindCodexSandbox must be a managed sandbox runtime distinct from host-local %q", KindCodex) + } +} diff --git a/internal/runtime/codexsandbox/config.go b/internal/runtime/codexsandbox/config.go new file mode 100644 index 00000000..59726161 --- /dev/null +++ b/internal/runtime/codexsandbox/config.go @@ -0,0 +1,304 @@ +package codexsandbox + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "csgclaw/internal/channel/feishu" + "csgclaw/internal/codexmodel" + "csgclaw/internal/config" +) + +const ( + HostDir = ".codex-sandbox" + HostConfig = "config.json" + HostGatewayLog = "gateway.log" + HostWorkspaceDir = "workspace" + HostCodexHomeDir = "codex-home" + + BoxUserHome = "/home/codex" + BoxDir = BoxUserHome + "/.codex-sandbox" + BoxConfigPath = BoxDir + "/" + HostConfig + BoxWorkspaceDir = BoxDir + "/workspace" + BoxProjectsDir = BoxWorkspaceDir + "/projects" + BoxCodexHomeDir = BoxDir + "/" + HostCodexHomeDir + BoxGatewayLogPath = BoxDir + "/" + HostGatewayLog + + ProfileName = "csgclaw" + appSecretEnvKey = "APP_SECRET" + defaultCodexBinary = "/usr/local/bin/codex" + codexConfigFile = "config.toml" + modelCatalogFile = "model_catalog.json" + csgclawProviderID = "csgclaw-llm" +) + +type BaseURLResolver func(config.ServerConfig) string + +func Root(agentHome string) string { + return filepath.Join(agentHome, HostDir) +} + +func workspaceRoot(agentHome string) string { + return filepath.Join(Root(agentHome), HostWorkspaceDir) +} + +func HostGatewayLogPath(agentHome string) string { + return filepath.Join(Root(agentHome), HostGatewayLog) +} + +func EnsureConfig(agentHome, participantID, agentID string, server config.ServerConfig, model config.ModelConfig, resolveBaseURL BaseURLResolver, provider feishu.AgentCredentialProvider) (string, error) { + hostRoot := Root(agentHome) + if err := os.MkdirAll(hostRoot, 0o755); err != nil { + return "", fmt.Errorf("create codex sandbox config dir: %w", err) + } + if err := os.MkdirAll(filepath.Join(hostRoot, HostWorkspaceDir, "projects"), 0o755); err != nil { + return "", fmt.Errorf("create codex sandbox workspace projects dir: %w", err) + } + if err := os.MkdirAll(filepath.Join(hostRoot, HostCodexHomeDir), 0o755); err != nil { + return "", fmt.Errorf("create codex sandbox codex home dir: %w", err) + } + if err := ensureGatewayLogFile(hostRoot); err != nil { + return "", err + } + if err := ensureCodexHomeConfig(hostRoot, agentID, server, model, resolveBaseURL); err != nil { + return "", err + } + + data, err := RenderConfig(participantID, agentID, server, model, resolveBaseURL, provider) + if err != nil { + return "", err + } + configPath := filepath.Join(hostRoot, HostConfig) + if err := os.WriteFile(configPath, append(data, '\n'), 0o600); err != nil { + return "", fmt.Errorf("write codex sandbox config: %w", err) + } + return hostRoot, nil +} + +func ensureGatewayLogFile(hostRoot string) error { + target := filepath.Join(hostRoot, HostGatewayLog) + file, err := os.OpenFile(target, os.O_CREATE, 0o600) + if err != nil { + return fmt.Errorf("create codex sandbox gateway log: %w", err) + } + return file.Close() +} + +func ensureCodexHomeConfig(hostRoot, agentID string, server config.ServerConfig, model config.ModelConfig, resolveBaseURL BaseURLResolver) error { + codexHome := filepath.Join(hostRoot, HostCodexHomeDir) + model = model.Resolved() + if strings.TrimSpace(model.ModelID) == "" { + return nil + } + baseURL := llmBridgeBaseURL(managerBaseURL(server, resolveBaseURL), agentID) + if baseURL == "" { + return nil + } + + configPath := filepath.Join(codexHome, codexConfigFile) + if err := os.WriteFile(configPath, []byte(renderCodexConfig(model.ModelID, baseURL)), 0o600); err != nil { + return fmt.Errorf("write codex sandbox codex config: %w", err) + } + catalog, err := json.MarshalIndent(codexmodel.Catalog(codexmodel.Profile{ + ModelID: strings.TrimSpace(model.ModelID), + ReasoningEffort: strings.TrimSpace(model.ReasoningEffort), + }), "", " ") + if err != nil { + return fmt.Errorf("encode codex sandbox model catalog: %w", err) + } + if err := os.WriteFile(filepath.Join(codexHome, modelCatalogFile), append(catalog, '\n'), 0o600); err != nil { + return fmt.Errorf("write codex sandbox model catalog: %w", err) + } + return nil +} + +func renderCodexConfig(modelID, baseURL string) string { + modelID = strings.TrimSpace(modelID) + baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") + if modelID == "" || baseURL == "" { + return "" + } + var b strings.Builder + fmt.Fprintf(&b, "model = %s\n", strconv.Quote(modelID)) + fmt.Fprintf(&b, "model_provider = %s\n", strconv.Quote(csgclawProviderID)) + fmt.Fprintf(&b, "model_catalog_json = %s\n\n", strconv.Quote(modelCatalogFile)) + fmt.Fprintf(&b, "[model_providers.%s]\n", csgclawProviderID) + fmt.Fprintf(&b, "name = %s\n", strconv.Quote("CSGClaw LLM bridge")) + fmt.Fprintf(&b, "base_url = %s\n", strconv.Quote(baseURL)) + fmt.Fprintf(&b, "wire_api = %s\n", strconv.Quote("responses")) + b.WriteString("supports_websockets = false\n") + fmt.Fprintf(&b, "env_key = %s\n", strconv.Quote("OPENAI_API_KEY")) + return b.String() +} + +func RenderConfig(participantID, agentID string, server config.ServerConfig, model config.ModelConfig, resolveBaseURL BaseURLResolver, provider feishu.AgentCredentialProvider) ([]byte, error) { + participantID = strings.TrimSpace(participantID) + agentID = strings.TrimSpace(agentID) + if participantID == "" { + participantID = agentID + } + if agentID == "" { + agentID = participantID + } + + appID := "" + if provider != nil && agentID != "" { + if _, app, ok := provider.BotConfigForAgent(agentID); ok { + appID = strings.TrimSpace(app.AppID) + } + } + + root := codexSandboxRootConfig{ + SchemaVersion: 2, + ActiveProfile: ProfileName, + Preferences: map[string]any{}, + Migrations: codexSandboxMigrations{ + PermissionDefaultsV1: []string{ProfileName}, + }, + Profiles: map[string]codexSandboxProfileConfig{ + ProfileName: { + SchemaVersion: 2, + AgentKind: "codex", + Accounts: codexSandboxAccounts{ + App: codexSandboxAppCredentials{ + ID: appID, + Secret: "${" + appSecretEnvKey + "}", + Tenant: "feishu", + }, + }, + Preferences: map[string]any{ + "messageReply": "markdown", + "showToolCalls": true, + }, + Access: codexSandboxAccess{ + AllowedUsers: []string{}, + AllowedChats: []string{}, + Admins: []string{}, + RequireMentionInGroup: true, + }, + Workspaces: codexSandboxWorkspaces{Default: BoxProjectsDir}, + Permissions: codexSandboxPermissions{ + DefaultAccess: "full", + MaxAccess: "full", + }, + Codex: codexSandboxCodexConfig{ + BinaryPath: defaultCodexBinary, + CodexHome: BoxCodexHomeDir, + IgnoreUserConfig: false, + IgnoreRules: false, + InheritCodexHome: false, + }, + Attachments: codexSandboxAttachments{ + MaxCount: 10, + MaxBytes: 100 * 1024 * 1024, + MaxFileBytes: 25 * 1024 * 1024, + ImageMaxBytes: 25 * 1024 * 1024, + CacheTTLMS: 24 * 60 * 60 * 1000, + CacheMaxBytes: 512 * 1024 * 1024, + }, + Comments: map[string]any{}, + LarkCLI: codexSandboxLarkCLI{ + IdentityPreset: "bot-only", + }, + }, + }, + } + + data, err := json.MarshalIndent(root, "", " ") + if err != nil { + return nil, fmt.Errorf("encode codex sandbox config: %w", err) + } + return data, nil +} + +func managerBaseURL(server config.ServerConfig, resolveBaseURL BaseURLResolver) string { + if resolveBaseURL == nil { + return strings.TrimRight(strings.TrimSpace(server.AdvertiseBaseURL), "/") + } + return strings.TrimRight(strings.TrimSpace(resolveBaseURL(server)), "/") +} + +func llmBridgeBaseURL(managerBaseURL, agentID string) string { + managerBaseURL = strings.TrimRight(strings.TrimSpace(managerBaseURL), "/") + if managerBaseURL == "" || strings.TrimSpace(agentID) == "" { + return "" + } + return managerBaseURL + "/api/v1/agents/" + strings.TrimSpace(agentID) + "/llm" +} + +type codexSandboxRootConfig struct { + SchemaVersion int `json:"schemaVersion"` + ActiveProfile string `json:"activeProfile"` + Preferences map[string]any `json:"preferences"` + Migrations codexSandboxMigrations `json:"migrations"` + Profiles map[string]codexSandboxProfileConfig `json:"profiles"` +} + +type codexSandboxMigrations struct { + PermissionDefaultsV1 []string `json:"permissionDefaultsV1"` +} + +type codexSandboxProfileConfig struct { + SchemaVersion int `json:"schemaVersion"` + AgentKind string `json:"agentKind"` + Accounts codexSandboxAccounts `json:"accounts"` + Preferences map[string]any `json:"preferences"` + Access codexSandboxAccess `json:"access"` + Workspaces codexSandboxWorkspaces `json:"workspaces"` + Permissions codexSandboxPermissions `json:"permissions"` + Codex codexSandboxCodexConfig `json:"codex"` + Attachments codexSandboxAttachments `json:"attachments"` + Comments map[string]any `json:"comments"` + LarkCLI codexSandboxLarkCLI `json:"larkCli"` +} + +type codexSandboxAccounts struct { + App codexSandboxAppCredentials `json:"app"` +} + +type codexSandboxAppCredentials struct { + ID string `json:"id"` + Secret string `json:"secret"` + Tenant string `json:"tenant"` +} + +type codexSandboxAccess struct { + AllowedUsers []string `json:"allowedUsers"` + AllowedChats []string `json:"allowedChats"` + Admins []string `json:"admins"` + RequireMentionInGroup bool `json:"requireMentionInGroup"` +} + +type codexSandboxWorkspaces struct { + Default string `json:"default"` +} + +type codexSandboxPermissions struct { + DefaultAccess string `json:"defaultAccess"` + MaxAccess string `json:"maxAccess"` +} + +type codexSandboxCodexConfig struct { + BinaryPath string `json:"binaryPath"` + CodexHome string `json:"codexHome"` + IgnoreUserConfig bool `json:"ignoreUserConfig"` + IgnoreRules bool `json:"ignoreRules"` + InheritCodexHome bool `json:"inheritCodexHome"` +} + +type codexSandboxAttachments struct { + MaxCount int `json:"maxCount"` + MaxBytes int `json:"maxBytes"` + MaxFileBytes int `json:"maxFileBytes"` + ImageMaxBytes int `json:"imageMaxBytes"` + CacheTTLMS int `json:"cacheTtlMs"` + CacheMaxBytes int `json:"cacheMaxBytes"` +} + +type codexSandboxLarkCLI struct { + IdentityPreset string `json:"identityPreset"` +} diff --git a/internal/runtime/codexsandbox/config_test.go b/internal/runtime/codexsandbox/config_test.go new file mode 100644 index 00000000..c68504fe --- /dev/null +++ b/internal/runtime/codexsandbox/config_test.go @@ -0,0 +1,128 @@ +package codexsandbox + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "csgclaw/internal/channel/feishu" + "csgclaw/internal/config" +) + +func TestRenderConfigUsesFeishuAppIDAndEnvSecret(t *testing.T) { + data, err := RenderConfig("dev", "u-dev", config.ServerConfig{ + AccessToken: "shared-token", + }, config.ModelConfig{ + ModelID: "gpt-5.5", + }, fixedBaseURL("http://127.0.0.1:18080"), codexSandboxFeishuProvider{ + participantID: "dev", + app: feishu.AppConfig{ + AppID: "cli_dev", + AppSecret: "dev-secret", + }, + }) + if err != nil { + t.Fatalf("RenderConfig() error = %v", err) + } + if strings.Contains(string(data), "dev-secret") { + t.Fatalf("RenderConfig() wrote plaintext app secret:\n%s", data) + } + + var rendered codexSandboxRootConfig + if err := json.Unmarshal(data, &rendered); err != nil { + t.Fatalf("RenderConfig() produced invalid JSON: %v", err) + } + profile := rendered.Profiles[ProfileName] + if got, want := profile.AgentKind, "codex"; got != want { + t.Fatalf("agentKind = %q, want %q", got, want) + } + if got, want := profile.Accounts.App.ID, "cli_dev"; got != want { + t.Fatalf("accounts.app.id = %q, want %q", got, want) + } + if got, want := profile.Accounts.App.Secret, "${APP_SECRET}"; got != want { + t.Fatalf("accounts.app.secret = %q, want %q", got, want) + } + if got, want := profile.Workspaces.Default, BoxProjectsDir; got != want { + t.Fatalf("workspaces.default = %q, want %q", got, want) + } + if got, want := profile.Codex.BinaryPath, defaultCodexBinary; got != want { + t.Fatalf("codex.binaryPath = %q, want %q", got, want) + } + if got, want := profile.Codex.CodexHome, BoxCodexHomeDir; got != want { + t.Fatalf("codex.codexHome = %q, want %q", got, want) + } +} + +func TestRenderConfigAllowsMissingFeishuProvider(t *testing.T) { + data, err := RenderConfig("dev", "u-dev", config.ServerConfig{}, config.ModelConfig{}, nil, nil) + if err != nil { + t.Fatalf("RenderConfig() error = %v", err) + } + var rendered codexSandboxRootConfig + if err := json.Unmarshal(data, &rendered); err != nil { + t.Fatalf("RenderConfig() produced invalid JSON: %v", err) + } + profile := rendered.Profiles[ProfileName] + if got := profile.Accounts.App.ID; got != "" { + t.Fatalf("accounts.app.id = %q, want empty before Feishu binding", got) + } + if got, want := profile.Accounts.App.Secret, "${APP_SECRET}"; got != want { + t.Fatalf("accounts.app.secret = %q, want %q", got, want) + } +} + +func TestEnsureConfigWritesCodexHomeConfig(t *testing.T) { + agentHome := t.TempDir() + _, err := EnsureConfig(agentHome, "dev", "u-dev", config.ServerConfig{ + AccessToken: "shared-token", + AdvertiseBaseURL: "http://manager.example", + }, config.ModelConfig{ + ModelID: "gpt-5.5", + ReasoningEffort: "medium", + }, fixedBaseURL("http://127.0.0.1:18080"), nil) + if err != nil { + t.Fatalf("EnsureConfig() error = %v", err) + } + + configPath := filepath.Join(Root(agentHome), HostCodexHomeDir, codexConfigFile) + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("ReadFile(config.toml) error = %v", err) + } + content := string(data) + for _, want := range []string{ + `model = "gpt-5.5"`, + `model_provider = "csgclaw-llm"`, + `model_catalog_json = "model_catalog.json"`, + `base_url = "http://127.0.0.1:18080/api/v1/agents/u-dev/llm"`, + `wire_api = "responses"`, + `env_key = "OPENAI_API_KEY"`, + } { + if !strings.Contains(content, want) { + t.Fatalf("config.toml missing %q:\n%s", want, content) + } + } + + catalogPath := filepath.Join(Root(agentHome), HostCodexHomeDir, modelCatalogFile) + catalog, err := os.ReadFile(catalogPath) + if err != nil { + t.Fatalf("ReadFile(model_catalog.json) error = %v", err) + } + if !strings.Contains(string(catalog), `"slug": "gpt-5.5"`) { + t.Fatalf("model catalog missing gpt-5.5:\n%s", catalog) + } +} + +type codexSandboxFeishuProvider struct { + participantID string + app feishu.AppConfig +} + +func (p codexSandboxFeishuProvider) BotConfigForAgent(agentID string) (string, feishu.AppConfig, bool) { + if agentID != "u-dev" { + return "", feishu.AppConfig{}, false + } + return p.participantID, p.app, true +} diff --git a/internal/runtime/codexsandbox/provision_test.go b/internal/runtime/codexsandbox/provision_test.go new file mode 100644 index 00000000..f6bf3ae3 --- /dev/null +++ b/internal/runtime/codexsandbox/provision_test.go @@ -0,0 +1,60 @@ +package codexsandbox + +import ( + "context" + "os" + "path/filepath" + "testing" + + "csgclaw/internal/config" + agentruntime "csgclaw/internal/runtime" + templateembed "csgclaw/internal/template/embed" +) + +func TestProvisionPreparesGatewayAssets(t *testing.T) { + agentHome := t.TempDir() + projectsRoot := t.TempDir() + overlayRoot := t.TempDir() + if err := os.WriteFile(filepath.Join(overlayRoot, "USER.md"), []byte("overlay user\n"), 0o644); err != nil { + t.Fatalf("WriteFile(USER.md) error = %v", err) + } + + rt := New(Dependencies{}) + + if err := rt.Provision(context.Background(), agentruntime.ProvisionRequest{ + RuntimeID: "rt-u-dev", + AgentID: "u-dev", + AgentName: "dev", + ParticipantID: "dev", + WorkspaceOverlay: overlayRoot, + Gateway: &agentruntime.GatewayProvision{ + ModelFallback: "fallback-model", + Server: config.ServerConfig{AdvertiseBaseURL: "http://127.0.0.1:18080", AccessToken: "shared-token"}, + ManagerBaseURL: "http://127.0.0.1:18080", + AgentHome: agentHome, + ProjectsRoot: projectsRoot, + WorkspaceTemplate: templateembed.CodexSandboxWorkerRoot, + }, + }); err != nil { + t.Fatalf("Provision() error = %v", err) + } + + if _, err := os.Stat(filepath.Join(Root(agentHome), HostConfig)); err != nil { + t.Fatalf("stat codex sandbox config: %v", err) + } + if _, err := os.Stat(filepath.Join(Root(agentHome), HostGatewayLog)); err != nil { + t.Fatalf("stat codex sandbox gateway log: %v", err) + } + data, err := os.ReadFile(filepath.Join(workspaceRoot(agentHome), "USER.md")) + if err != nil { + t.Fatalf("ReadFile(USER.md) error = %v", err) + } + 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/codexsandbox/runtime.go b/internal/runtime/codexsandbox/runtime.go new file mode 100644 index 00000000..8035622f --- /dev/null +++ b/internal/runtime/codexsandbox/runtime.go @@ -0,0 +1,151 @@ +package codexsandbox + +import ( + "context" + "fmt" + "path/filepath" + "reflect" + "strings" + + "csgclaw/internal/config" + agentruntime "csgclaw/internal/runtime" + "csgclaw/internal/runtime/sandboxgateway" +) + +type AgentRef = sandboxgateway.AgentRef +type Dependencies = sandboxgateway.Dependencies +type WorkspaceLayout = sandboxgateway.WorkspaceLayout + +type Runtime struct { + *sandboxgateway.Runtime +} + +var _ agentruntime.Provisioner = (*Runtime)(nil) +var _ agentruntime.ConversationStarter = (*Runtime)(nil) +var _ agentruntime.RuntimeConfigController = (*Runtime)(nil) + +func New(deps Dependencies) *Runtime { + deps.RuntimeKind = agentruntime.KindCodexSandbox + deps.HomeEnv = BoxUserHome + deps.MountGuestPath = BoxDir + deps.WorkspaceGuestPath = BoxWorkspaceDir + deps.ProjectsGuestPath = BoxProjectsDir + deps.GatewayLogPath = BoxGatewayLogPath + if deps.GatewayCommand == nil { + deps.GatewayCommand = GatewayRunCommand + } + if strings.TrimSpace(deps.ReadinessProbe.Name) == "" { + deps.ReadinessProbe = sandboxgateway.GatewayReadinessProbe{ + Name: "wget", + Args: []string{"-q", "--spider", "-T", "2", "http://127.0.0.1:18791/readyz"}, + } + } + return &Runtime{Runtime: sandboxgateway.New(deps)} +} + +func (r *Runtime) WorkspaceRoot(agentHome string) string { + return r.Layout(agentHome).WorkspaceRoot +} + +func (r *Runtime) Layout(agentHome string) agentruntime.Layout { + workspace := workspaceRoot(agentHome) + return agentruntime.Layout{ + WorkspaceRoot: workspace, + SkillsRoot: filepath.Join(workspace, "skills"), + HostLogPaths: []string{HostGatewayLogPath(agentHome)}, + } +} + +func (r *Runtime) NewConversation(_ context.Context, _ agentruntime.Handle, _ agentruntime.ConversationStartRequest) (agentruntime.ConversationStartAction, error) { + return agentruntime.ConversationStartAction{ + Mode: agentruntime.ConversationStartActionBotEvent, + BotEventText: "/new", + }, nil +} + +func (r *Runtime) Provision(_ context.Context, req agentruntime.ProvisionRequest) error { + if r == nil { + return nil + } + gateway := req.Gateway + if gateway == nil { + return fmt.Errorf("gateway provisioning data is required") + } + profile := req.Profile.Normalized() + if strings.TrimSpace(profile.ModelID) == "" { + profile.ModelID = strings.TrimSpace(gateway.ModelFallback) + } + agentHome := strings.TrimSpace(gateway.AgentHome) + if agentHome == "" { + return fmt.Errorf("gateway agent home is required") + } + participantID := strings.TrimSpace(req.ParticipantID) + if participantID == "" { + participantID = strings.TrimSpace(req.AgentID) + } + if _, err := EnsureConfig(agentHome, participantID, req.AgentID, gateway.Server, configModelFromProfile(profile), fixedBaseURL(gateway.ManagerBaseURL), r.CurrentFeishuProvider()); err != nil { + return err + } + workspaceRoot := r.Layout(agentHome).WorkspaceRoot + 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, + WorkspaceHostPath: workspaceRoot, + WorkspaceGuestPath: BoxWorkspaceDir, + }) + if err != nil { + return err + } + r.RememberPreparedGatewayProvision(req.AgentID, prepared) + return nil +} + +func (r *Runtime) ValidateConfig(context.Context, agentruntime.RuntimeConfigSnapshot) error { + return nil +} + +func (r *Runtime) RestartRequired(change agentruntime.RuntimeConfigChange) (bool, error) { + return !runtimeProfileConfigEqual(change.Previous.Profile, change.Current.Profile) || + !reflect.DeepEqual(change.Previous.Options, change.Current.Options), nil +} + +func (r *Runtime) ReconcileConfig(context.Context, agentruntime.Handle, agentruntime.RuntimeConfigChange) error { + return nil +} + +func GatewayRunCommand() string { + return "exec /usr/local/bin/csgclaw-codex-gateway 1>" + BoxGatewayLogPath + " 2>&1" +} + +func fixedBaseURL(baseURL string) BaseURLResolver { + baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") + return func(config.ServerConfig) string { + return baseURL + } +} + +func configModelFromProfile(profile agentruntime.Profile) config.ModelConfig { + return config.ModelConfig{ + Provider: profile.Provider, + BaseURL: profile.BaseURL, + APIKey: profile.APIKey, + ModelID: profile.ModelID, + ReasoningEffort: profile.ReasoningEffort, + } +} + +func runtimeProfileConfigEqual(a, b agentruntime.RuntimeProfileConfig) bool { + return strings.TrimSpace(a.Provider) == strings.TrimSpace(b.Provider) && + strings.TrimRight(strings.TrimSpace(a.BaseURL), "/") == strings.TrimRight(strings.TrimSpace(b.BaseURL), "/") && + strings.TrimSpace(a.APIKey) == strings.TrimSpace(b.APIKey) && + strings.TrimSpace(a.ModelID) == strings.TrimSpace(b.ModelID) && + strings.TrimSpace(a.ReasoningEffort) == strings.TrimSpace(b.ReasoningEffort) && + reflect.DeepEqual(a.Headers, b.Headers) && + reflect.DeepEqual(a.RequestOptions, b.RequestOptions) +} diff --git a/internal/runtime/codexsandbox/runtime_test.go b/internal/runtime/codexsandbox/runtime_test.go new file mode 100644 index 00000000..3f38e69c --- /dev/null +++ b/internal/runtime/codexsandbox/runtime_test.go @@ -0,0 +1,75 @@ +package codexsandbox + +import ( + "path/filepath" + "strings" + "testing" + + agentruntime "csgclaw/internal/runtime" +) + +func TestGatewayRunCommandUsesCodexGatewayBinaryAndLog(t *testing.T) { + cmd := GatewayRunCommand() + if !strings.Contains(cmd, "/usr/local/bin/csgclaw-codex-gateway") { + t.Fatalf("GatewayRunCommand() = %q, want csgclaw-codex-gateway", cmd) + } + if !strings.Contains(cmd, BoxGatewayLogPath) { + t.Fatalf("GatewayRunCommand() = %q, want log path %q", cmd, BoxGatewayLogPath) + } +} + +func TestLayoutUsesCodexSandboxWorkspace(t *testing.T) { + agentHome := t.TempDir() + rt := New(Dependencies{}) + layout := rt.Layout(agentHome) + if got, want := layout.WorkspaceRoot, workspaceRoot(agentHome); got != want { + t.Fatalf("WorkspaceRoot = %q, want %q", got, want) + } + if got, want := layout.SkillsRoot, filepath.Join(workspaceRoot(agentHome), "skills"); got != want { + t.Fatalf("SkillsRoot = %q, want %q", got, want) + } + if got, want := rt.GatewayLogPath(), BoxGatewayLogPath; got != want { + t.Fatalf("GatewayLogPath() = %q, want %q", got, want) + } +} + +func TestRestartRequiredWhenRuntimeProfileChanges(t *testing.T) { + rt := New(Dependencies{}) + got, err := rt.RestartRequired(agentruntime.RuntimeConfigChange{ + Previous: agentruntime.RuntimeConfigSnapshot{Profile: agentruntime.RuntimeProfileConfig{ + BaseURL: "http://127.0.0.1:18080/api/v1/agents/u-dev/llm", + APIKey: "token", + ModelID: "gpt-5.4", + }}, + Current: agentruntime.RuntimeConfigSnapshot{Profile: agentruntime.RuntimeProfileConfig{ + BaseURL: "http://127.0.0.1:18080/api/v1/agents/u-dev/llm", + APIKey: "token", + ModelID: "gpt-5.5", + }}, + }) + if err != nil { + t.Fatalf("RestartRequired() error = %v", err) + } + if !got { + t.Fatal("RestartRequired() = false, want true when model changes") + } + + got, err = rt.RestartRequired(agentruntime.RuntimeConfigChange{ + Previous: agentruntime.RuntimeConfigSnapshot{Profile: agentruntime.RuntimeProfileConfig{ + BaseURL: "http://127.0.0.1:18080/api/v1/agents/u-dev/llm", + APIKey: "token", + ModelID: "gpt-5.5", + }}, + Current: agentruntime.RuntimeConfigSnapshot{Profile: agentruntime.RuntimeProfileConfig{ + BaseURL: "http://127.0.0.1:18080/api/v1/agents/u-dev/llm/", + APIKey: "token", + ModelID: "gpt-5.5", + }}, + }) + if err != nil { + t.Fatalf("RestartRequired() unchanged error = %v", err) + } + if got { + t.Fatal("RestartRequired() = true, want false for equivalent profile") + } +} diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 9a36e607..5fa383af 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -10,6 +10,7 @@ import ( const ( KindPicoClawSandbox = "picoclaw_sandbox" KindOpenClawSandbox = "openclaw_sandbox" + KindCodexSandbox = "codex_sandbox" KindCodex = "codex" ) diff --git a/internal/runtime/runtime_selection.go b/internal/runtime/runtime_selection.go index f031e1ed..1bbd721f 100644 --- a/internal/runtime/runtime_selection.go +++ b/internal/runtime/runtime_selection.go @@ -36,6 +36,9 @@ func (c RuntimeConfig) LegacyKind() string { return KindOpenClawSandbox } case NameCodex: + if c.Sandboxed { + return KindCodexSandbox + } if !c.Sandboxed { return KindCodex } @@ -54,6 +57,9 @@ func (c RuntimeConfig) Kind() string { return NameOpenClaw } case NameCodex: + if c.Sandboxed { + return KindCodexSandbox + } if !c.Sandboxed { return KindCodex } @@ -67,7 +73,7 @@ func NormalizeRuntimeName(name string) string { return NamePicoClaw case NameOpenClaw, KindOpenClawSandbox: return NameOpenClaw - case NameCodex: + case NameCodex, KindCodexSandbox: return NameCodex case "": return "" @@ -77,6 +83,9 @@ func NormalizeRuntimeName(name string) string { } func RuntimeConfigForKind(kind string) RuntimeConfig { + if strings.EqualFold(strings.TrimSpace(kind), KindCodexSandbox) { + return RuntimeConfig{Name: NameCodex, Sandboxed: true} + } switch NormalizeRuntimeName(kind) { case NamePicoClaw: return RuntimeConfig{Name: NamePicoClaw, Sandboxed: true} diff --git a/internal/template/builtin_store.go b/internal/template/builtin_store.go index 1a1c888f..5ea2b521 100644 --- a/internal/template/builtin_store.go +++ b/internal/template/builtin_store.go @@ -113,6 +113,10 @@ func (s *BuiltinStore) loadManifest(id string) (templateManifest, error) { if err := toml.Unmarshal(data, &manifest); err != nil { return templateManifest{}, fmt.Errorf("decode builtin manifest %q: %w", id, err) } + manifest, err = normalizeTemplateManifest(manifest) + if err != nil { + return templateManifest{}, fmt.Errorf("validate builtin manifest %q: %w", id, err) + } if err := validateManifest(manifest); err != nil { return templateManifest{}, fmt.Errorf("validate builtin manifest %q: %w", id, err) } diff --git a/internal/template/builtin_store_test.go b/internal/template/builtin_store_test.go index 3f79f9d2..af476864 100644 --- a/internal/template/builtin_store_test.go +++ b/internal/template/builtin_store_test.go @@ -12,7 +12,8 @@ import ( ) const ( - testBuiltinOpenClawImage = "opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/openclaw:20260702.25-csgclaw" + testBuiltinOpenClawImage = "opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/openclaw:20260702.25-csgclaw" + testBuiltinCodexSandboxImage = "opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/csgclaw-codex-sandbox:20260701" ) func TestBuiltinStoreListGetAndFetchWorkspace(t *testing.T) { @@ -22,21 +23,24 @@ func TestBuiltinStoreListGetAndFetchWorkspace(t *testing.T) { if err != nil { t.Fatalf("List() error = %v", err) } - if got, want := len(items), 4; got != want { + if got, want := len(items), 5; got != want { t.Fatalf("len(List()) = %d, want %d", got, want) } - if got, want := items[0].ID, "openclaw-manager"; got != want { + if got, want := items[0].ID, "codex-sandbox-worker"; got != want { t.Fatalf("List()[0].ID = %q, want %q", got, want) } - if got, want := items[1].ID, "openclaw-worker"; got != want { + if got, want := items[1].ID, "openclaw-manager"; got != want { t.Fatalf("List()[1].ID = %q, want %q", got, want) } - if got, want := items[2].ID, "picoclaw-manager"; got != want { + if got, want := items[2].ID, "openclaw-worker"; got != want { t.Fatalf("List()[2].ID = %q, want %q", got, want) } - if got, want := items[3].ID, "picoclaw-worker"; got != want { + if got, want := items[3].ID, "picoclaw-manager"; got != want { t.Fatalf("List()[3].ID = %q, want %q", got, want) } + if got, want := items[4].ID, "picoclaw-worker"; got != want { + t.Fatalf("List()[4].ID = %q, want %q", got, want) + } item, err := store.Get(context.Background(), "picoclaw-worker") if err != nil { @@ -63,6 +67,17 @@ func TestBuiltinStoreListGetAndFetchWorkspace(t *testing.T) { t.Fatalf("Get(openclaw-worker).Image = %q, want %q", got, want) } + codexItem, err := store.Get(context.Background(), "codex-sandbox-worker") + if err != nil { + t.Fatalf("Get(codex-sandbox-worker) error = %v", err) + } + if got, want := codexItem.RuntimeKind, runtime.KindCodexSandbox; got != want { + t.Fatalf("Get(codex-sandbox-worker).RuntimeKind = %q, want %q", got, want) + } + if got, want := codexItem.Image, testBuiltinCodexSandboxImage; got != want { + t.Fatalf("Get(codex-sandbox-worker).Image = %q, want %q", got, want) + } + workspace, err := store.FetchWorkspace(context.Background(), "picoclaw-worker") if err != nil { t.Fatalf("FetchWorkspace() error = %v", err) @@ -118,22 +133,25 @@ func TestServiceListAggregatesBuiltinAndLocalWithDefaultStoreFactory(t *testing. if err != nil { t.Fatalf("List() error = %v", err) } - if got, want := len(items), 5; got != want { + if got, want := len(items), 6; got != want { t.Fatalf("len(List()) = %d, want %d", got, want) } - if got, want := items[0].ID, "builtin.openclaw-manager"; got != want { + if got, want := items[0].ID, "builtin.codex-sandbox-worker"; got != want { t.Fatalf("List()[0].ID = %q, want %q", got, want) } - if got, want := items[1].ID, "builtin.openclaw-worker"; got != want { + if got, want := items[1].ID, "builtin.openclaw-manager"; got != want { t.Fatalf("List()[1].ID = %q, want %q", got, want) } - if got, want := items[2].ID, "builtin.picoclaw-manager"; got != want { + if got, want := items[2].ID, "builtin.openclaw-worker"; got != want { t.Fatalf("List()[2].ID = %q, want %q", got, want) } - if got, want := items[3].ID, "builtin.picoclaw-worker"; got != want { + if got, want := items[3].ID, "builtin.picoclaw-manager"; got != want { t.Fatalf("List()[3].ID = %q, want %q", got, want) } - if got, want := items[4].ID, "local.team-helper"; got != want { + if got, want := items[4].ID, "builtin.picoclaw-worker"; got != want { t.Fatalf("List()[4].ID = %q, want %q", got, want) } + if got, want := items[5].ID, "local.team-helper"; got != want { + t.Fatalf("List()[5].ID = %q, want %q", got, want) + } } diff --git a/internal/template/embed/embed.go b/internal/template/embed/embed.go index b0ac29a0..956cb9e7 100644 --- a/internal/template/embed/embed.go +++ b/internal/template/embed/embed.go @@ -3,14 +3,15 @@ package templateembed import "embed" const ( - Root = "" - ManifestFileName = "agent.toml" - WorkspaceDirName = "workspace" - PicoClawManagerRoot = "manager/picoclaw" - PicoClawWorkerRoot = "worker/picoclaw" - OpenClawManagerRoot = "manager/openclaw" - OpenClawWorkerRoot = "worker/openclaw" + Root = "" + ManifestFileName = "agent.toml" + WorkspaceDirName = "workspace" + PicoClawManagerRoot = "manager/picoclaw" + PicoClawWorkerRoot = "worker/picoclaw" + OpenClawManagerRoot = "manager/openclaw" + OpenClawWorkerRoot = "worker/openclaw" + CodexSandboxWorkerRoot = "worker/codexsandbox" ) -//go:embed manager/picoclaw worker/picoclaw manager/openclaw worker/openclaw +//go:embed manager/picoclaw worker/picoclaw manager/openclaw worker/openclaw worker/codexsandbox var runtimeTemplateFS embed.FS diff --git a/internal/template/embed/resolve.go b/internal/template/embed/resolve.go index fb7899b0..27b7ae4a 100644 --- a/internal/template/embed/resolve.go +++ b/internal/template/embed/resolve.go @@ -23,6 +23,12 @@ type BuiltinTemplate struct { } var builtinTemplates = []BuiltinTemplate{ + { + ID: "codex-sandbox-worker", + RuntimeKind: runtimepkg.KindCodexSandbox, + Role: roleWorker, + Root: CodexSandboxWorkerRoot, + }, { ID: "openclaw-manager", RuntimeKind: runtimepkg.KindOpenClawSandbox, diff --git a/internal/template/embed/resolve_test.go b/internal/template/embed/resolve_test.go index b3a8792b..3ff3a906 100644 --- a/internal/template/embed/resolve_test.go +++ b/internal/template/embed/resolve_test.go @@ -13,6 +13,7 @@ func TestLookupBuiltin(t *testing.T) { role string root string }{ + {id: "codex-sandbox-worker", runtimeKind: runtime.KindCodexSandbox, role: roleWorker, root: CodexSandboxWorkerRoot}, {id: "openclaw-manager", runtimeKind: runtime.KindOpenClawSandbox, role: roleManager, root: OpenClawManagerRoot}, {id: "openclaw-worker", runtimeKind: runtime.KindOpenClawSandbox, role: roleWorker, root: OpenClawWorkerRoot}, {id: "picoclaw-manager", runtimeKind: runtime.KindPicoClawSandbox, role: roleManager, root: PicoClawManagerRoot}, diff --git a/internal/template/embed/worker/codexsandbox/agent.toml b/internal/template/embed/worker/codexsandbox/agent.toml new file mode 100644 index 00000000..df723ec7 --- /dev/null +++ b/internal/template/embed/worker/codexsandbox/agent.toml @@ -0,0 +1,9 @@ +name = "generic-assistant-codex-sandbox" +description = "General-purpose assistant powered by Codex in the CSGClaw sandbox." +role = "worker" +runtime_kind = "codex_sandbox" +updated_at = "2026-07-01T00:00:00Z" +version = "0.1.0" + +[image] +ref = "opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/csgclaw-codex-sandbox:20260701" diff --git a/internal/template/embed/worker/codexsandbox/workspace/AGENTS.md b/internal/template/embed/worker/codexsandbox/workspace/AGENTS.md new file mode 100644 index 00000000..789a5b30 --- /dev/null +++ b/internal/template/embed/worker/codexsandbox/workspace/AGENTS.md @@ -0,0 +1,64 @@ +# AGENTS.md - CSGClaw Codex Sandbox Worker + +This workspace is managed by CSGClaw and mounted as +`~/.codex-sandbox/workspace` inside the Codex sandbox worker runtime. + +## Session Startup + +Before acting on a request: + +1. Read `SOUL.md` for identity, tone, and boundaries. +2. Read `USER.md` for user preferences when present. +3. Read `IDENTITY.md` for the worker role. +4. Use durable workspace notes only when they are safe and relevant to the + current conversation. + +This workspace is already initialized by CSGClaw. Do not run first-use setup or +change runtime configuration unless the user explicitly asks for it. + +## Role + +You are a Codex worker agent connected to CSGClaw. Help with coding, workspace +tasks, and skill-based work. Stay practical, accurate, and concise. + +## CSGClaw Runtime + +- CSGClaw provides room and task messages through the local CSGClaw channel + bridge, and model access through the LLM bridge. Feishu/Lark channel access + is optional and is provided by lark-channel-bridge when the runtime has app + config. +- Your CSGClaw participant ID comes from the channel/runtime config, commonly a + stable worker slug such as `frontend-dev`. Rendered mentions may display only + the handle, such as `@frontend-dev`; use the exact participant ID shown in + structured mentions or task commands. +- Do not edit `~/.codex-sandbox/config.json` unless the user asks you to change + runtime configuration. +- Treat channel messages as user-visible output. Keep private context private, + especially in group conversations. +- Ask before destructive commands, public posts, outbound messages, or actions + that leave the machine unless the user already authorized the action. + +## Skills + +- Local skills live under `skills//SKILL.md`. +- Before using a skill, check the local `skills/` directory and read the + matching `SKILL.md`. +- If the assignment is a direct agent task notification with + `csgclaw-cli task claim --task `, claim it with + `csgclaw-cli task claim --task --participant-id ` + and report completion, failure, or blockage with + `csgclaw-cli task update --task --actor-id --status ...`. + Do not use `team task` commands for direct agent tasks. +- If a task begins with ``, + treat `` as the required skill slug and the remaining text as the task + instruction. +- Prefer local workspace skills over external discovery. +- Use `TOOLS.md` for local tool notes and operational details. + +## Working Principles + +- Be clear and direct. +- Use tools when action is required. +- Prefer simple, reversible steps. +- Explain blockers concretely. +- Preserve user files and do not overwrite workspace memory casually. diff --git a/internal/template/embed/worker/codexsandbox/workspace/IDENTITY.md b/internal/template/embed/worker/codexsandbox/workspace/IDENTITY.md new file mode 100644 index 00000000..e413c1c2 --- /dev/null +++ b/internal/template/embed/worker/codexsandbox/workspace/IDENTITY.md @@ -0,0 +1,16 @@ +# IDENTITY.md - Worker Identity + +Name: Codex + +Role: CSGClaw worker agent + +Nature: Codex runtime agent managed by CSGClaw + +Vibe: calm, practical, accurate, and concise + +Responsibilities: + +- Answer user requests through the configured channel bridge. +- Use workspace skills when they match the task. +- Keep memory and user context private unless sharing is clearly appropriate. +- Avoid first-use setup unless explicitly requested. diff --git a/internal/template/embed/worker/codexsandbox/workspace/SOUL.md b/internal/template/embed/worker/codexsandbox/workspace/SOUL.md new file mode 100644 index 00000000..82274532 --- /dev/null +++ b/internal/template/embed/worker/codexsandbox/workspace/SOUL.md @@ -0,0 +1,17 @@ +# Soul + +I am Codex: calm, helpful, and practical. + +## Personality + +- Helpful and direct +- Concise and task-focused +- Careful with code and user files +- Honest about uncertainty and blockers + +## Values + +- Accuracy over speed +- User privacy and safety +- Clear actions and results +- Simplicity over unnecessary complexity diff --git a/internal/template/embed/worker/codexsandbox/workspace/TOOLS.md b/internal/template/embed/worker/codexsandbox/workspace/TOOLS.md new file mode 100644 index 00000000..85fe8105 --- /dev/null +++ b/internal/template/embed/worker/codexsandbox/workspace/TOOLS.md @@ -0,0 +1,25 @@ +# TOOLS.md - Local Tool Notes + +This file records workspace-specific notes for tools and skills. It does not +grant or remove tool permissions. + +## Runtime + +- Workspace path in the container: `~/.codex-sandbox/workspace` +- Project mount path in the container: `~/.codex-sandbox/workspace/projects` +- Bridge config path in the container: `~/.codex-sandbox/config.json` +- Codex home path in the container: `~/.codex-sandbox/codex-home` +- CSGClaw provides model access through runtime configuration. Feishu/Lark + channel access is available when runtime app config is bound. + +## Skills + +- Local skills are under `skills/`. +- Read a skill's `SKILL.md` before following it. +- Prefer local skills before installing or fetching external skills. + +## Safety + +- Ask before destructive filesystem changes. +- Ask before sending messages or making external changes on the user's behalf. +- Keep secrets out of logs, memory, and chat replies. diff --git a/internal/template/embed/worker/codexsandbox/workspace/USER.md b/internal/template/embed/worker/codexsandbox/workspace/USER.md new file mode 100644 index 00000000..9a3419d8 --- /dev/null +++ b/internal/template/embed/worker/codexsandbox/workspace/USER.md @@ -0,0 +1,21 @@ +# User + +Information about the user goes here. + +## Preferences + +- Communication style: (casual/formal) +- Timezone: (your timezone) +- Language: (your preferred language) + +## Personal Information + +- Name: (optional) +- Location: (optional) +- Occupation: (optional) + +## Learning Goals + +- What the user wants to learn from AI +- Preferred interaction style +- Areas of interest diff --git a/internal/template/embed/worker/codexsandbox/workspace/skills/agent-teams/SKILL.md b/internal/template/embed/worker/codexsandbox/workspace/skills/agent-teams/SKILL.md new file mode 100644 index 00000000..ce3cacf2 --- /dev/null +++ b/internal/template/embed/worker/codexsandbox/workspace/skills/agent-teams/SKILL.md @@ -0,0 +1,99 @@ +--- +name: agent-teams +description: Use this skill when you are a worker operating inside a CSGClaw task execution room. Only act on explicit task dispatch messages, claim the dispatched task, report blocked/completed/failed status through the team CLI, and keep all execution aligned with server task state. +--- + +# Agent Teams Worker + +Use this skill when the manager is coordinating work through CSGClaw team tasks. + +Only begin work after an explicit dispatch message in the task execution room: + +```text +@ dispatched to . Claim: csgclaw-cli team task claim --team --task --participant-id +``` + +The claim command uses participant IDs, for example `u-frontend-dev`. Rendered +mentions may show only the display name or handle, for example `@frontend-dev`; +do not use the display name as the CLI participant ID unless the claim command +shows it. + +Do not use legacy `--bot-id` for team commands. If +`csgclaw-cli team task claim --help` does not show `--participant-id`, the +sandbox has a stale `csgclaw-cli`; report that blocker and stop instead of +retrying with `--bot-id`. + +Ignore team setup and planning events, including messages such as `enabled +team`, `created task`, `created tasks`, `completed planning for task`, and +`created execution room`. Those messages are not permission to start work. + +## Worker actions + +- claim the dispatched task with the exact `` and `` from the + dispatch message +- report `blocked`, `completed`, or `failed` +- request approval through the manager or human when needed + +Do not self-assign work. When the room dispatch message includes a task id, +always use `--task`. Use `claim-next` only when no task id was provided. + +Never infer `team_id` from the room id. A room id such as `room-178...` is not a +team id. Use the `--team ` value shown in the dispatch message. If the +only available value starts with `room-`, stop and report blocked instead of +continuing. + +## Commands + +Claim the dispatched task: + +```bash +csgclaw-cli team task claim --team --task --participant-id +``` + +If the dispatch did not include a task id, claim the next available task: + +```bash +csgclaw-cli team task claim-next --team --participant-id +``` + +After claiming, confirm the response status is `in_progress` for the same task +before doing the work. + +Report a completed task: + +```bash +csgclaw-cli team task update --team --task --actor-id --status completed --result "" +``` + +The task is not complete until this CLI status update succeeds. Sending a normal +room message with the result is useful, but it does not update task state. + +Report a blocked task: + +```bash +csgclaw-cli team task update --team --task --actor-id --status blocked --reason "" +``` + +Report a failed task: + +```bash +csgclaw-cli team task update --team --task --actor-id --status failed --error "" +``` + +## Working Rules + +- For any team task that creates, inspects, or updates a project, use + `~/.codex-sandbox/workspace/projects/{name}` as the shared project directory. + Check there first before creating a new project path. +- Keep project artifacts, notes, and generated files under the same + `~/.codex-sandbox/workspace/projects/{name}` tree so managers and workers can + inspect the same content. +- Claim before you execute. Do not start speculative work on pending tasks. +- Complete/fail/block through the same `/` that you claimed; + do not finish with only a chat message. +- When blocked by a command or external action, update the task to `blocked` and + explain the reason clearly. +- If the room later shows an approval resolution, refresh your understanding from + the room and continue on the same task if it moved back to `in_progress`. +- Keep your room reply short and factual: what changed, what result you + produced, or what blocker remains. diff --git a/internal/template/local_store_test.go b/internal/template/local_store_test.go index 61735393..83148f38 100644 --- a/internal/template/local_store_test.go +++ b/internal/template/local_store_test.go @@ -197,17 +197,26 @@ func TestLocalStorePublishAllowsMissingWorkspace(t *testing.T) { } func TestLocalStorePublishRequiresImageForGatewayRuntime(t *testing.T) { - store := NewLocalStore(t.TempDir()) - workspaceRoot := writeWorkspaceFile(t, "workspace", "AGENTS.md", "agent") + tests := []string{ + runtime.KindPicoClawSandbox, + runtime.KindCodexSandbox, + } + for _, runtimeKind := range tests { + t.Run(runtimeKind, func(t *testing.T) { + store := NewLocalStore(t.TempDir()) + workspaceRoot := writeWorkspaceFile(t, "workspace", "AGENTS.md", "agent") - _, err := store.Publish(context.Background(), PublishSpec{ - Name: "gateway-worker", - Role: TemplateRoleWorker, - RuntimeKind: runtime.NamePicoClaw, - WorkspaceRef: WorkspaceRef{Kind: WorkspaceKindDir, Path: workspaceRoot}, - }) - if err == nil || err.Error() != `image.ref is required for runtime_kind "picoclaw"` { - t.Fatalf("Publish() error = %v, want missing image error", err) + _, err := store.Publish(context.Background(), PublishSpec{ + Name: "gateway-worker", + Role: TemplateRoleWorker, + RuntimeKind: runtimeKind, + WorkspaceRef: WorkspaceRef{Kind: WorkspaceKindDir, Path: workspaceRoot}, + }) + wantErr := `image.ref is required for runtime_kind "` + normalizeTemplateRuntimeKind(runtimeKind) + `"` + if err == nil || err.Error() != wantErr { + t.Fatalf("Publish() error = %v, want missing image error %q", err, wantErr) + } + }) } } diff --git a/internal/template/remote_store.go b/internal/template/remote_store.go index 546e5dae..3d2ee9b2 100644 --- a/internal/template/remote_store.go +++ b/internal/template/remote_store.go @@ -192,6 +192,10 @@ func (s *RemoteStore) fetchManifest(ctx context.Context, id, branch string) (tem if err := toml.Unmarshal(data, &manifest); err != nil { return templateManifest{}, fmt.Errorf("decode remote hub manifest %q: %w", id, err) } + manifest, err = normalizeTemplateManifest(manifest) + if err != nil { + return templateManifest{}, fmt.Errorf("validate remote hub manifest %q: %w", id, err) + } if err := validateManifest(manifest); err != nil { return templateManifest{}, fmt.Errorf("validate remote hub manifest %q: %w", id, err) } diff --git a/internal/template/remote_store_test.go b/internal/template/remote_store_test.go index 501ff459..8d03c26a 100644 --- a/internal/template/remote_store_test.go +++ b/internal/template/remote_store_test.go @@ -14,6 +14,7 @@ import ( "testing" "csgclaw/internal/config" + "csgclaw/internal/runtime" ) const remoteTestManifest = `name = "gitlab-assistant" @@ -162,6 +163,48 @@ func TestRemoteStoreListGetAndFetchWorkspace(t *testing.T) { } } +func TestRemoteStoreMapsHubRuntimeKindAlias(t *testing.T) { + t.Parallel() + + manifest := `name = "gitlab-assistant" +role = "worker" +description = "GitLab assistant" +runtime_kind = "openclaw" +version = "2026.6.16.0" + +[image] +ref = "registry.example.com/openclaw-glab:2026.6.16.0" +` + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/api/v1/codes/Agentic/gitlab-assistant": + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "name": "gitlab-assistant", + "path": "Agentic/gitlab-assistant", + "default_branch": "main", + }, + }) + case r.URL.Path == "/api/v1/codes/Agentic/gitlab-assistant/blob/agent.toml": + assertQueryValue(t, r.URL, "ref", "main") + writeRemoteBlob(t, w, "agent.toml", []byte(manifest)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + + store := NewRemoteStore(srv.URL, "") + item, err := store.Get(context.Background(), "gitlab-assistant") + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if got, want := item.RuntimeKind, runtime.NameOpenClaw; got != want { + t.Fatalf("Get().RuntimeKind = %q, want %q", got, want) + } +} + func TestRemoteStoreListSkipsInvalidRepositories(t *testing.T) { t.Parallel() diff --git a/internal/template/runtime_kind.go b/internal/template/runtime_kind.go index 3a3aea1c..55c31866 100644 --- a/internal/template/runtime_kind.go +++ b/internal/template/runtime_kind.go @@ -8,10 +8,12 @@ import ( func normalizeTemplateRuntimeKind(kind string) string { switch strings.ToLower(strings.TrimSpace(kind)) { - case runtime.NamePicoClaw: + case runtime.NamePicoClaw, runtime.KindPicoClawSandbox: return runtime.NamePicoClaw - case runtime.NameOpenClaw: + case runtime.NameOpenClaw, runtime.KindOpenClawSandbox: return runtime.NameOpenClaw + case "codex-sandbox", runtime.KindCodexSandbox: + return runtime.KindCodexSandbox case runtime.KindCodex: return runtime.KindCodex default: @@ -25,6 +27,8 @@ func templateLegacyRuntimeKind(kind string) string { return runtime.KindPicoClawSandbox case runtime.NameOpenClaw: return runtime.KindOpenClawSandbox + case runtime.KindCodexSandbox: + return runtime.KindCodexSandbox case runtime.KindCodex: return runtime.KindCodex default: diff --git a/internal/template/template_manifest.go b/internal/template/template_manifest.go index b3e4cb6d..e8c3fe9d 100644 --- a/internal/template/template_manifest.go +++ b/internal/template/template_manifest.go @@ -11,13 +11,18 @@ import ( ) type templateManifest struct { - Name string `toml:"name"` - Description string `toml:"description,omitempty"` - Role string `toml:"role"` - RuntimeKind string `toml:"runtime_kind"` - Version string `toml:"version,omitempty"` - Image templateImageSection `toml:"image"` - UpdatedAt string `toml:"updated_at,omitempty"` + Name string `toml:"name"` + Description string `toml:"description,omitempty"` + Role string `toml:"role"` + RuntimeKind string `toml:"runtime_kind"` + Runtime templateRuntimeSection `toml:"runtime,omitempty"` + Version string `toml:"version,omitempty"` + Image templateImageSection `toml:"image"` + UpdatedAt string `toml:"updated_at,omitempty"` +} + +type templateRuntimeSection struct { + Kind string `toml:"kind"` } type templateImageSection struct { @@ -125,6 +130,26 @@ func validateImageEnvContracts(items []templateImageEnvItem) error { return nil } +func normalizeTemplateManifest(manifest templateManifest) (templateManifest, error) { + topLevelRuntimeKind := strings.TrimSpace(manifest.RuntimeKind) + nestedRuntimeKind := strings.TrimSpace(manifest.Runtime.Kind) + if topLevelRuntimeKind != "" && nestedRuntimeKind != "" { + canonicalTopLevel := normalizeTemplateRuntimeKind(topLevelRuntimeKind) + canonicalNested := normalizeTemplateRuntimeKind(nestedRuntimeKind) + if canonicalTopLevel != canonicalNested { + return templateManifest{}, fmt.Errorf("runtime_kind %q conflicts with runtime.kind %q", topLevelRuntimeKind, nestedRuntimeKind) + } + manifest.RuntimeKind = canonicalTopLevel + return manifest, nil + } + if topLevelRuntimeKind != "" { + manifest.RuntimeKind = normalizeTemplateRuntimeKind(topLevelRuntimeKind) + return manifest, nil + } + manifest.RuntimeKind = normalizeTemplateRuntimeKind(nestedRuntimeKind) + return manifest, nil +} + func validateManifest(manifest templateManifest) error { manifest.Name = strings.TrimSpace(manifest.Name) if manifest.Name == "" { @@ -137,7 +162,7 @@ func validateManifest(manifest templateManifest) error { } manifest.RuntimeKind = normalizeTemplateRuntimeKind(manifest.RuntimeKind) switch manifest.RuntimeKind { - case runtime.NamePicoClaw, runtime.NameOpenClaw, runtime.KindCodex: + case runtime.NamePicoClaw, runtime.NameOpenClaw, runtime.KindCodexSandbox, runtime.KindCodex: default: return fmt.Errorf("%w: %s", ErrRuntimeKindRequired, manifest.RuntimeKind) } diff --git a/internal/template/template_manifest_test.go b/internal/template/template_manifest_test.go index ddda2e4d..e275135a 100644 --- a/internal/template/template_manifest_test.go +++ b/internal/template/template_manifest_test.go @@ -3,6 +3,7 @@ package template import ( "os" "path/filepath" + "strings" "testing" "csgclaw/internal/runtime" @@ -58,6 +59,69 @@ default = "https://gitlab.example.com" } } +func TestLoadManifestNestedRuntimeKindAlias(t *testing.T) { + registryRoot := t.TempDir() + manifestPath := filepath.Join(registryRoot, "templates", "gitlab-assistant", "agent.toml") + if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + manifest := ` +name = "gitlab-assistant" +description = "GitLab assistant" +role = "worker" + +[runtime] +kind = "openclaw" + +[image] +ref = "openclaw:test" +` + if err := os.WriteFile(manifestPath, []byte(manifest), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + store := NewLocalStore(registryRoot) + got, err := store.Get(t.Context(), "gitlab-assistant") + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if got.RuntimeKind != runtime.NameOpenClaw { + t.Fatalf("RuntimeKind = %q, want %q", got.RuntimeKind, runtime.NameOpenClaw) + } +} + +func TestLoadManifestRejectsRuntimeKindConflict(t *testing.T) { + registryRoot := t.TempDir() + manifestPath := filepath.Join(registryRoot, "templates", "gitlab-assistant", "agent.toml") + if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + manifest := ` +name = "gitlab-assistant" +role = "worker" +runtime_kind = "picoclaw_sandbox" + +[runtime] +kind = "openclaw" + +[image] +ref = "openclaw:test" +` + if err := os.WriteFile(manifestPath, []byte(manifest), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + store := NewLocalStore(registryRoot) + _, err := store.Get(t.Context(), "gitlab-assistant") + if err == nil { + t.Fatal("Get() error = nil, want runtime kind conflict") + } + want := `runtime_kind "picoclaw_sandbox" conflicts with runtime.kind "openclaw"` + if !strings.Contains(err.Error(), want) { + t.Fatalf("Get() error = %v, want %q", err, want) + } +} + func TestValidateImageEnvRejectsSecretDefault(t *testing.T) { err := validateImageEnvContracts([]templateImageEnvItem{ {Name: "API_KEY", Secret: true, Default: "secret"}, diff --git a/internal/template/workspace_copy.go b/internal/template/workspace_copy.go index e2e71438..b28551db 100644 --- a/internal/template/workspace_copy.go +++ b/internal/template/workspace_copy.go @@ -168,6 +168,10 @@ func loadManifestFS(srcFS fs.FS, manifestPath, label string) (string, templateMa if err := toml.Unmarshal(data, &manifest); err != nil { return "", templateManifest{}, fmt.Errorf("decode %s manifest %q: %w", label, id, err) } + manifest, err = normalizeTemplateManifest(manifest) + if err != nil { + return "", templateManifest{}, fmt.Errorf("validate %s manifest %q: %w", label, id, err) + } if err := validateManifest(manifest); err != nil { return "", templateManifest{}, fmt.Errorf("validate %s manifest %q: %w", label, id, err) } diff --git a/web/app/src/api/agents.ts b/web/app/src/api/agents.ts index b1c6f8f0..8e58ce2a 100644 --- a/web/app/src/api/agents.ts +++ b/web/app/src/api/agents.ts @@ -280,6 +280,7 @@ export async function createBotRequest(payload: CreateBotPayload): Promise item.id === "builtin.codex-sandbox-worker") || + candidates.find((item) => item.name === "generic-assistant-codex-sandbox") || + candidates.find((item) => String(item.id || "").endsWith(".codex-sandbox-worker")) || + candidates[0] + ); + } if (requestedRuntime === DEFAULT_RUNTIME_KIND || !requestedRuntime) { return ( candidates.find((item) => item.id === "builtin.picoclaw-worker") || @@ -1822,6 +1835,9 @@ export function normalizeRuntimeKind(kind: unknown): RuntimeKind { return "picoclaw_sandbox"; case "codex": return "codex"; + case "codex_sandbox": + case "codex-sandbox": + return "codex_sandbox"; case "picoclaw_sandbox": return "picoclaw_sandbox"; default: @@ -1936,7 +1952,9 @@ export function availableManagerRuntimeOptions(bootstrapConfig: RuntimeBootstrap : []; const gatewayKinds = (configuredKinds.length ? configuredKinds : [DEFAULT_RUNTIME_KIND, "openclaw_sandbox"]) .map((kind) => normalizeRuntimeKind(kind)) - .filter((kind, index, array) => kind && kind !== "codex" && array.indexOf(kind) === index); + .filter( + (kind, index, array) => kind && kind !== "codex" && kind !== "codex_sandbox" && array.indexOf(kind) === index, + ); return RUNTIME_KIND_OPTIONS.filter((option) => gatewayKinds.includes(option.value)); } @@ -1980,7 +1998,7 @@ export function availableManagerRebuildRuntimeOptions( const seen = new Set(); const push = (kind: unknown) => { const normalized = normalizeRuntimeKind(kind); - if (!normalized || normalized === "codex" || seen.has(normalized)) { + if (!normalized || normalized === "codex" || normalized === "codex_sandbox" || seen.has(normalized)) { return; } seen.add(normalized); @@ -2036,7 +2054,7 @@ export function agentCreateProgressSteps(runtimeKind: unknown): AgentCreateProgr { label: "agentCreateProgressFinishing", target: 96 }, ]; } - if (kind === "openclaw_sandbox" || kind === DEFAULT_RUNTIME_KIND) { + if (kind === "openclaw_sandbox" || kind === "codex_sandbox" || kind === DEFAULT_RUNTIME_KIND) { return [ { label: "agentCreateProgressSandboxConfig", target: 16 }, { label: "agentCreateProgressImage", target: 42 }, @@ -2090,6 +2108,8 @@ export function formatRuntimeKindLabel(kind: unknown, t: TranslateFn): string { switch (runtimeKind) { case "openclaw_sandbox": return t("runtimeOpenclaw"); + case "codex_sandbox": + return t("runtimeCodexSandbox"); case "codex": return t("runtimeCodexCLI"); case "picoclaw_sandbox": diff --git a/web/app/src/pages/WorkspacePage/components/WorkspaceModals/AgentProfileModal.tsx b/web/app/src/pages/WorkspacePage/components/WorkspaceModals/AgentProfileModal.tsx index d65f58bc..4f4b3419 100644 --- a/web/app/src/pages/WorkspacePage/components/WorkspaceModals/AgentProfileModal.tsx +++ b/web/app/src/pages/WorkspacePage/components/WorkspaceModals/AgentProfileModal.tsx @@ -1,7 +1,4 @@ -import { - BOT_TYPE_NORMAL, - DEFAULT_RUNTIME_KIND, -} from "@/shared/constants/agents"; +import { BOT_TYPE_NORMAL, DEFAULT_RUNTIME_KIND } from "@/shared/constants/agents"; import { useEffect, useRef, useState, type SetStateAction } from "react"; import { AgentCreateProgress, @@ -301,12 +298,8 @@ export function AgentProfileModal({
{!isNotificationContext ? (
-
- {t("profileBasics")} -
-

- {t("profileBasicsDescription")} -

+
{t("profileBasics")}
+

{t("profileBasicsDescription")}

) : null}
@@ -369,7 +362,9 @@ export function AgentProfileModal({