fix: preserve migrated auth isolation

This commit is contained in:
Peter Steinberger
2026-05-17 21:20:39 +01:00
parent 4a334ebf74
commit 5dc14243af
4 changed files with 101 additions and 8 deletions

View File

@@ -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<String, DeviceAuthEntry> {
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(

View File

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

View File

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

View File

@@ -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<AcpxRuntimeModule> {
@@ -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({