From 980e67e2aee8c2dfc6fdca5a64361a0b732e890a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 21 Aug 2026 17:33:49 -0700 Subject: [PATCH] fix(sessions): retain assigned owners during doctor and startup repairs (#127706) --- ...ession-canonical-keys.owner-repair.test.ts | 112 +++++++++++++++ src/commands/doctor-session-canonical-keys.ts | 6 + ...egacy-main-session-migration-operations.ts | 23 +-- .../legacy-main-session-migration.test.ts | 132 +++++++++++++----- ...ion-accessor.sqlite-canonical-inventory.ts | 4 +- .../session-accessor.sqlite-import.ts | 6 +- .../session-accessor.sqlite-owner.test.ts | 18 +++ .../sessions/session-accessor.sqlite-owner.ts | 70 +++++----- src/config/sessions/session-accessor.test.ts | 17 +-- 9 files changed, 296 insertions(+), 92 deletions(-) diff --git a/src/commands/doctor-session-canonical-keys.owner-repair.test.ts b/src/commands/doctor-session-canonical-keys.owner-repair.test.ts index f51aec1b55ec..79563dfa5e68 100644 --- a/src/commands/doctor-session-canonical-keys.owner-repair.test.ts +++ b/src/commands/doctor-session-canonical-keys.owner-repair.test.ts @@ -2,11 +2,13 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { resolveSessionStorePathCore } from "../config/sessions/paths.js"; import { + assignSessionOwner, loadExactSessionEntryReadOnly, loadTranscriptEvents, } from "../config/sessions/session-accessor.js"; import { resolveSqliteTargetFromSessionStorePath } from "../config/sessions/session-sqlite-target.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { FIRST_USE_ADDITIVE_AGENT_COLUMN_DEFINITIONS } from "../state/openclaw-agent-db-additive-columns.js"; import { closeOpenClawAgentDatabasesForTest, openOpenClawAgentDatabase, @@ -42,6 +44,116 @@ function insertEmptyAlias(params: { } describe("doctor transcript owner repair", () => { + it.each([ + { label: "replaces a stale same-store owner", sourceAgentId: "main", winnerOwned: true }, + { label: "clears a stale same-store owner", sourceAgentId: "main", winnerOwned: false }, + { label: "lazily restores cross-store owner columns", sourceAgentId: "ops", winnerOwned: true }, + { + label: "preserves an owner while repairing malformed session metadata", + sourceAgentId: "main", + winnerOwned: true, + malformed: true, + }, + ])("$label from the selected canonical-repair winner", async (fixture) => { + const { sourceAgentId, winnerOwned } = fixture; + await withStateDirEnv("openclaw-doctor-assigned-owner-", async ({ stateDir }) => { + const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; + const storeTemplate = path.join(stateDir, "agents", "{agentId}", "sessions.json"); + const destinationStore = resolveSessionStorePathCore(storeTemplate, { agentId: "main", env }); + const sourceStore = resolveSessionStorePathCore(storeTemplate, { + agentId: sourceAgentId, + env, + }); + const canonicalKey = "agent:main:work"; + const winnerKey = "agent:main:main"; + const cfg = { + agents: { + list: [ + { id: "main", default: true }, + ...(sourceAgentId === "ops" ? [{ id: "ops" }] : []), + ], + }, + session: { mainKey: "work", store: storeTemplate }, + } as OpenClawConfig; + + if (sourceAgentId === "main") { + insertLegacySession({ + agentId: "main", + entry: { sessionId: "stale-destination", updatedAt: 10 }, + env, + sessionKey: canonicalKey, + storePath: destinationStore, + }); + assignSessionOwner( + { agentId: "main", env, sessionKey: canonicalKey, storePath: destinationStore }, + { + owner: { type: "human", id: "profile-stale" }, + assignedBy: { type: "human", id: "profile-stale-assigner" }, + assignedAt: 10, + }, + ); + } + + insertLegacySession({ + agentId: sourceAgentId, + entry: { sessionId: "selected-winner", updatedAt: 20 }, + env, + sessionKey: winnerKey, + storePath: sourceStore, + }); + const owner = winnerOwned + ? assignSessionOwner( + { agentId: sourceAgentId, env, sessionKey: winnerKey, storePath: sourceStore }, + { + owner: { type: "human", id: "profile-winner" }, + assignedBy: { type: "agent", id: "research" }, + assignedAt: 1234, + }, + ) + : undefined; + + if ("malformed" in fixture) { + openOpenClawAgentDatabase({ + agentId: sourceAgentId, + env, + path: resolveSqliteTargetFromSessionStorePath(sourceStore, { + agentId: sourceAgentId, + env, + }).path, + }) + .db.prepare("UPDATE session_nodes SET entry_json = ? WHERE session_key = ?") + .run("{malformed", winnerKey); + } + + if (sourceAgentId === "ops") { + const database = openOpenClawAgentDatabase({ + agentId: "main", + env, + path: resolveSqliteTargetFromSessionStorePath(destinationStore, { + agentId: "main", + env, + }).path, + }); + for (const { columnName } of FIRST_USE_ADDITIVE_AGENT_COLUMN_DEFINITIONS) { + database.db.exec(`ALTER TABLE session_nodes DROP COLUMN ${columnName};`); + } + } + + expect(await repairCanonicalSessionKeys({ apply: true, cfg, env })).toMatchObject({ + foundGroups: 1, + repairedGroups: 1, + }); + expect( + loadExactSessionEntryReadOnly({ + agentId: "main", + env, + sessionKey: canonicalKey, + storePath: destinationStore, + })?.entry.owner, + ).toEqual(owner ?? undefined); + }); + }); + it("restores a valid node after an empty alias steals its transcript window", async () => { await withStateDirEnv("openclaw-doctor-transcript-owner-", async ({ stateDir }) => { const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; diff --git a/src/commands/doctor-session-canonical-keys.ts b/src/commands/doctor-session-canonical-keys.ts index 4ad1dff096ea..7aad0dd9bf1b 100644 --- a/src/commands/doctor-session-canonical-keys.ts +++ b/src/commands/doctor-session-canonical-keys.ts @@ -14,6 +14,7 @@ import { copySessionNodeArtifactsForRepair, deleteSessionMembersForRepair, } from "../config/sessions/session-accessor.sqlite-node-artifacts.js"; +import { replaceSessionOwnerInTransaction } from "../config/sessions/session-accessor.sqlite-owner.js"; import { collectSessionStateIdsForEntry } from "../config/sessions/session-accessor.sqlite-references.js"; import { resolveSqliteTranscriptArchiveDirectory } from "../config/sessions/session-accessor.sqlite-scope.js"; import { setCanonicalSqliteSessionMainKey } from "../config/sessions/session-canonical-key.js"; @@ -257,6 +258,11 @@ function applyCanonicalDestinationArtifacts(params: { rehomeDeliveries: boolean; winner: CanonicalSessionCandidate; }): void { + replaceSessionOwnerInTransaction( + params.database, + params.winner.canonicalKey, + params.winner.entry.owner, + ); const destinationAliasKeys = listCanonicalDestinationAliasKeys( params.destinationStore, params.winner, diff --git a/src/config/sessions/legacy-main-session-migration-operations.ts b/src/config/sessions/legacy-main-session-migration-operations.ts index 71a21b811c56..a359e978c710 100644 --- a/src/config/sessions/legacy-main-session-migration-operations.ts +++ b/src/config/sessions/legacy-main-session-migration-operations.ts @@ -23,6 +23,7 @@ import { } from "./session-accessor.sqlite-entry-store.js"; import { importSqliteSessionRows } from "./session-accessor.sqlite-import.js"; import { deleteSessionEntryLifecycle } from "./session-accessor.sqlite-lifecycle.js"; +import { replaceSessionOwnerInTransaction } from "./session-accessor.sqlite-owner.js"; import { getSessionKysely } from "./session-accessor.sqlite-scope.js"; import type { SessionEntry } from "./types.js"; @@ -108,6 +109,18 @@ export function warningForDivergence( return `session: ${kind} for ${canonicalKey}; preserved claims ${claimsText}. Run openclaw doctor --fix to quarantine the losing claims.`; } +function writeMigratedSessionClaim( + database: OpenClawAgentDatabase, + sessionKey: string, + entry: SessionEntry, +): void { + writeSessionEntry(database, sessionKey, entry, { + allowStoredAliases: true, + previousEntry: null, + }); + replaceSessionOwnerInTransaction(database, sessionKey, entry.owner); +} + function migrateClaimsInPlace(params: { aliases: readonly SessionClaim[]; canonical?: SessionClaim; @@ -135,10 +148,7 @@ function migrateClaimsInPlace(params: { return; } if (!currentCanonical) { - writeSessionEntry(database, params.canonicalKey, params.winner.entry, { - allowStoredAliases: true, - previousEntry: null, - }); + writeMigratedSessionClaim(database, params.canonicalKey, params.winner.entry); } deleteLegacySessionEntryRows( database, @@ -225,10 +235,7 @@ function quarantineClaim(params: { break; } } - writeSessionEntry(database, quarantineKey, params.claim.entry, { - allowStoredAliases: true, - previousEntry: null, - }); + writeMigratedSessionClaim(database, quarantineKey, params.claim.entry); deleteLegacySessionEntryRows(database, [params.claim.key], quarantineKey, { rehomeMembers: true, }); diff --git a/src/config/sessions/legacy-main-session-migration.test.ts b/src/config/sessions/legacy-main-session-migration.test.ts index a4c3f2c94d88..891f88c1ae5e 100644 --- a/src/config/sessions/legacy-main-session-migration.test.ts +++ b/src/config/sessions/legacy-main-session-migration.test.ts @@ -14,6 +14,7 @@ import { import { resolveOpenClawStateSqlitePath } from "../../state/openclaw-state-db.paths.js"; import type { OpenClawConfig } from "../types.openclaw.js"; import { migrateLegacyMainSessionKeys } from "./legacy-main-session-migration.js"; +import { assignSessionOwner } from "./session-accessor.js"; import { readExactSessionEntryRowForCanonicalRepair } from "./session-accessor.sqlite-canonical-repair.js"; import { writeSessionEntry } from "./session-accessor.sqlite-entry-store.js"; import { readTranscriptEventRows } from "./session-accessor.sqlite-read.js"; @@ -21,6 +22,11 @@ import { appendTranscriptEventInTransaction } from "./session-accessor.sqlite-tr import type { SessionEntry } from "./types.js"; const tempDirs = useAutoCleanupTempDirTracker(afterEach); +const humanOwner = { + actor: { type: "human", id: "alice" }, + assignedBy: { type: "human", id: "bob" }, + assignedAt: 123, +} as const; type Fixture = { cfg: OpenClawConfig; @@ -48,6 +54,19 @@ function databasePath(stateDir: string, agentId: string): string { return path.join(stateDir, "agents", agentId, "agent", "openclaw-agent.sqlite"); } +function assignHumanOwner(storePath: string): void { + expect( + assignSessionOwner( + { agentId: "main", sessionKey: "agent:main:chat", storePath }, + { + owner: humanOwner.actor, + assignedBy: humanOwner.assignedBy, + assignedAt: humanOwner.assignedAt, + }, + ), + ).toEqual(humanOwner); +} + function seedClaim(params: { databaseAgentId: string; databasePath: string; @@ -400,6 +419,41 @@ describe("legacy main session migration", () => { ).toBeDefined(); }); + it.each([ + { kind: "migrated-in-place", sharedStore: true }, + { kind: "migrated-cross-store", sharedStore: false }, + ])("preserves the assigned human owner when $kind", async ({ kind, sharedStore }) => { + const storePath = sharedStore + ? path.join(tempDirs.make("owned-in-place-migration-"), "sessions.sqlite") + : undefined; + const fixture = createFixture({ + agents: { entries: { ops: {} } }, + ...(storePath ? { session: { store: storePath } } : {}), + }); + const sourcePath = storePath ?? databasePath(fixture.stateDir, "main"); + seedClaim({ databaseAgentId: "main", databasePath: sourcePath, key: "agent:main:chat" }); + assignHumanOwner(sourcePath); + + const result = await migrateLegacyMainSessionKeys({ + cfg: fixture.cfg, + env: fixture.env, + mode: "automatic", + }); + + expect(result.complete).toBe(true); + expect(outcomeKinds(result)).toContain(kind); + expect( + readClaim({ + databaseAgentId: sharedStore ? "main" : "ops", + databasePath: storePath ?? databasePath(fixture.stateDir, "ops"), + key: "agent:ops:chat", + })?.entry.owner, + ).toEqual(humanOwner); + expect( + readClaim({ databaseAgentId: "main", databasePath: sourcePath, key: "agent:main:chat" }), + ).toBeUndefined(); + }); + it.each([ { copiedBeforeCrash: true, label: "copy committed before source cleanup" }, { copiedBeforeCrash: false, label: "source cleanup committed before ledger" }, @@ -452,47 +506,53 @@ describe("legacy main session migration", () => { ]); }); - it("quarantines losing claims without overwriting existing quarantine keys", async () => { - const fixture = createFixture(); - const mainPath = databasePath(fixture.stateDir, "main"); - seedClaim({ - databaseAgentId: "main", - databasePath: mainPath, - entry: { sessionId: "legacy", updatedAt: 100 }, - key: "agent:main:chat", - }); - seedClaim({ - databaseAgentId: "main", - databasePath: mainPath, - entry: { sessionId: "occupied", updatedAt: 50 }, - key: "agent:ops:legacy-main-conflict-1", - }); - seedClaim({ - databaseAgentId: "ops", - databasePath: databasePath(fixture.stateDir, "ops"), - entry: { sessionId: "canonical", updatedAt: 200 }, - key: "agent:ops:chat", - }); + it.each([false, true])( + "quarantines losing claims without overwriting existing quarantine keys (assigned owner: %s)", + async (hasHumanOwner) => { + const fixture = createFixture(); + const mainPath = databasePath(fixture.stateDir, "main"); + seedClaim({ + databaseAgentId: "main", + databasePath: mainPath, + entry: { sessionId: "legacy", updatedAt: 100 }, + key: "agent:main:chat", + }); + if (hasHumanOwner) { + assignHumanOwner(mainPath); + } + seedClaim({ + databaseAgentId: "main", + databasePath: mainPath, + entry: { sessionId: "occupied", updatedAt: 50 }, + key: "agent:ops:legacy-main-conflict-1", + }); + seedClaim({ + databaseAgentId: "ops", + databasePath: databasePath(fixture.stateDir, "ops"), + entry: { sessionId: "canonical", updatedAt: 200 }, + key: "agent:ops:chat", + }); - const result = await migrateLegacyMainSessionKeys({ - cfg: fixture.cfg, - env: fixture.env, - mode: "doctor-fix", - }); + const result = await migrateLegacyMainSessionKeys({ + cfg: fixture.cfg, + env: fixture.env, + mode: "doctor-fix", + }); - const outcome = result.outcomes.find((entry) => entry.kind === "divergent-canonical"); - expect(outcome?.quarantinedKeys).toEqual(["agent:ops:legacy-main-conflict-2"]); - expect( - readClaim({ databaseAgentId: "main", databasePath: mainPath, key: "agent:main:chat" }), - ).toBeUndefined(); - expect( - readClaim({ + const outcome = result.outcomes.find((entry) => entry.kind === "divergent-canonical"); + expect(outcome?.quarantinedKeys).toEqual(["agent:ops:legacy-main-conflict-2"]); + expect( + readClaim({ databaseAgentId: "main", databasePath: mainPath, key: "agent:main:chat" }), + ).toBeUndefined(); + const quarantined = readClaim({ databaseAgentId: "main", databasePath: mainPath, key: "agent:ops:legacy-main-conflict-2", - })?.events, - ).toEqual(['{"type":"message","id":"event-1","text":"hello"}']); - }); + }); + expect(quarantined?.events).toEqual(['{"type":"message","id":"event-1","text":"hello"}']); + expect(quarantined?.entry.owner).toEqual(hasHumanOwner ? humanOwner : undefined); + }, + ); it("uses a completed ledger once and rearms when its identity changes", async () => { const fixture = createFixture(); diff --git a/src/config/sessions/session-accessor.sqlite-canonical-inventory.ts b/src/config/sessions/session-accessor.sqlite-canonical-inventory.ts index 586fee7d5902..a3b177bcd002 100644 --- a/src/config/sessions/session-accessor.sqlite-canonical-inventory.ts +++ b/src/config/sessions/session-accessor.sqlite-canonical-inventory.ts @@ -11,6 +11,7 @@ import { } from "../../utils/delivery-context.shared.js"; import { isInternalSessionEffectsKey } from "./internal-session-key.js"; import type { SessionEntrySummary } from "./session-accessor.sqlite-contract.js"; +import { projectSqliteSessionOwner } from "./session-accessor.sqlite-owner-projection.js"; import { getSessionKysely, resolveSqliteScope, @@ -106,7 +107,7 @@ function hydrateCanonicalRepairEntry(row: CanonicalRepairRow): SessionEntry { }, }) : undefined; - return projectCanonicalSessionEntryShape({ + const entry = projectCanonicalSessionEntryShape({ ...record, ...(row.status ? { status: row.status } : {}), ...(row.current_started_at !== null ? { startedAt: row.current_started_at } : {}), @@ -141,6 +142,7 @@ function hydrateCanonicalRepairEntry(row: CanonicalRepairRow): SessionEntry { sessionId: row.current_session_id, updatedAt: row.updated_at, }); + return projectSqliteSessionOwner(entry, row); } function canonicalRepairQuery(database: Pick) { diff --git a/src/config/sessions/session-accessor.sqlite-import.ts b/src/config/sessions/session-accessor.sqlite-import.ts index 6d47ee580fee..addee93e30b5 100644 --- a/src/config/sessions/session-accessor.sqlite-import.ts +++ b/src/config/sessions/session-accessor.sqlite-import.ts @@ -10,6 +10,7 @@ import { readExactSessionEntryRowForCanonicalRepair } from "./session-accessor.s import type { TranscriptEvent } from "./session-accessor.sqlite-contract.js"; import { publishSessionEntryCacheInvalidation } from "./session-accessor.sqlite-entry-cache.js"; import { writeSessionEntry } from "./session-accessor.sqlite-entry-store.js"; +import { replaceSessionOwnerInTransaction } from "./session-accessor.sqlite-owner.js"; import { readTranscriptEventJsonSetInTransaction } from "./session-accessor.sqlite-read.js"; import { formatSqliteSessionReferenceForScope, @@ -116,10 +117,11 @@ function importSqliteSessionRowsInTransaction( allowStoredAliases: true, previousEntry: currentEntry ?? null, }); - // The legacy-main handoff hashes raw ordered rows. Parsing or deduping here would make the - // destination proof differ and strand a partially imported canonical claim. + // Only trusted SQLite handoffs can transfer ownership and hash exact ordered rows; + // parsing, deduping, or trusting JSON ownership would break the migration boundary. const exactTranscriptRows = prepared.exactTranscriptRows; if (exactTranscriptRows) { + replaceSessionOwnerInTransaction(database, resolved.sessionKey, params.entry.owner); const transcriptScope = { ...resolved, sessionId: params.entry.sessionId, diff --git a/src/config/sessions/session-accessor.sqlite-owner.test.ts b/src/config/sessions/session-accessor.sqlite-owner.test.ts index 254a1cb4ecf2..adb4e0a6132a 100644 --- a/src/config/sessions/session-accessor.sqlite-owner.test.ts +++ b/src/config/sessions/session-accessor.sqlite-owner.test.ts @@ -3,6 +3,7 @@ import { FIRST_USE_ADDITIVE_AGENT_COLUMN_DEFINITIONS } from "../../state/opencla import { closeOpenClawAgentDatabasesForTest, openOpenClawAgentDatabase, + runOpenClawAgentWriteTransaction, } from "../../state/openclaw-agent-db.js"; import { withOpenClawTestState } from "../../test-utils/openclaw-test-state.js"; import { @@ -39,6 +40,23 @@ describe("SQLite session owner assignment", () => { }); expect(loadSessionEntry(scope)?.owner).toBeUndefined(); + expect(() => + runOpenClawAgentWriteTransaction( + () => { + expect( + assignSessionOwner(scope, { + owner: { type: "agent", id: "rolled-back-owner" }, + assignedBy: { type: "human", id: "profile-assigner" }, + assignedAt: 1233, + }), + ).not.toBeNull(); + throw new Error("roll back owner schema"); + }, + { agentId: "main", env: state.env }, + ), + ).toThrow("roll back owner schema"); + expect(loadSessionEntry(scope)?.owner).toBeUndefined(); + expect( assignSessionOwner(scope, { owner: { type: "agent", id: "research" }, diff --git a/src/config/sessions/session-accessor.sqlite-owner.ts b/src/config/sessions/session-accessor.sqlite-owner.ts index cbfc7cdfcecb..0b6c23eeea7b 100644 --- a/src/config/sessions/session-accessor.sqlite-owner.ts +++ b/src/config/sessions/session-accessor.sqlite-owner.ts @@ -1,28 +1,51 @@ -import type { DatabaseSync } from "node:sqlite"; import { executeSqliteQuerySync } from "../../infra/kysely-sync.js"; import { FIRST_USE_ADDITIVE_AGENT_COLUMN_DEFINITIONS } from "../../state/openclaw-agent-db-additive-columns.js"; import { - openOpenClawAgentDatabase, runOpenClawAgentWriteTransaction, + type OpenClawAgentDatabase, } from "../../state/openclaw-agent-db.js"; import { ensureColumn } from "../../state/openclaw-state-db-schema-helpers.js"; import type { SessionAccessScope } from "./session-accessor.sqlite-contract.js"; import { publishSessionEntryCacheInvalidation } from "./session-accessor.sqlite-entry-cache.js"; +import { hasSqliteSessionOwnerColumns } from "./session-accessor.sqlite-owner-projection.js"; import { getSessionKysely, resolveSqliteScope, toDatabaseOptions, } from "./session-accessor.sqlite-scope.js"; import type { SessionCreatedActor, SessionOwnerAssignment } from "./session-entry-provenance.js"; -const ensuredOwnerDatabases = new WeakSet(); -function ensureSessionOwnerColumns(database: DatabaseSync): void { - if (ensuredOwnerDatabases.has(database)) { - return; +export function replaceSessionOwnerInTransaction( + database: OpenClawAgentDatabase, + sessionKey: string, + owner: SessionOwnerAssignment | undefined, +): boolean { + if (!hasSqliteSessionOwnerColumns(database.db)) { + if (!owner?.actor.id) { + return false; + } + for (const { columnName, dataType, tableName } of FIRST_USE_ADDITIVE_AGENT_COLUMN_DEFINITIONS) { + ensureColumn(database.db, tableName, `${columnName} ${dataType}`); + } } - for (const { columnName, dataType, tableName } of FIRST_USE_ADDITIVE_AGENT_COLUMN_DEFINITIONS) { - ensureColumn(database, tableName, `${columnName} ${dataType}`); + const result = executeSqliteQuerySync( + database.db, + getSessionKysely(database.db) + .updateTable("session_nodes") + .set({ + owner_actor_type: owner?.actor.type ?? null, + owner_actor_id: owner?.actor.id ?? null, + owner_assigned_by_type: owner?.assignedBy?.type ?? null, + owner_assigned_by_id: owner?.assignedBy?.id ?? null, + owner_assigned_at: owner?.assignedAt ?? null, + }) + .where("session_key", "=", sessionKey), + ); + if (result.numAffectedRows !== 1n) { + return false; } + publishSessionEntryCacheInvalidation(database); + return true; } export function assignSessionOwner( @@ -36,45 +59,18 @@ export function assignSessionOwner( ): SessionOwnerAssignment | null { const resolved = resolveSqliteScope(scope); const options = toDatabaseOptions(resolved); - const opened = openOpenClawAgentDatabase(options); - const assignedAt = params.assignedAt ?? Date.now(); const owner: SessionOwnerAssignment = { actor: params.owner, assignedBy: params.assignedBy, - assignedAt, + assignedAt: params.assignedAt ?? Date.now(), }; - let ensured = false; const updated = runOpenClawAgentWriteTransaction( (database) => { - if (!ensuredOwnerDatabases.has(database.db)) { - ensureSessionOwnerColumns(database.db); - ensured = true; - } params.assertCurrent?.(); - const result = executeSqliteQuerySync( - database.db, - getSessionKysely(database.db) - .updateTable("session_nodes") - .set({ - owner_actor_type: params.owner.type, - owner_actor_id: params.owner.id, - owner_assigned_by_type: params.assignedBy.type, - owner_assigned_by_id: params.assignedBy.id, - owner_assigned_at: assignedAt, - }) - .where("session_key", "=", resolved.sessionKey), - ); - if (result.numAffectedRows === 1n) { - publishSessionEntryCacheInvalidation(database); - return true; - } - return false; + return replaceSessionOwnerInTransaction(database, resolved.sessionKey, owner); }, options, { operationLabel: "sessions.assign-owner" }, ); - if (ensured) { - ensuredOwnerDatabases.add(opened.db); - } return updated ? owner : null; } diff --git a/src/config/sessions/session-accessor.test.ts b/src/config/sessions/session-accessor.test.ts index 1f61a3af75e9..cd7c0e1c3150 100644 --- a/src/config/sessions/session-accessor.test.ts +++ b/src/config/sessions/session-accessor.test.ts @@ -4327,11 +4327,12 @@ describe("session accessor seam", () => { expect(target.sessionKey).toBe(canonicalScope.sessionKey); }); - it("drops imported legacy session transcript paths from canonical rows", async () => { + it("drops imported legacy transcript paths and untrusted owners from canonical rows", async () => { const sessionKey = "agent:main:main"; await importSqliteSessionRows({ agentId: "main", entry: { + owner: { actor: { type: "human", id: "spoofed" } }, sessionFile: path.join(tempDir, "legacy-transcript.jsonl"), sessionId: "session-1", updatedAt: 10, @@ -4340,13 +4341,13 @@ describe("session accessor seam", () => { storePath, }); - expect( - loadExactSessionEntry({ - agentId: "main", - sessionKey, - storePath, - })?.entry, - ).not.toHaveProperty("sessionFile"); + const entry = loadExactSessionEntry({ + agentId: "main", + sessionKey, + storePath, + })?.entry; + expect(entry).not.toHaveProperty("sessionFile"); + expect(entry).not.toHaveProperty("owner"); }); it("reads imported transcripts before opening the SQLite transaction", async () => {