diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceAuthStore.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceAuthStore.swift index 8bb939cae94..6ca4dbda1e7 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceAuthStore.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceAuthStore.swift @@ -57,7 +57,11 @@ public enum DeviceAuthStore { self.removeLegacyToken(deviceId: deviceId, role: normalizedRole) } } catch { - self.writeLegacyStore(DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [normalizedRole: entry])) + var fallback = + self.readLegacyStore().flatMap { $0.deviceId == deviceId ? $0 : nil } + ?? DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:]) + fallback.tokens[normalizedRole] = entry + self.writeLegacyStore(fallback) } return entry } diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeviceIdentityStoreTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeviceIdentityStoreTests.swift index e279febda8e..b2348ce558b 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeviceIdentityStoreTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeviceIdentityStoreTests.swift @@ -185,6 +185,33 @@ struct DeviceIdentityStoreTests { } } + @Test("merges legacy device auth sidecar when SQLite write fails") + func mergesLegacyDeviceAuthSidecarWhenSQLiteWriteFails() throws { + try Self.withTempStateDir { stateDir in + let legacyURL = Self.legacyAuthURL(stateDir: stateDir) + try Self.writeLegacyAuthSidecar( + legacyURL, + deviceId: "device-1", + token: "gateway-token", + scopes: ["read"]) + try FileManager.default.createDirectory( + at: Self.databaseURL(stateDir: stateDir), + withIntermediateDirectories: true) + + _ = DeviceAuthStore.storeToken( + deviceId: "device-1", + role: "operator", + token: "operator-token", + scopes: ["write"]) + + let data = try Data(contentsOf: legacyURL) + let root = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let tokens = try #require(root["tokens"] as? [String: Any]) + #expect((tokens["gateway"] as? [String: Any])?["token"] as? String == "gateway-token") + #expect((tokens["operator"] as? [String: Any])?["token"] as? String == "operator-token") + } + } + @Test("drops stale legacy device auth sidecar when storing a different device") func dropsStaleLegacyDeviceAuthSidecarWhenReplacingDevice() throws { try Self.withTempStateDir { stateDir in diff --git a/src/config/sessions/session-entries.sqlite.test.ts b/src/config/sessions/session-entries.sqlite.test.ts index 3c9a787724b..f7e5f919c26 100644 --- a/src/config/sessions/session-entries.sqlite.test.ts +++ b/src/config/sessions/session-entries.sqlite.test.ts @@ -157,6 +157,41 @@ describe("SQLite session row backend", () => { }); }); + it("preserves channel-only delivery context without a conversation peer", () => { + const stateDir = createTempDir(); + const env = { OPENCLAW_STATE_DIR: stateDir }; + + upsertSessionEntry({ + agentId: "ops", + env, + sessionKey: "webchat:main", + entry: { + sessionId: "webchat-session", + updatedAt: 200, + channel: "webchat", + deliveryContext: { + channel: "webchat", + }, + lastChannel: "webchat", + }, + }); + + const loaded = getSessionEntry({ + agentId: "ops", + env, + sessionKey: "webchat:main", + }); + expect(loaded).toMatchObject({ + sessionId: "webchat-session", + channel: "webchat", + deliveryContext: { + channel: "webchat", + }, + lastChannel: "webchat", + }); + expect(loaded?.lastTo).toBeUndefined(); + }); + it("stores hot session metadata in canonical session roots", () => { const stateDir = createTempDir(); const env = { OPENCLAW_STATE_DIR: stateDir }; diff --git a/src/config/sessions/session-entries.sqlite.ts b/src/config/sessions/session-entries.sqlite.ts index 062a5523ef9..b2dccaf84d6 100644 --- a/src/config/sessions/session-entries.sqlite.ts +++ b/src/config/sessions/session-entries.sqlite.ts @@ -13,7 +13,10 @@ import { runOpenClawAgentWriteTransaction, } from "../../state/openclaw-agent-db.js"; import { type OpenClawStateDatabaseOptions } from "../../state/openclaw-state-db.js"; -import { normalizeDeliveryContext } from "../../utils/delivery-context.shared.js"; +import { + normalizeDeliveryContext, + normalizeSessionDeliveryFields, +} from "../../utils/delivery-context.shared.js"; import { conversationIdentityFromSessionEntry, type ConversationIdentity, @@ -142,19 +145,23 @@ function clearCompatibilityRoutingShadow( } function projectCompatibilityRoutingShadow(entry: SessionEntry): void { - const deliveryContext = normalizeDeliveryContext(entry.deliveryContext); - if (!deliveryContext?.channel || !deliveryContext.to) { + const normalized = normalizeSessionDeliveryFields(entry); + const deliveryContext = normalized.deliveryContext; + if (!deliveryContext?.channel) { return; } entry.deliveryContext = deliveryContext; - entry.lastChannel = deliveryContext.channel; - entry.lastTo = deliveryContext.to; - entry.lastAccountId = deliveryContext.accountId; - entry.lastThreadId = deliveryContext.threadId; + entry.lastChannel = normalized.lastChannel; + entry.lastTo = normalized.lastTo; + entry.lastAccountId = normalized.lastAccountId; + entry.lastThreadId = normalized.lastThreadId; } function projectTypedSessionColumns(row: SessionEntryRow): SessionEntry | null { const parsed = parseSessionEntry(row); + const parsedDeliveryContext = parsed + ? normalizeSessionDeliveryFields(parsed).deliveryContext + : undefined; const sessionId = optionalString(row.typed_session_id) ?? parsed?.sessionId; const updatedAt = typeof row.typed_updated_at === "number" && Number.isFinite(row.typed_updated_at) @@ -222,6 +229,9 @@ function projectTypedSessionColumns(row: SessionEntryRow): SessionEntry | null { if (nativeDirectUserId) { next.nativeDirectUserId = nativeDirectUserId; } + if (!next.deliveryContext && parsedDeliveryContext?.channel && !parsedDeliveryContext.to) { + next.deliveryContext = parsedDeliveryContext; + } const modelProvider = optionalString(row.typed_model_provider); if (modelProvider) { next.modelProvider = modelProvider;