mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-12 12:18:55 +02:00
* 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.
87 lines
3.6 KiB
Go
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,
|
|
}
|
|
}
|