mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +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.
131 lines
3.8 KiB
Go
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)
|
|
}
|
|
}
|