diff --git a/cmd/launch/openclaw.go b/cmd/launch/openclaw.go index b47dec022..5373a70db 100644 --- a/cmd/launch/openclaw.go +++ b/cmd/launch/openclaw.go @@ -312,9 +312,10 @@ func (c *Openclaw) onboarded() bool { return lastRunAt != "" } -// patchDeviceScopes upgrades the local CLI device's paired scopes to include -// operator.admin. Only patches the local device, not remote ones. -// Best-effort: silently returns on any error. +// patchDeviceScopes upgrades the local CLI device's paired operator scopes so +// newer gateway auth baselines (approvedScopes) allow launch+TUI reconnects +// without forcing an interactive re-pair. Only patches the local device, +// not remote ones. Best-effort: silently returns on any error. func patchDeviceScopes() { home, err := os.UserHomeDir() if err != nil { @@ -350,9 +351,15 @@ func patchDeviceScopes() { } changed := patchScopes(dev, "scopes", required) + if patchScopes(dev, "approvedScopes", required) { + changed = true + } if tokens, ok := dev["tokens"].(map[string]any); ok { - for _, tok := range tokens { + for role, tok := range tokens { if tokenMap, ok := tok.(map[string]any); ok { + if !isOperatorToken(role, tokenMap) { + continue + } if patchScopes(tokenMap, "scopes", required) { changed = true } @@ -408,6 +415,14 @@ func patchScopes(obj map[string]any, key string, required []string) bool { return added } +func isOperatorToken(tokenRole string, token map[string]any) bool { + if strings.EqualFold(strings.TrimSpace(tokenRole), "operator") { + return true + } + role, _ := token["role"].(string) + return strings.EqualFold(strings.TrimSpace(role), "operator") +} + // canInstallDaemon reports whether the openclaw daemon can be installed as a // background service. Returns false on Linux when systemd is absent (e.g. // containers) so that --install-daemon is omitted and the gateway is started diff --git a/cmd/launch/openclaw_test.go b/cmd/launch/openclaw_test.go index a7782ebd4..9b02720e1 100644 --- a/cmd/launch/openclaw_test.go +++ b/cmd/launch/openclaw_test.go @@ -1036,6 +1036,133 @@ func TestOpenclawGatewayInfo(t *testing.T) { }) } +func TestPatchDeviceScopes(t *testing.T) { + t.Run("patches device approved scopes and operator token only", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + + identityDir := filepath.Join(tmpDir, ".openclaw", "identity") + if err := os.MkdirAll(identityDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(identityDir, "device-auth.json"), []byte(`{"deviceId":"dev-1"}`), 0o600); err != nil { + t.Fatal(err) + } + + devicesDir := filepath.Join(tmpDir, ".openclaw", "devices") + if err := os.MkdirAll(devicesDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(devicesDir, "paired.json"), []byte(`{ + "dev-1": { + "deviceId": "dev-1", + "scopes": ["operator.read"], + "approvedScopes": ["operator.read"], + "tokens": { + "operator": {"role":"operator","scopes":["operator.read"]}, + "node": {"role":"node","scopes":["node.exec"]} + } + } + }`), 0o600); err != nil { + t.Fatal(err) + } + + patchDeviceScopes() + + data, err := os.ReadFile(filepath.Join(devicesDir, "paired.json")) + if err != nil { + t.Fatal(err) + } + var devices map[string]map[string]any + if err := json.Unmarshal(data, &devices); err != nil { + t.Fatal(err) + } + + required := []string{ + "operator.read", + "operator.admin", + "operator.approvals", + "operator.pairing", + } + + toSet := func(v any) map[string]bool { + out := map[string]bool{} + items, _ := v.([]any) + for _, item := range items { + if s, ok := item.(string); ok { + out[s] = true + } + } + return out + } + assertContainsAll := func(name string, got any, want []string) { + t.Helper() + set := toSet(got) + for _, scope := range want { + if !set[scope] { + t.Fatalf("%s missing required scope %q (got=%v)", name, scope, set) + } + } + } + + dev := devices["dev-1"] + assertContainsAll("device.scopes", dev["scopes"], required) + assertContainsAll("device.approvedScopes", dev["approvedScopes"], required) + + tokens, _ := dev["tokens"].(map[string]any) + operator, _ := tokens["operator"].(map[string]any) + assertContainsAll("tokens.operator.scopes", operator["scopes"], required) + + node, _ := tokens["node"].(map[string]any) + nodeScopes := toSet(node["scopes"]) + if len(nodeScopes) != 1 || !nodeScopes["node.exec"] { + t.Fatalf("expected non-operator token scopes unchanged, got=%v", nodeScopes) + } + }) + + t.Run("creates approvedScopes when missing", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + + identityDir := filepath.Join(tmpDir, ".openclaw", "identity") + if err := os.MkdirAll(identityDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(identityDir, "device-auth.json"), []byte(`{"deviceId":"dev-2"}`), 0o600); err != nil { + t.Fatal(err) + } + + devicesDir := filepath.Join(tmpDir, ".openclaw", "devices") + if err := os.MkdirAll(devicesDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(devicesDir, "paired.json"), []byte(`{ + "dev-2": { + "deviceId": "dev-2", + "scopes": ["operator.read"], + "tokens": {"operator":{"role":"operator","scopes":["operator.read"]}} + } + }`), 0o600); err != nil { + t.Fatal(err) + } + + patchDeviceScopes() + + data, err := os.ReadFile(filepath.Join(devicesDir, "paired.json")) + if err != nil { + t.Fatal(err) + } + var devices map[string]map[string]any + if err := json.Unmarshal(data, &devices); err != nil { + t.Fatal(err) + } + dev := devices["dev-2"] + if _, ok := dev["approvedScopes"]; !ok { + t.Fatal("expected approvedScopes to be created") + } + }) +} + func TestPrintOpenclawReady(t *testing.T) { t.Run("includes port in URL", func(t *testing.T) { var buf bytes.Buffer