Files
multica/server/internal/daemon/openclaw_runtime_config_test.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

164 lines
4.9 KiB
Go

package daemon
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log/slog"
"strings"
"testing"
"github.com/multica-ai/multica/server/internal/daemon/execenv"
)
func quietLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
func TestDecodeOpenclawRuntimeConfigEmpty(t *testing.T) {
t.Parallel()
mode, gw := decodeOpenclawRuntimeConfig(nil, quietLogger())
if mode != "" {
t.Errorf("mode for nil payload: got %q, want \"\"", mode)
}
if !gw.IsZero() {
t.Errorf("gateway for nil payload: got %+v, want zero", gw)
}
}
func TestDecodeOpenclawRuntimeConfigGatewayMode(t *testing.T) {
t.Parallel()
raw := json.RawMessage(`{
"mode": "gateway",
"gateway": {
"host": "gw.internal",
"port": 18789,
"token": "secret",
"tls": true
}
}`)
mode, gw := decodeOpenclawRuntimeConfig(raw, quietLogger())
if mode != "gateway" {
t.Errorf("mode: got %q, want %q", mode, "gateway")
}
want := execenv.OpenclawGatewayPin{
Host: "gw.internal",
Port: 18789,
Token: "secret",
TLS: true,
}
if gw != want {
t.Errorf("gateway: got %+v, want %+v", gw, want)
}
}
func TestDecodeOpenclawRuntimeConfigMalformedFailsSoftToLocal(t *testing.T) {
t.Parallel()
// A broken JSON blob must never block dispatch — the agent runs in the
// historical embedded mode until the user fixes the config.
mode, gw := decodeOpenclawRuntimeConfig(json.RawMessage(`{"mode": "gateway"`), quietLogger())
if mode != "" {
t.Errorf("mode for malformed payload: got %q, want \"\"", mode)
}
if !gw.IsZero() {
t.Errorf("gateway for malformed payload: got %+v, want zero", gw)
}
}
func TestDecodeOpenclawRuntimeConfigModeOnly(t *testing.T) {
t.Parallel()
// Users may switch to gateway mode and rely on the daemon host's local
// ~/.openclaw/openclaw.json for the endpoint — gateway block stays zero.
mode, gw := decodeOpenclawRuntimeConfig(json.RawMessage(`{"mode": "gateway"}`), quietLogger())
if mode != "gateway" {
t.Errorf("mode: got %q, want %q", mode, "gateway")
}
if !gw.IsZero() {
t.Errorf("gateway: got %+v, want zero", gw)
}
}
// TestOpenclawGatewayPinDefaultFormattingMasksToken — a stray `%v` /
// `%+v` / json.Marshal of an OpenclawGatewayPin must NOT print the bearer
// token verbatim. The wrapper-config writer still gets the real value
// directly off the Token field; only default formatters get redacted.
// Guards against the secondary leak path called out in the issue #3260 CR.
func TestOpenclawGatewayPinDefaultFormattingMasksToken(t *testing.T) {
t.Parallel()
pin := execenv.OpenclawGatewayPin{
Host: "gw.internal",
Port: 18789,
Token: "real-secret",
TLS: true,
}
if got := pin.String(); strings.Contains(got, "real-secret") {
t.Errorf("String() leaks token: %q", got)
}
if got := fmt.Sprintf("%+v", pin); strings.Contains(got, "real-secret") {
t.Errorf("%%+v leaks token: %q", got)
}
raw, err := json.Marshal(pin)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if strings.Contains(string(raw), "real-secret") {
t.Errorf("MarshalJSON leaks token: %s", raw)
}
// Sanity: the host stays visible so the masked payload is still
// useful for debugging the non-secret half of the pin.
if !strings.Contains(string(raw), "gw.internal") {
t.Errorf("MarshalJSON dropped host along with token: %s", raw)
}
}
// TestDecodeOpenclawRuntimeConfigLocalModeDropsGatewayPin — a local-mode
// payload that still carries a gateway block (craftable via a direct PATCH)
// must not surface the pin. Otherwise the bearer token would be written into
// the 0o600 per-task wrapper that `--local` makes openclaw ignore.
func TestDecodeOpenclawRuntimeConfigLocalModeDropsGatewayPin(t *testing.T) {
t.Parallel()
raw := json.RawMessage(`{
"mode": "local",
"gateway": {"host": "gw.internal", "port": 18789, "token": "secret", "tls": true}
}`)
mode, gw := decodeOpenclawRuntimeConfig(raw, quietLogger())
if mode != "local" {
t.Errorf("mode: got %q, want %q", mode, "local")
}
if !gw.IsZero() {
t.Errorf("gateway for local mode: got %+v, want zero", gw)
}
}
// TestDecodeOpenclawRuntimeConfigUnknownModeWarnsAndDropsPin — a typo'd mode
// neither behaves like gateway nor silently like local: it falls back to local
// (zero pin) AND logs a WARN so the misconfiguration is discoverable.
func TestDecodeOpenclawRuntimeConfigUnknownModeWarnsAndDropsPin(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn}))
raw := json.RawMessage(`{
"mode": "gatway",
"gateway": {"host": "gw.internal", "port": 18789, "token": "secret"}
}`)
mode, gw := decodeOpenclawRuntimeConfig(raw, logger)
if mode != "gatway" {
t.Errorf("mode: got %q, want %q", mode, "gatway")
}
if !gw.IsZero() {
t.Errorf("gateway for unknown mode: got %+v, want zero", gw)
}
if !strings.Contains(buf.String(), "unrecognized mode") {
t.Errorf("expected WARN about unrecognized mode, got: %q", buf.String())
}
}