Files
multica/server/internal/daemon/openclaw_runtime_config.go
YOMXXX 34d4cd3a28 feat(openclaw): support connecting to existing OpenClaw gateway (#3260) [MUL-3158] (#3664)
* feat(openclaw): support connecting to existing OpenClaw gateway (#3260)

When the daemon host is a lightweight dev machine or CI coordinator, the
heavy agent work (LLM inference, code execution, tool use) often belongs
on a more powerful remote server already running an OpenClaw gateway.
Multica historically hard-coded `openclaw agent --local`, forcing every
turn to execute in-process on the daemon host.

This change adds an opt-in gateway routing mode controlled per-agent via
`runtime_config`:

  {
    "mode": "gateway",
    "gateway": { "host": "...", "port": 18789, "token": "...", "tls": false }
  }

- Backend: ExecOptions gains OpenclawMode + OpenclawGateway; buildOpenclawArgs
  drops `--local` when mode == "gateway". Per-task openclaw-config.json
  wrapper pins gateway.{host,port,auth.{mode,token},tls} so users do not
  need to edit the daemon host's `~/.openclaw/openclaw.json` to point at
  a different endpoint.
- Daemon: AgentData carries the raw runtime_config; decoding is fail-soft
  (malformed JSON falls back to local mode rather than blocking dispatch).
- API: gateway.token is masked to "***" on every GET; PATCH replays the
  sentinel back, and the update handler restores the persisted token so
  the round-trip never destroys the secret. Defense-in-depth masking on
  WS broadcasts, plus String/MarshalJSON masking on the in-memory struct
  to block stray `%+v` / json.Marshal leaks.
- UI: openclaw-only "Routing" tab on the agent detail page with mode
  selector + structured endpoint form. Token uses a "saved — submit a
  new value to rotate" UX and matching backend preserve hook.

Empty `runtime_config` keeps the historical embedded behaviour, so
existing agents are unaffected.

* fix(openclaw): address #3664 review — drop dead gateway field, gate pin on mode

Per Bohan-J's review:

- Remove the dead ExecOptions.OpenclawGateway field (+ its String/MarshalJSON and
  the daemon.go construction block). It carried the plaintext bearer token but was
  never read — buildOpenclawArgs only consumes OpenclawMode and the live gateway
  path runs through execenv.OpenclawGatewayPin — so this narrows the secret's
  footprint.
- Gate the gateway pin on mode=="gateway" in decodeOpenclawRuntimeConfig: a
  {"mode":"local","gateway":{...,"token"}} payload no longer writes the token into
  the 0o600 per-task wrapper that --local makes openclaw ignore.
- Warn on an unrecognized non-empty mode (e.g. "gatway") instead of silently
  falling back to local.
- Run preserveMaskedGatewayToken in CreateAgent too, so a literal "***" at create
  time can't persist as a real bearer token.
- Document the gateway host:port trust boundary (SSRF note for shared daemon hosts).

Adds regression tests for the local-mode pin drop and the unknown-mode warning.
2026-06-13 15:33:28 +08:00

87 lines
3.6 KiB
Go

package daemon
import (
"encoding/json"
"log/slog"
"github.com/multica-ai/multica/server/internal/daemon/execenv"
)
// openclawRuntimeConfig is the schema the daemon expects under an openclaw
// agent's `runtime_config` JSONB column. All fields are optional; absence
// (or the agent's whole runtime_config being null/empty) keeps the
// historical embedded behaviour so existing agents are unaffected.
//
// Schema (issue #3260):
//
// {
// "mode": "local" | "gateway", // default: "local"
// "gateway": {
// "host": "<hostname>", // remote OpenClaw gateway host
// "port": 18789, // gateway port
// "token": "<bearer>", // gateway auth token (masked in API responses)
// "tls": false // dial https if true
// }
// }
//
// Other providers' runtime_config payloads pass through untouched — this
// decoder only reads keys that have meaning for the openclaw backend.
type openclawRuntimeConfig struct {
Mode string `json:"mode"`
Gateway openclawRuntimeGatewayConfig `json:"gateway"`
}
// openclawRuntimeGatewayConfig is the owner-supplied Gateway endpoint.
//
// Trust boundary: in gateway mode the daemon writes this host:port into the
// per-task wrapper and the spawned openclaw CLI dials it. For self-hosted,
// single-tenant daemons this is the same trust level as custom_args /
// custom_env — the owner already controls the daemon host. Operators running
// a SHARED / managed daemon host should treat it as an SSRF surface (an agent
// owner could point the daemon at an arbitrary internal address) and
// gate/allowlist gateway targets accordingly.
type openclawRuntimeGatewayConfig struct {
Host string `json:"host"`
Port int `json:"port"`
Token string `json:"token"`
TLS bool `json:"tls"`
}
// decodeOpenclawRuntimeConfig extracts the openclaw-specific knobs from an
// agent's runtime_config payload. Returns the routing mode plus the gateway
// pin shaped for execenv. The pin is non-zero only in gateway mode — any
// other mode drops it so a local-mode payload can't smuggle a bearer token
// into the per-task wrapper. A malformed payload logs a warning and degrades
// to local mode (mode="", zero gateway) rather than failing dispatch — the
// alternative would let one bad save block every task that agent runs.
func decodeOpenclawRuntimeConfig(raw json.RawMessage, logger *slog.Logger) (string, execenv.OpenclawGatewayPin) {
if len(raw) == 0 {
return "", execenv.OpenclawGatewayPin{}
}
var cfg openclawRuntimeConfig
if err := json.Unmarshal(raw, &cfg); err != nil {
logger.Warn("openclaw runtime_config: parse failed; falling back to local mode", "error", err)
return "", execenv.OpenclawGatewayPin{}
}
// Surface an unrecognized non-empty mode instead of silently treating it
// as local — a typo like "gatway" would otherwise leave the user wondering
// why their gateway config is ignored.
if cfg.Mode != "" && cfg.Mode != "local" && cfg.Mode != "gateway" {
logger.Warn("openclaw runtime_config: unrecognized mode; falling back to local mode",
"mode", cfg.Mode)
}
// Only gateway mode consults the pin. For every other mode (local / empty /
// unrecognized) drop the gateway block so a stray
// {"mode":"local","gateway":{...,"token":"..."}} never writes the bearer
// token into the 0o600 per-task wrapper that `--local` makes openclaw ignore.
if cfg.Mode != "gateway" {
return cfg.Mode, execenv.OpenclawGatewayPin{}
}
return cfg.Mode, execenv.OpenclawGatewayPin{
Host: cfg.Gateway.Host,
Port: cfg.Gateway.Port,
Token: cfg.Gateway.Token,
TLS: cfg.Gateway.TLS,
}
}