From 5dc14243af7fac7c22bcbeaf73bfc23d4caf4162 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 21:20:39 +0100 Subject: [PATCH] fix: preserve migrated auth isolation --- .../openclaw/app/gateway/DeviceAuthStore.kt | 17 ++++++++- .../app/gateway/DeviceAuthStoreTest.kt | 33 +++++++++++++++++ extensions/acpx/src/service.test.ts | 22 +++++++++++ extensions/acpx/src/service.ts | 37 +++++++++++++++---- 4 files changed, 101 insertions(+), 8 deletions(-) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/DeviceAuthStore.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/DeviceAuthStore.kt index d13850eafbf..edd19e73ffc 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/DeviceAuthStore.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/DeviceAuthStore.kt @@ -203,7 +203,22 @@ class DeviceAuthStore private constructor( removeLegacyEntry(normalizedDevice, normalizedRole) return null } - return migrateLegacyEntry(normalizedDevice, normalizedRole) + return migrateLegacyEntriesForDevice(normalizedDevice)[normalizedRole] + } + + private fun migrateLegacyEntriesForDevice(normalizedDevice: String): Map { + val prefix = tokenKeyPrefix(normalizedDevice) + return legacyPrefs + .keysWithPrefix(prefix) + .mapNotNull { key -> + val role = normalizeRole(key.removePrefix(prefix)) + if (role.isEmpty()) { + null + } else { + migrateLegacyEntry(normalizedDevice, role)?.let { role to it } + } + } + .toMap() } private fun migrateLegacyEntry( diff --git a/apps/android/app/src/test/java/ai/openclaw/app/gateway/DeviceAuthStoreTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/gateway/DeviceAuthStoreTest.kt index 33bd4b4b320..d90bcd4ab09 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/gateway/DeviceAuthStoreTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/gateway/DeviceAuthStoreTest.kt @@ -83,6 +83,39 @@ class DeviceAuthStoreTest { ) } + @Test + fun loadEntryMigratesAllLegacyRolesBeforeSQLiteRowsExist() { + val app = RuntimeEnvironment.getApplication() + val prefs = legacyPrefs(app) + prefs.putString("gateway.deviceToken.device-1.operator", " operator-token ") + prefs.putString( + "gateway.deviceTokenMeta.device-1.operator", + """{"scopes":["operator.write"],"updatedAtMs":1700000000000}""", + ) + prefs.putString("gateway.deviceToken.device-1.node", " node-token ") + prefs.putString( + "gateway.deviceTokenMeta.device-1.node", + """{"scopes":["node.connect"],"updatedAtMs":1700000000001}""", + ) + val store = DeviceAuthStore(app, legacyPrefsOverride = prefs) + + val operator = store.loadEntry("device-1", "operator") + val node = store.loadEntry("device-1", "node") + + assertEquals("operator-token", operator?.token) + assertEquals(listOf("operator.write"), operator?.scopes) + assertEquals("node-token", node?.token) + assertEquals(listOf("node.connect"), node?.scopes) + assertEquals( + "__openclaw_secure_prefs__", + OpenClawSQLiteStateStore(app).readDeviceAuthToken("device-1", "operator")?.token, + ) + assertEquals( + "__openclaw_secure_prefs__", + OpenClawSQLiteStateStore(app).readDeviceAuthToken("device-1", "node")?.token, + ) + } + @Test fun loadEntryDoesNotResurrectLegacyRoleAfterSQLiteRowsExist() { val app = RuntimeEnvironment.getApplication() diff --git a/extensions/acpx/src/service.test.ts b/extensions/acpx/src/service.test.ts index ca396ae2824..305ba3fbb71 100644 --- a/extensions/acpx/src/service.test.ts +++ b/extensions/acpx/src/service.test.ts @@ -396,6 +396,28 @@ describe("createAcpxRuntimeService", () => { await service.stop?.(ctx); }); + it("scopes generated wrapper roots by state dir and gateway instance", async () => { + const stateDirA = path.join(await makeTempDir(), "state"); + const stateDirB = path.join(await makeTempDir(), "state"); + + const rootA1 = resolveAcpxWrapperRoot({ + gatewayInstanceId: "gw-a", + stateDir: stateDirA, + }); + const rootA2 = resolveAcpxWrapperRoot({ + gatewayInstanceId: "gw-b", + stateDir: stateDirA, + }); + const rootB1 = resolveAcpxWrapperRoot({ + gatewayInstanceId: "gw-a", + stateDir: stateDirB, + }); + + expect(rootA1).not.toBe(rootA2); + expect(rootA1).not.toBe(rootB1); + expect(path.dirname(path.dirname(rootA1))).toBe(resolveAcpxWrapperRoot()); + }); + it("runs wrapper-root orphan cleanup before dropping pending ACPX leases", async () => { const workspaceDir = await makeTempDir(); const ctx = createServiceContext(workspaceDir); diff --git a/extensions/acpx/src/service.ts b/extensions/acpx/src/service.ts index 35f0f571d03..e643307b094 100644 --- a/extensions/acpx/src/service.ts +++ b/extensions/acpx/src/service.ts @@ -1,4 +1,4 @@ -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import { inspect } from "node:util"; @@ -78,8 +78,28 @@ type CreateAcpxRuntimeServiceParams = { processCleanupDeps?: AcpxProcessCleanupDeps; }; -export function resolveAcpxWrapperRoot(): string { - return path.join(resolvePreferredOpenClawTmpDir(), "acpx"); +function sanitizeWrapperRootSegment(value: string, fallback: string): string { + const segment = value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, ""); + return segment || fallback; +} + +function hashWrapperRootStateDir(stateDir: string): string { + return createHash("sha256").update(path.resolve(stateDir)).digest("hex").slice(0, 16); +} + +export function resolveAcpxWrapperRoot(params?: { + gatewayInstanceId: string; + stateDir: string; +}): string { + const baseRoot = path.join(resolvePreferredOpenClawTmpDir(), "acpx"); + if (!params) { + return baseRoot; + } + return path.join( + baseRoot, + hashWrapperRootStateDir(params.stateDir), + sanitizeWrapperRootSegment(params.gatewayInstanceId, "gateway"), + ); } function loadRuntimeModule(): Promise { @@ -533,7 +553,13 @@ export function createAcpxRuntimeService( ...basePluginConfig, probeAgent: basePluginConfig.probeAgent ?? resolveAllowedAgentsProbeAgent(ctx), }; - const wrapperRoot = resolveAcpxWrapperRoot(); + const gatewayInstanceId = await measureAcpxStartup(ctx, "gateway-instance-id", () => + resolveGatewayInstanceId(), + ); + const wrapperRoot = resolveAcpxWrapperRoot({ + gatewayInstanceId, + stateDir: ctx.stateDir, + }); const pluginConfig = await measureAcpxStartup(ctx, "config.prepare-codex-auth", () => prepareAcpxCodexAuthConfig({ pluginConfig: effectiveBasePluginConfig, @@ -544,9 +570,6 @@ export function createAcpxRuntimeService( await measureAcpxStartup(ctx, "filesystem.prepare", async () => { await fs.mkdir(wrapperRoot, { recursive: true }); }); - const gatewayInstanceId = await measureAcpxStartup(ctx, "gateway-instance-id", () => - resolveGatewayInstanceId(), - ); const processLeaseStore = createAcpxProcessLeaseStore(); const startupReap = await measureAcpxStartup(ctx, "process-leases.reap", () => reapOpenAcpxProcessLeases({