yuan-lab-llm--clawmanager
58c19856fd
* feat(runtime): improve Lite gateway scheduling and proxy stability - Expand the runtime gateway port range to match 100 instances per pod - Scale runtime deployments based on pending backlog - Serialize gateway creation per pod and handle starting/creating states - Clean up missing gateway bindings from runtime agent reports - Improve Lite gateway proxy token stripping and auth header injection - Update deployment manifests, docs, and related tests * fix(runtime): harden Hermes Lite gateway recovery - Add hashed gateway token aliases for short-term token compatibility - Fix /resume conversations failing when old sessions use mismatched gateway tokens - Recover ports only from stale error bindings without affecting active gateways * feat(runtime): enable bidirectional desktop clipboard and direct proxy - Enable WebTop/Selkies/Kasm clipboard by default with per-instance overrides - Document clipboard verification, configuration pitfalls, and IME troubleshooting - Enable direct desktop proxy in the K8s and K3s manifests --------- Co-authored-by: litiantian03 <litiantian03@ieisystem.com>
359 行
12 KiB
Go
359 行
12 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"clawreef/internal/models"
|
|
)
|
|
|
|
func TestGatewayTokenAliasTTLConfig(t *testing.T) {
|
|
t.Setenv(gatewayTokenAliasTTLEnv, "")
|
|
if got := gatewayTokenAliasTTL(); got != defaultGatewayTokenAliasTTL {
|
|
t.Fatalf("default gateway token alias TTL = %v, want %v", got, defaultGatewayTokenAliasTTL)
|
|
}
|
|
|
|
t.Setenv(gatewayTokenAliasTTLEnv, "2")
|
|
if got := gatewayTokenAliasTTL(); got != 2*time.Hour {
|
|
t.Fatalf("configured gateway token alias TTL = %v, want 2h", got)
|
|
}
|
|
|
|
t.Setenv(gatewayTokenAliasTTLEnv, "0")
|
|
if got := gatewayTokenAliasTTL(); got != 0 {
|
|
t.Fatalf("disabled gateway token alias TTL = %v, want 0", got)
|
|
}
|
|
}
|
|
|
|
type stubLLMModelRepository struct {
|
|
active []models.LLMModel
|
|
}
|
|
|
|
func (r *stubLLMModelRepository) List() ([]models.LLMModel, error) {
|
|
items := make([]models.LLMModel, len(r.active))
|
|
copy(items, r.active)
|
|
return items, nil
|
|
}
|
|
|
|
func (r *stubLLMModelRepository) ListActive() ([]models.LLMModel, error) {
|
|
items := make([]models.LLMModel, len(r.active))
|
|
copy(items, r.active)
|
|
return items, nil
|
|
}
|
|
|
|
func (r *stubLLMModelRepository) GetByID(id int) (*models.LLMModel, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (r *stubLLMModelRepository) GetByDisplayName(displayName string) (*models.LLMModel, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (r *stubLLMModelRepository) Save(model *models.LLMModel) error {
|
|
return nil
|
|
}
|
|
|
|
func (r *stubLLMModelRepository) Delete(id int) error {
|
|
return nil
|
|
}
|
|
|
|
func TestBuildGatewayEnvInjectsGatewayModelCatalog(t *testing.T) {
|
|
t.Setenv("CLAWMANAGER_LLM_GATEWAY_BASE_URL", "http://gateway.example/api/v1/gateway/llm")
|
|
|
|
token := "igt_test_token"
|
|
for _, instanceType := range []string{"openclaw", "hermes"} {
|
|
t.Run(instanceType, func(t *testing.T) {
|
|
service := &instanceService{
|
|
llmModelRepo: &stubLLMModelRepository{
|
|
active: []models.LLMModel{
|
|
{DisplayName: "GPT-4.1"},
|
|
{DisplayName: "Claude 3.7 Sonnet"},
|
|
{DisplayName: "auto"},
|
|
{ProviderModelName: "deepseek-r1"},
|
|
},
|
|
},
|
|
}
|
|
|
|
env, err := service.buildGatewayEnv(&models.Instance{
|
|
Type: instanceType,
|
|
AccessToken: &token,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("buildGatewayEnv returned error: %v", err)
|
|
}
|
|
|
|
if env["CLAWMANAGER_LLM_BASE_URL"] != "http://gateway.example/api/v1/gateway/llm" {
|
|
t.Fatalf("expected CLAWMANAGER_LLM_BASE_URL to use gateway base URL, got %q", env["CLAWMANAGER_LLM_BASE_URL"])
|
|
}
|
|
if env["CLAWMANAGER_LLM_MODEL"] != `["auto","GPT-4.1","Claude 3.7 Sonnet","deepseek-r1"]` {
|
|
t.Fatalf("expected CLAWMANAGER_LLM_MODEL to contain injected model catalog JSON, got %q", env["CLAWMANAGER_LLM_MODEL"])
|
|
}
|
|
if env["OPENAI_MODEL"] != "auto" {
|
|
t.Fatalf("expected OPENAI_MODEL to remain the default gateway alias, got %q", env["OPENAI_MODEL"])
|
|
}
|
|
if env["CLAWMANAGER_LLM_API_KEY"] != token || env["OPENAI_API_KEY"] != token {
|
|
t.Fatalf("expected gateway token aliases to be preserved")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBuildGatewayEnvEnsuresMissingGatewayToken(t *testing.T) {
|
|
t.Setenv("CLAWMANAGER_LLM_GATEWAY_BASE_URL", "http://gateway.example/api/v1/gateway/llm")
|
|
|
|
instanceRepo := &stubGatewayEnvInstanceRepository{fakeRuntimeInstanceRepo: newFakeRuntimeInstanceRepo()}
|
|
service := &instanceService{
|
|
instanceRepo: instanceRepo,
|
|
llmModelRepo: &stubLLMModelRepository{
|
|
active: []models.LLMModel{{DisplayName: "auto"}},
|
|
},
|
|
}
|
|
instance := &models.Instance{
|
|
ID: 68,
|
|
UserID: 1,
|
|
Type: "openclaw",
|
|
}
|
|
|
|
env, err := service.BuildGatewayEnv(instance)
|
|
if err != nil {
|
|
t.Fatalf("BuildGatewayEnv returned error: %v", err)
|
|
}
|
|
if instance.AccessToken == nil || *instance.AccessToken == "" {
|
|
t.Fatal("BuildGatewayEnv did not provision instance access token")
|
|
}
|
|
if env["CLAWMANAGER_LLM_API_KEY"] != *instance.AccessToken {
|
|
t.Fatalf("CLAWMANAGER_LLM_API_KEY = %q, want provisioned token %q", env["CLAWMANAGER_LLM_API_KEY"], *instance.AccessToken)
|
|
}
|
|
if got := instanceRepo.updated[68]; got == nil || got.AccessToken == nil || *got.AccessToken != *instance.AccessToken {
|
|
t.Fatalf("repository update did not persist provisioned token: %#v", instanceRepo.updated[68])
|
|
}
|
|
alias, ok := instanceRepo.aliases[68]
|
|
if !ok {
|
|
t.Fatal("repository did not record provisioned token as a gateway alias")
|
|
}
|
|
if alias.token != *instance.AccessToken {
|
|
t.Fatalf("gateway alias token = %q, want provisioned token %q", alias.token, *instance.AccessToken)
|
|
}
|
|
if alias.expiresAt.Before(time.Now().Add(6 * 24 * time.Hour)) {
|
|
t.Fatalf("gateway alias expiry = %v, want roughly the default compatibility window", alias.expiresAt)
|
|
}
|
|
}
|
|
|
|
func TestBuildGatewayEnvRecordsExistingGatewayTokenAlias(t *testing.T) {
|
|
t.Setenv("CLAWMANAGER_LLM_GATEWAY_BASE_URL", "http://gateway.example/api/v1/gateway/llm")
|
|
|
|
token := "igt_existing_token"
|
|
instanceRepo := &stubGatewayEnvInstanceRepository{fakeRuntimeInstanceRepo: newFakeRuntimeInstanceRepo()}
|
|
service := &instanceService{
|
|
instanceRepo: instanceRepo,
|
|
llmModelRepo: &stubLLMModelRepository{
|
|
active: []models.LLMModel{{DisplayName: "auto"}},
|
|
},
|
|
}
|
|
|
|
env, err := service.BuildGatewayEnv(&models.Instance{
|
|
ID: 69,
|
|
UserID: 1,
|
|
Type: "hermes",
|
|
AccessToken: &token,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("BuildGatewayEnv returned error: %v", err)
|
|
}
|
|
if env["CLAWMANAGER_LLM_API_KEY"] != token {
|
|
t.Fatalf("CLAWMANAGER_LLM_API_KEY = %q, want existing token", env["CLAWMANAGER_LLM_API_KEY"])
|
|
}
|
|
alias, ok := instanceRepo.aliases[69]
|
|
if !ok {
|
|
t.Fatal("repository did not record existing token as a gateway alias")
|
|
}
|
|
if alias.token != token {
|
|
t.Fatalf("gateway alias token = %q, want existing token %q", alias.token, token)
|
|
}
|
|
}
|
|
func TestBuildGatewayEnvMergesEnvironmentOverrides(t *testing.T) {
|
|
t.Setenv("CLAWMANAGER_LLM_GATEWAY_BASE_URL", "http://gateway.example/api/v1/gateway/llm")
|
|
token := "igt_test_token"
|
|
raw, err := marshalEnvironmentOverrides(map[string]string{
|
|
"CLAWMANAGER_TEAM_ENABLED": "true",
|
|
"CLAWMANAGER_TEAM_MEMBER_ID": "lite-worker",
|
|
"CUSTOM_GATEWAY_ENV": "enabled",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("marshalEnvironmentOverrides returned error: %v", err)
|
|
}
|
|
service := &instanceService{
|
|
llmModelRepo: &stubLLMModelRepository{
|
|
active: []models.LLMModel{{DisplayName: "auto"}},
|
|
},
|
|
}
|
|
|
|
env, err := service.BuildGatewayEnv(&models.Instance{
|
|
ID: 88,
|
|
Type: "openclaw",
|
|
RuntimeType: RuntimeBackendGateway,
|
|
AccessToken: &token,
|
|
EnvironmentOverridesJSON: raw,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("BuildGatewayEnv returned error: %v", err)
|
|
}
|
|
|
|
if env["CLAWMANAGER_TEAM_ENABLED"] != "true" || env["CLAWMANAGER_TEAM_MEMBER_ID"] != "lite-worker" {
|
|
t.Fatalf("expected Team environment overrides to be merged into gateway env, got %#v", env)
|
|
}
|
|
if env["CUSTOM_GATEWAY_ENV"] != "enabled" {
|
|
t.Fatalf("expected custom gateway environment override to be merged, got %#v", env)
|
|
}
|
|
if env["CLAWMANAGER_LLM_API_KEY"] != token {
|
|
t.Fatalf("expected gateway token env to remain available")
|
|
}
|
|
if env["CLAWMANAGER_RUNTIME_TYPE"] != RuntimeBackendGateway {
|
|
t.Fatalf("expected runtime type marker %q, got %q", RuntimeBackendGateway, env["CLAWMANAGER_RUNTIME_TYPE"])
|
|
}
|
|
}
|
|
|
|
func TestBuildGatewayEnvSkipsUnmanagedRuntime(t *testing.T) {
|
|
token := "igt_test_token"
|
|
service := &instanceService{}
|
|
|
|
env, err := service.buildGatewayEnv(&models.Instance{
|
|
Type: "ubuntu",
|
|
AccessToken: &token,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("buildGatewayEnv returned error: %v", err)
|
|
}
|
|
if len(env) != 0 {
|
|
t.Fatalf("expected unmanaged runtime to receive no gateway env, got %#v", env)
|
|
}
|
|
}
|
|
|
|
type gatewayTokenAliasRecord struct {
|
|
token string
|
|
expiresAt time.Time
|
|
}
|
|
|
|
type stubGatewayEnvInstanceRepository struct {
|
|
*fakeRuntimeInstanceRepo
|
|
updated map[int]*models.Instance
|
|
aliases map[int]gatewayTokenAliasRecord
|
|
}
|
|
|
|
func (r *stubGatewayEnvInstanceRepository) Update(instance *models.Instance) error {
|
|
if r.updated == nil {
|
|
r.updated = map[int]*models.Instance{}
|
|
}
|
|
copy := *instance
|
|
r.updated[instance.ID] = ©
|
|
return nil
|
|
}
|
|
|
|
func (r *stubGatewayEnvInstanceRepository) UpsertGatewayTokenAlias(ctx context.Context, instanceID int, accessToken string, expiresAt time.Time) error {
|
|
if r.aliases == nil {
|
|
r.aliases = map[int]gatewayTokenAliasRecord{}
|
|
}
|
|
r.aliases[instanceID] = gatewayTokenAliasRecord{token: accessToken, expiresAt: expiresAt}
|
|
return nil
|
|
}
|
|
func TestBuildAgentEnvInjectsHermesAgentConfig(t *testing.T) {
|
|
t.Setenv("CLAWMANAGER_AGENT_CONTROL_BASE_URL", "http://agent-control.example")
|
|
|
|
token := "agt_boot_test_token"
|
|
service := &instanceService{}
|
|
|
|
env, err := service.buildAgentEnv(&models.Instance{
|
|
ID: 24,
|
|
Type: "hermes",
|
|
DiskGB: 20,
|
|
AgentBootstrapToken: &token,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("buildAgentEnv returned error: %v", err)
|
|
}
|
|
|
|
if env["CLAWMANAGER_AGENT_ENABLED"] != "true" {
|
|
t.Fatalf("expected Hermes agent to be enabled")
|
|
}
|
|
if env["CLAWMANAGER_AGENT_BASE_URL"] != "http://agent-control.example" {
|
|
t.Fatalf("expected Hermes agent base URL to be injected, got %q", env["CLAWMANAGER_AGENT_BASE_URL"])
|
|
}
|
|
if env["CLAWMANAGER_AGENT_BOOTSTRAP_TOKEN"] != token {
|
|
t.Fatalf("expected Hermes agent bootstrap token to be injected")
|
|
}
|
|
if env["CLAWMANAGER_AGENT_INSTANCE_ID"] != "24" {
|
|
t.Fatalf("expected Hermes instance id to be injected, got %q", env["CLAWMANAGER_AGENT_INSTANCE_ID"])
|
|
}
|
|
if env["CLAWMANAGER_AGENT_PERSISTENT_DIR"] != "/config/.hermes" {
|
|
t.Fatalf("expected Hermes persistent dir /config/.hermes, got %q", env["CLAWMANAGER_AGENT_PERSISTENT_DIR"])
|
|
}
|
|
if env["CLAWMANAGER_AGENT_DISK_LIMIT_BYTES"] != "21474836480" {
|
|
t.Fatalf("expected Hermes disk limit bytes to be injected, got %q", env["CLAWMANAGER_AGENT_DISK_LIMIT_BYTES"])
|
|
}
|
|
}
|
|
|
|
func TestPersistentVolumeMountPathNormalizesManagedDesktopRuntimes(t *testing.T) {
|
|
for _, instanceType := range []string{"openclaw", "ubuntu", "webtop", "hermes"} {
|
|
t.Run(instanceType, func(t *testing.T) {
|
|
got := persistentVolumeMountPath(&models.Instance{
|
|
Type: instanceType,
|
|
MountPath: "/data",
|
|
})
|
|
if got != "/config" {
|
|
t.Fatalf("expected %s PVC mount path /config, got %q", instanceType, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestManagedRuntimePersistentDirKeepsHermesSubdirectory(t *testing.T) {
|
|
got := managedRuntimePersistentDir(&models.Instance{
|
|
Type: "hermes",
|
|
MountPath: "/config",
|
|
})
|
|
if got != "/config/.hermes" {
|
|
t.Fatalf("expected Hermes persistent dir /config/.hermes, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestRuntimeVolumeInitScriptsAddsHermesLayoutMigration(t *testing.T) {
|
|
scripts := runtimeVolumeInitScripts("hermes", "/config")
|
|
if len(scripts) != 1 {
|
|
t.Fatalf("expected one Hermes volume init script, got %d", len(scripts))
|
|
}
|
|
if scripts[0].Name != "data" || scripts[0].MountPath != "/config" {
|
|
t.Fatalf("unexpected Hermes volume init script: %#v", scripts[0])
|
|
}
|
|
if !strings.Contains(scripts[0].Script, `target="$base/.hermes"`) {
|
|
t.Fatalf("expected Hermes init script to target /config/.hermes, got %s", scripts[0].Script)
|
|
}
|
|
}
|
|
|
|
func TestResolveGatewayModelInjectionRequiresActiveModels(t *testing.T) {
|
|
service := &instanceService{
|
|
llmModelRepo: &stubLLMModelRepository{},
|
|
}
|
|
|
|
injection, err := service.resolveGatewayModelInjection()
|
|
if err == nil {
|
|
t.Fatalf("expected resolveGatewayModelInjection to fail when no active models exist, got %#v", injection)
|
|
}
|
|
}
|
|
|
|
func TestSecurityModeForInstance(t *testing.T) {
|
|
service := &instanceService{}
|
|
|
|
if got := service.securityModeForInstance("openclaw"); got != "chromium-compat" {
|
|
t.Fatalf("expected openclaw to use chromium compat mode, got %q", got)
|
|
}
|
|
if got := service.securityModeForInstance("ubuntu"); got != "default" {
|
|
t.Fatalf("expected ubuntu to use default security mode, got %q", got)
|
|
}
|
|
|
|
service.allowPrivilegedPods = true
|
|
if got := service.securityModeForInstance("openclaw"); got != "privileged" {
|
|
t.Fatalf("expected explicit privileged override to win, got %q", got)
|
|
}
|
|
}
|