launch/openclaw: patch approvedScopes baseline for TUI pairing (#15375)

This commit is contained in:
Parth Sareen
2026-04-06 18:00:12 -07:00
committed by GitHub
parent 26a58b294c
commit 82f0139587
2 changed files with 146 additions and 4 deletions

View File

@@ -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

View File

@@ -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