Files
multica/server/internal/handler/agent_runtime_config_mask_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

131 lines
3.8 KiB
Go

package handler
import (
"encoding/json"
"testing"
)
func TestMaskGatewayTokenReplacesNonEmpty(t *testing.T) {
t.Parallel()
rc := map[string]any{
"mode": "gateway",
"gateway": map[string]any{
"host": "gw.internal",
"port": float64(18789), // json.Unmarshal yields float64 for numbers
"token": "real-secret",
"tls": true,
},
}
maskGatewayToken(rc)
gw := rc["gateway"].(map[string]any)
if gw["token"] != runtimeConfigGatewayTokenMask {
t.Errorf("token: got %v, want %q", gw["token"], runtimeConfigGatewayTokenMask)
}
if gw["host"] != "gw.internal" {
t.Errorf("host must not be touched, got %v", gw["host"])
}
}
func TestMaskGatewayTokenSkipsEmptyToken(t *testing.T) {
t.Parallel()
// host+port-only configs (token still inherited from the user's local
// openclaw.json) must not surface a misleading "***" placeholder.
rc := map[string]any{
"gateway": map[string]any{
"host": "gw.internal",
"port": float64(18789),
},
}
maskGatewayToken(rc)
gw := rc["gateway"].(map[string]any)
if _, present := gw["token"]; present {
t.Errorf("empty token must not gain a mask, got %v", gw["token"])
}
}
func TestMaskGatewayTokenNoOpOnNonOpenclawShape(t *testing.T) {
t.Parallel()
// rc with no `gateway` key (e.g. other providers' runtime_config) must
// pass through untouched.
rc := map[string]any{"some_other_key": "value"}
maskGatewayToken(rc)
if _, present := rc["gateway"]; present {
t.Errorf("must not synthesise gateway key, got %v", rc)
}
}
func TestPreserveMaskedGatewayTokenRestoresFromPersisted(t *testing.T) {
t.Parallel()
persisted := []byte(`{"mode":"gateway","gateway":{"token":"real-secret","host":"gw.internal"}}`)
incoming := map[string]any{
"mode": "gateway",
"gateway": map[string]any{
"host": "gw.internal",
"port": float64(18789),
"token": runtimeConfigGatewayTokenMask,
},
}
preserveMaskedGatewayToken(incoming, persisted)
gw := incoming["gateway"].(map[string]any)
if gw["token"] != "real-secret" {
t.Errorf("token should be restored from persisted row, got %v", gw["token"])
}
}
func TestPreserveMaskedGatewayTokenPassesThroughRealValue(t *testing.T) {
t.Parallel()
// A genuine new token in the PATCH body must overwrite the persisted one.
persisted := []byte(`{"gateway":{"token":"old-secret"}}`)
incoming := map[string]any{
"gateway": map[string]any{"token": "rotated-secret"},
}
preserveMaskedGatewayToken(incoming, persisted)
gw := incoming["gateway"].(map[string]any)
if gw["token"] != "rotated-secret" {
t.Errorf("real PATCH token must win, got %v", gw["token"])
}
}
func TestPreserveMaskedGatewayTokenDropsMaskWhenNoPersistedToken(t *testing.T) {
t.Parallel()
// A first-time gateway config that only contained host/port has no
// stored token. If a later PATCH sends the mask back (e.g. a UI that
// always includes the field), we must drop the placeholder rather than
// landing the literal "***" string in the database as a fake bearer.
persisted := []byte(`{"gateway":{"host":"gw.internal"}}`)
incoming := map[string]any{
"gateway": map[string]any{"token": runtimeConfigGatewayTokenMask},
}
preserveMaskedGatewayToken(incoming, persisted)
gw := incoming["gateway"].(map[string]any)
if _, present := gw["token"]; present {
t.Errorf("token must be dropped, got %v", gw["token"])
}
}
// Round-trip: marshal a runtime_config, mask it, ensure it stays a valid
// shape that can survive json.Marshal again (no NaNs, no funny types).
func TestMaskGatewayTokenRoundTripsAsJSON(t *testing.T) {
t.Parallel()
raw := []byte(`{"gateway":{"token":"plaintext","host":"gw"}}`)
var rc any
if err := json.Unmarshal(raw, &rc); err != nil {
t.Fatalf("unmarshal: %v", err)
}
maskGatewayToken(rc)
out, err := json.Marshal(rc)
if err != nil {
t.Fatalf("marshal after mask: %v", err)
}
if string(out) == string(raw) {
t.Errorf("mask should change the bytes, got %q", out)
}
}