From cf2f5911610855fb521a0b606dbbd1e448d5effc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 22 Jul 2026 05:47:21 -0700 Subject: [PATCH] feat(sessions): permanent creator attribution, owner avatars, person filter, multi-user docs (#112658) * feat(sessions): persist creator attribution * feat(ui): add session creator filtering * chore(sessions): refresh sqlite schema baseline * docs(security): explain shared-agent trust * fix(sessions): project catalog creator ownership * fix(ui): restore startup JS budget headroom for creator attribution --- SECURITY.md | 4 + .../OpenClawProtocol/GatewayModels.swift | 8 + ...-session-transcript-schema-baseline.sha256 | 2 +- docs/.i18n/glossary.zh-CN.json | 20 ++ docs/concepts/multi-user.md | 39 +++ docs/docs.json | 3 +- docs/docs_map.md | 9 + packages/gateway-protocol/src/index.ts | 1 + .../src/schema/sessions-catalog.test.ts | 1 + .../src/schema/sessions-catalog.ts | 2 + .../gateway-protocol/src/schema/sessions.ts | 9 + scripts/check-control-ui-performance.mjs | 9 +- src/auto-reply/reply/get-reply-fast-path.ts | 3 + src/auto-reply/reply/session-creator.test.ts | 41 +++ src/auto-reply/reply/session.ts | 7 + src/auto-reply/templating.ts | 2 + .../session-accessor.sqlite-entry-store.ts | 3 + .../session-accessor.sqlite-status.ts | 36 ++- src/config/sessions/session-accessor.test.ts | 23 ++ src/config/sessions/types.ts | 3 + .../server-methods/agent-reset-phase.ts | 2 + .../server-methods/agent-run-handler.ts | 2 + .../agent-session-patch.test.ts | 53 ++++ .../server-methods/agent-session-patch.ts | 4 + .../server-methods/agent-session-reset.ts | 3 + .../server-methods/chat-send-user-turn.ts | 4 + .../server-methods/gateway-client-identity.ts | 5 + .../server-methods/session-catalog.test.ts | 104 +++++++ src/gateway/server-methods/session-catalog.ts | 46 +++- src/gateway/server-methods/sessions-create.ts | 2 + .../server-methods/sessions-mutations.ts | 4 +- src/gateway/server-methods/shared-types.ts | 3 + src/gateway/server-session-events.ts | 1 + src/gateway/server.sessions.create.test.ts | 45 +++ .../server/ws-connection/connect-session.ts | 28 ++ ...essage-handler.post-connect-health.test.ts | 6 +- src/gateway/session-create-service.ts | 6 + src/gateway/session-event-payload.test.ts | 21 ++ src/gateway/session-event-payload.ts | 1 + src/gateway/session-reset-service.ts | 8 +- src/gateway/session-utils-creators.test.ts | 42 +++ src/gateway/session-utils.ts | 39 ++- src/gateway/session-utils.types.ts | 2 + src/plugins/session-entry-slot-keys.ts | 1 + src/shared/session-types.ts | 4 + src/state/openclaw-agent-db-schema.ts | 10 + src/state/openclaw-agent-db.generated.d.ts | 1 + src/state/openclaw-agent-db.test.ts | 24 ++ src/state/openclaw-agent-schema.generated.ts | 1 + src/state/openclaw-agent-schema.sql | 1 + ui/src/api/types.ts | 1 + .../app-sidebar-session-catalogs.ts | 11 +- ui/src/components/app-sidebar-session-data.ts | 8 +- ui/src/components/app-sidebar-session-list.ts | 11 +- .../app-sidebar-session-navigation.ts | 16 +- .../app-sidebar-session-ownership.ts | 103 +++++++ .../components/app-sidebar-session-types.ts | 2 + ui/src/components/app-sidebar.test.ts | 1 + ui/src/components/session-owner-chip.ts | 130 +++++++++ ui/src/e2e/session-ownership.e2e.test.ts | 131 +++++++++ ui/src/i18n/locales/en.ts | 3 + ui/src/lib/sessions/index.ts | 13 + ui/src/lib/sessions/reconcile.test.ts | 39 +++ ui/src/lib/sessions/reconcile.ts | 12 +- ui/src/pages/chat/chat-pane.ts | 6 + .../chat/components/chat-pane-header.test.ts | 14 + .../pages/chat/components/chat-pane-header.ts | 6 + ui/src/styles/components.css | 50 ++++ .../app-sidebar-cases/session-ownership.ts | 258 ++++++++++++++++++ ui/src/test-helpers/app-sidebar.ts | 3 + 70 files changed, 1481 insertions(+), 35 deletions(-) create mode 100644 docs/concepts/multi-user.md create mode 100644 src/auto-reply/reply/session-creator.test.ts create mode 100644 src/gateway/session-event-payload.test.ts create mode 100644 src/gateway/session-utils-creators.test.ts create mode 100644 ui/src/components/app-sidebar-session-ownership.ts create mode 100644 ui/src/components/session-owner-chip.ts create mode 100644 ui/src/e2e/session-ownership.e2e.test.ts create mode 100644 ui/src/test-helpers/app-sidebar-cases/session-ownership.ts diff --git a/SECURITY.md b/SECURITY.md index 6b4b3b762aef..cf44b7a0ae3d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -8,6 +8,10 @@ The fastest useful reports show a current, reproducible boundary bypass with dem Security work is shared across a number of OpenClaw maintainers, including engineers and security researchers from organizations such as NVIDIA and Tencent. See the [maintainer list](CONTRIBUTING.md#maintainers). +## Shared Agents + +Anyone who can operate an agent can make it do anything that agent can do. Session ownership, visibility, and presence are usability features, not security boundaries. Turn attribution is best-effort because steering can merge input into an active turn. Use separate agents or separate gateway/host trust boundaries when operators need real isolation. + ## Report a Security Issue Report vulnerabilities directly to the repository where the issue lives: diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 1b379600dc10..924afb1bf49a 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -3958,6 +3958,7 @@ public struct SessionsListParams: Codable, Sendable { public let includederivedtitles: Bool? public let includelastmessage: Bool? public let label: String? + public let creatorid: String? public let spawnedby: String? public let agentid: String? public let search: String? @@ -3975,6 +3976,7 @@ public struct SessionsListParams: Codable, Sendable { includederivedtitles: Bool? = nil, includelastmessage: Bool? = nil, label: String? = nil, + creatorid: String? = nil, spawnedby: String? = nil, agentid: String? = nil, search: String? = nil, @@ -3991,6 +3993,7 @@ public struct SessionsListParams: Codable, Sendable { self.includederivedtitles = includederivedtitles self.includelastmessage = includelastmessage self.label = label + self.creatorid = creatorid self.spawnedby = spawnedby self.agentid = agentid self.search = search @@ -4009,6 +4012,7 @@ public struct SessionsListParams: Codable, Sendable { case includederivedtitles = "includeDerivedTitles" case includelastmessage = "includeLastMessage" case label + case creatorid = "creatorId" case spawnedby = "spawnedBy" case agentid = "agentId" case search @@ -4098,6 +4102,7 @@ public struct SessionCatalogSession: Codable, Sendable { public let pullrequest: SessionCatalogPullRequestSummary? public let archived: Bool public let sessionkey: String? + public let createdby: [String: AnyCodable]? public let cancontinue: Bool public let canarchive: Bool public let canopenterminal: Bool? @@ -4118,6 +4123,7 @@ public struct SessionCatalogSession: Codable, Sendable { pullrequest: SessionCatalogPullRequestSummary? = nil, archived: Bool, sessionkey: String? = nil, + createdby: [String: AnyCodable]? = nil, cancontinue: Bool, canarchive: Bool, canopenterminal: Bool? = nil) @@ -4137,6 +4143,7 @@ public struct SessionCatalogSession: Codable, Sendable { self.pullrequest = pullrequest self.archived = archived self.sessionkey = sessionkey + self.createdby = createdby self.cancontinue = cancontinue self.canarchive = canarchive self.canopenterminal = canopenterminal @@ -4158,6 +4165,7 @@ public struct SessionCatalogSession: Codable, Sendable { case pullrequest = "pullRequest" case archived case sessionkey = "sessionKey" + case createdby = "createdBy" case cancontinue = "canContinue" case canarchive = "canArchive" case canopenterminal = "canOpenTerminal" diff --git a/docs/.generated/sqlite-session-transcript-schema-baseline.sha256 b/docs/.generated/sqlite-session-transcript-schema-baseline.sha256 index b1b51969807b..d5611b65d7f3 100644 --- a/docs/.generated/sqlite-session-transcript-schema-baseline.sha256 +++ b/docs/.generated/sqlite-session-transcript-schema-baseline.sha256 @@ -1 +1 @@ -011b9ec0e0fa64b5a4036648fcd61a50674b813a92280e8e3fe8746f6acdb62d sqlite-session-transcript-schema-baseline.sql +0852c1b681df33646f60d239afcac6de53fbc498bd5daa58077376331205ccdf sqlite-session-transcript-schema-baseline.sql diff --git a/docs/.i18n/glossary.zh-CN.json b/docs/.i18n/glossary.zh-CN.json index e61bcc9fe83f..0d802853e8ef 100644 --- a/docs/.i18n/glossary.zh-CN.json +++ b/docs/.i18n/glossary.zh-CN.json @@ -1606,5 +1606,25 @@ { "source": "Baseten (Inkling + Model APIs)", "target": "Baseten(Inkling + Model APIs)" + }, + { + "source": "Multi-user mode", + "target": "多用户模式" + }, + { + "source": "The main session", + "target": "主会话" + }, + { + "source": "Session management", + "target": "会话管理" + }, + { + "source": "Presence", + "target": "在线状态" + }, + { + "source": "Gateway security", + "target": "Gateway 安全" } ] diff --git a/docs/concepts/multi-user.md b/docs/concepts/multi-user.md new file mode 100644 index 000000000000..8fa3b0260b42 --- /dev/null +++ b/docs/concepts/multi-user.md @@ -0,0 +1,39 @@ +--- +summary: "How session ownership and presence work when several people operate one agent" +read_when: + - You share one OpenClaw agent with other operators + - You need to understand session owner and presence indicators + - You are deciding whether one shared agent provides enough isolation +title: "Multi-user mode" +--- + +Multi-user mode lets several trusted people operate the same OpenClaw agent. It adds session ownership, live presence, and creator filtering so a team can tell who started work and who is currently watching it. + +## Trust boundary + +Everyone who can operate an agent can make it do anything that agent can do. Session ownership, visibility in the sidebar, and presence indicators are usability features, not security boundaries. + +If people must not access each other's sessions, tools, credentials, or files, give them separate agents or separate gateway/host trust boundaries. Do not rely on owner avatars or filters for isolation. + +## Ownership and presence + +New sessions record their creator when the Gateway has a trusted identity available. Trusted-proxy identity takes priority; otherwise OpenClaw uses the paired device's operator label or display name. Older sessions and sessions created without either identity have no owner stamp. + +The web app keeps ownership and presence visually distinct: + +- A solid owner avatar is permanent for the lifetime of that session. +- Ringed or translucent presence avatars show people who are currently connected or watching. +- The sidebar's person filter shows sessions created by one identity while preserving the existing custom groups. + +When fewer than two distinct creators appear in the loaded session list, OpenClaw hides all ownership and person-filter chrome. A single-user gateway therefore looks unchanged. + +## Turn attribution + +Turn sender attribution is best-effort. Steering can merge input into an active turn, so the transcript cannot always represent each person's contribution as a separate turn. + +## Related + +- [The main session](/concepts/main-session) +- [Session management](/concepts/session) +- [Presence](/concepts/presence) +- [Gateway security](/gateway/security) diff --git a/docs/docs.json b/docs/docs.json index 8e0b50865071..602edde6501b 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1233,6 +1233,7 @@ "group": "Sessions and memory", "pages": [ "concepts/main-session", + "concepts/multi-user", "concepts/session", "concepts/session-search", "concepts/channel-docking", @@ -2056,4 +2057,4 @@ } ] } -} \ No newline at end of file +} diff --git a/docs/docs_map.md b/docs/docs_map.md index 76c1b754dc4b..6c756a26b1bf 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -2778,6 +2778,15 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: Per-agent sandbox and tool configuration - H2: Related +## concepts/multi-user.md + +- Route: /concepts/multi-user +- Headings: + - H2: Trust boundary + - H2: Ownership and presence + - H2: Turn attribution + - H2: Related + ## concepts/oauth.md - Route: /concepts/oauth diff --git a/packages/gateway-protocol/src/index.ts b/packages/gateway-protocol/src/index.ts index de4da5b70cb0..622f25e1ffcb 100644 --- a/packages/gateway-protocol/src/index.ts +++ b/packages/gateway-protocol/src/index.ts @@ -25,6 +25,7 @@ export type { MissingScopeErrorDetails, } from "./schema/error-codes.js"; export * from "./schema/board.js"; +export { SessionCreatorIdentitySchema, type SessionCreatorIdentity } from "./schema/sessions.js"; export * from "./migration-api.js"; export type * from "./public-session-catalog.js"; import { diff --git a/packages/gateway-protocol/src/schema/sessions-catalog.test.ts b/packages/gateway-protocol/src/schema/sessions-catalog.test.ts index e5e96a9e8410..6b6d67ab6c5f 100644 --- a/packages/gateway-protocol/src/schema/sessions-catalog.test.ts +++ b/packages/gateway-protocol/src/schema/sessions-catalog.test.ts @@ -31,6 +31,7 @@ describe("SessionsCatalogListResultSchema", () => { threadId: "thread-1", status: "idle", archived: false, + createdBy: { id: "profile-ada", label: "Ada" }, canContinue: true, canArchive: false, canOpenTerminal: true, diff --git a/packages/gateway-protocol/src/schema/sessions-catalog.ts b/packages/gateway-protocol/src/schema/sessions-catalog.ts index f8838ec3c424..850e6a81066e 100644 --- a/packages/gateway-protocol/src/schema/sessions-catalog.ts +++ b/packages/gateway-protocol/src/schema/sessions-catalog.ts @@ -3,6 +3,7 @@ import { Type } from "typebox"; import { closedObject } from "./closed-object.js"; import { PluginJsonValueSchema } from "./plugins.js"; import { NonEmptyString } from "./primitives.js"; +import { SessionCreatorIdentitySchema } from "./sessions.js"; const SessionCatalogErrorSchema = closedObject({ code: NonEmptyString, message: NonEmptyString }); @@ -55,6 +56,7 @@ export const SessionCatalogSessionSchema = closedObject({ pullRequest: Type.Optional(SessionCatalogPullRequestSummarySchema), archived: Type.Boolean(), sessionKey: Type.Optional(NonEmptyString), + createdBy: Type.Optional(SessionCreatorIdentitySchema), canContinue: Type.Boolean(), canArchive: Type.Boolean(), canOpenTerminal: Type.Optional(Type.Boolean()), diff --git a/packages/gateway-protocol/src/schema/sessions.ts b/packages/gateway-protocol/src/schema/sessions.ts index 11bc7667b67a..846322fb549a 100644 --- a/packages/gateway-protocol/src/schema/sessions.ts +++ b/packages/gateway-protocol/src/schema/sessions.ts @@ -20,6 +20,13 @@ export const SESSION_OBSERVER_HEALTH_VALUES = [ "failed", ] as const; +/** Stable identity stamped on a session when an operator creates it. */ +export const SessionCreatorIdentitySchema = closedObject({ + id: NonEmptyString, + label: Type.Optional(NonEmptyString), +}); +export type SessionCreatorIdentity = Static; + /** Trajectory judgment produced for one observed agent session. */ export const SessionObserverHealthSchema = Type.Union([ Type.Literal("on-track"), @@ -297,6 +304,8 @@ export const SessionsListParamsSchema = closedObject({ */ includeLastMessage: Type.Optional(Type.Boolean()), label: Type.Optional(SessionLabelString), + /** Filter rows by their permanent creator identity. */ + creatorId: Type.Optional(NonEmptyString), spawnedBy: Type.Optional(NonEmptyString), agentId: Type.Optional(NonEmptyString), search: Type.Optional(Type.String()), diff --git a/scripts/check-control-ui-performance.mjs b/scripts/check-control-ui-performance.mjs index 0a529ac50d4f..1f4464b2c2b3 100644 --- a/scripts/check-control-ui-performance.mjs +++ b/scripts/check-control-ui-performance.mjs @@ -12,11 +12,10 @@ const KIB = 1024; export const CONTROL_UI_PERFORMANCE_BUDGETS = Object.freeze({ startupJsRequests: 18, startupCssRequests: 1, - // 314 KiB accompanies cloud-workspace conflict recovery (2026-07): the live - // notice and sidebar attention must be available on initial chat render, and - // their bounded recovery copy exhausted the previous ceiling after rebasing. - // One KiB restores explicit headroom without changing the request budget. - startupJsGzipBytes: 314 * KIB, + // 315 KiB accompanies session creator attribution (2026-07): owner chips, + // the person filter, and their catalog strings live in the startup bundle, + // and main again sat within 0.1 KiB of the ceiling. + startupJsGzipBytes: 315 * KIB, // 45 KiB CSS ceilings maintainer-approved 2026-07 alongside the interleaved // sidebar zone styling; headroom over the ~36.5 KiB post-diet baseline. startupCssGzipBytes: 45 * KIB, diff --git a/src/auto-reply/reply/get-reply-fast-path.ts b/src/auto-reply/reply/get-reply-fast-path.ts index aa455070251c..b11e572bd915 100644 --- a/src/auto-reply/reply/get-reply-fast-path.ts +++ b/src/auto-reply/reply/get-reply-fast-path.ts @@ -214,6 +214,9 @@ export function initFastReplySessionState(params: { const sessionEntry: SessionEntry = { ...(!resetTriggered ? existingEntry : undefined), sessionId, + ...((resetTriggered || !existingEntry) && ctx.SessionCreator + ? { createdBy: { ...ctx.SessionCreator } } + : {}), sessionFile, updatedAt: now, sessionStartedAt: resetTriggered ? now : (existingEntry?.sessionStartedAt ?? now), diff --git a/src/auto-reply/reply/session-creator.test.ts b/src/auto-reply/reply/session-creator.test.ts new file mode 100644 index 000000000000..3238403d8a65 --- /dev/null +++ b/src/auto-reply/reply/session-creator.test.ts @@ -0,0 +1,41 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, expect, it } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; +import { upsertSessionEntry } from "../../config/sessions/session-accessor.js"; +import { closeOpenClawAgentDatabasesForTest } from "../../state/openclaw-agent-db.js"; +import { initSessionState } from "./session.js"; + +let tempDir: string | undefined; + +afterEach(async () => { + closeOpenClawAgentDatabasesForTest(); + if (tempDir) { + await fs.rm(tempDir, { force: true, recursive: true }); + tempDir = undefined; + } +}); + +it("clears the previous creator when an ownerless turn starts a new generation", async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-creator-")); + const storePath = path.join(tempDir, "sessions.json"); + const sessionKey = "agent:main:telegram:chat:creator"; + await upsertSessionEntry( + { sessionKey, storePath }, + { + createdBy: { id: "alice@example.com", label: "Alice" }, + sessionId: "owned-session", + updatedAt: 1, + }, + ); + + const result = await initSessionState({ + ctx: { Body: "/new", CommandBody: "/new", SessionKey: sessionKey }, + cfg: { session: { store: storePath } } as OpenClawConfig, + commandAuthorized: true, + }); + + expect(result.isNewSession).toBe(true); + expect(result.sessionEntry).not.toHaveProperty("createdBy"); +}); diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index 9c1ea0909399..0117bb6df6cf 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -905,6 +905,13 @@ async function initSessionStateAttemptLocked( sessionEntry = { ...baseEntry, sessionId, + ...(isNewSession + ? ctx.SessionCreator + ? { createdBy: { ...ctx.SessionCreator } } + : {} + : baseEntry?.createdBy + ? { createdBy: baseEntry.createdBy } + : {}), updatedAt: Date.now(), sessionStartedAt: isNewSession ? now diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index e16fdec6e7d1..6179cef154db 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -276,6 +276,8 @@ export type MsgContext = { OwnerAllowFrom?: Array; SenderName?: string; SenderId?: string; + /** Trusted Gateway operator identity used only when creating a session. */ + SessionCreator?: import("../../packages/gateway-protocol/src/schema/sessions.js").SessionCreatorIdentity; SenderUsername?: string; SenderTag?: string; SenderE164?: string; diff --git a/src/config/sessions/session-accessor.sqlite-entry-store.ts b/src/config/sessions/session-accessor.sqlite-entry-store.ts index 91dabf0b9846..112924395719 100644 --- a/src/config/sessions/session-accessor.sqlite-entry-store.ts +++ b/src/config/sessions/session-accessor.sqlite-entry-store.ts @@ -25,6 +25,7 @@ import { import { normalizeSqliteStatus, parseSqliteSessionEntryJson as parseSessionEntryRow, + serializeSqliteSessionCreatorIdentity, } from "./session-accessor.sqlite-status.js"; import { readTranscriptMutationStateInTransaction, @@ -487,6 +488,7 @@ export function writeSessionEntry( entry_json: JSON.stringify(normalizedEntry), updated_at: updatedAt, status: normalizeSqliteStatus(normalizedEntry.status), + created_by_json: serializeSqliteSessionCreatorIdentity(normalizedEntry.createdBy), }) .onConflict((conflict) => conflict.column("session_key").doUpdateSet({ @@ -494,6 +496,7 @@ export function writeSessionEntry( entry_json: JSON.stringify(normalizedEntry), updated_at: updatedAt, status: normalizeSqliteStatus(normalizedEntry.status), + created_by_json: serializeSqliteSessionCreatorIdentity(normalizedEntry.createdBy), }), ), ); diff --git a/src/config/sessions/session-accessor.sqlite-status.ts b/src/config/sessions/session-accessor.sqlite-status.ts index d73ccc88b5cb..d125210aa008 100644 --- a/src/config/sessions/session-accessor.sqlite-status.ts +++ b/src/config/sessions/session-accessor.sqlite-status.ts @@ -19,12 +19,42 @@ export function normalizeSqliteStatus(value: unknown): SessionEntryStatus | null : null; } +function normalizeSessionCreatorIdentity(value: unknown): SessionEntry["createdBy"] { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + const candidate = value as { id?: unknown; label?: unknown }; + const id = typeof candidate.id === "string" ? candidate.id.trim() : ""; + if (!id) { + return undefined; + } + const label = typeof candidate.label === "string" ? candidate.label.trim() : ""; + return { id, ...(label ? { label } : {}) }; +} + +export function serializeSqliteSessionCreatorIdentity( + createdBy: SessionEntry["createdBy"], +): string | null { + const normalized = normalizeSessionCreatorIdentity(createdBy); + return normalized ? JSON.stringify(normalized) : null; +} + export function parseSqliteSessionEntryJson(row: { entry_json: string }): SessionEntry | null { try { const parsed = JSON.parse(row.entry_json) as unknown; - return parsed && typeof parsed === "object" && !Array.isArray(parsed) - ? (parsed as SessionEntry) - : null; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return null; + } + const entry = parsed as SessionEntry; + // entry_json stays authoritative across downgrade/upgrade cycles: an older + // binary can rewrite it without knowing about the additive projection column. + const createdBy = normalizeSessionCreatorIdentity(entry.createdBy); + if (createdBy) { + entry.createdBy = createdBy; + } else { + delete entry.createdBy; + } + return entry; } catch { return null; } diff --git a/src/config/sessions/session-accessor.test.ts b/src/config/sessions/session-accessor.test.ts index cdc351fc0094..233d7cac1a4f 100644 --- a/src/config/sessions/session-accessor.test.ts +++ b/src/config/sessions/session-accessor.test.ts @@ -105,6 +105,7 @@ describe("session accessor seam", () => { }; await upsertSessionEntry(scope, { + createdBy: { id: "profile-ada", label: "Ada Lovelace" }, model: "gpt-5.5", sessionId: "session-1", updatedAt: 10, @@ -112,10 +113,20 @@ describe("session accessor seam", () => { expect(loadSessionEntry(scope)).toMatchObject({ model: "gpt-5.5", + createdBy: { id: "profile-ada", label: "Ada Lovelace" }, sessionId: "session-1", updatedAt: expect.any(Number), }); expect(readSessionUpdatedAt(scope)).toEqual(expect.any(Number)); + const databasePath = resolveSqliteTargetFromSessionStorePath(storePath, { + agentId: "main", + }).path; + const database = openOpenClawAgentDatabase({ agentId: "main", path: databasePath }); + expect( + database.db + .prepare("SELECT created_by_json FROM session_entries WHERE session_key = ?") + .get(scope.sessionKey), + ).toEqual({ created_by_json: '{"id":"profile-ada","label":"Ada Lovelace"}' }); expect(listSessionEntries({ storePath })).toEqual([ { sessionKey: "agent:main:main", @@ -127,6 +138,17 @@ describe("session accessor seam", () => { }, ]); + // A downgraded writer knows only entry_json and can leave the additive + // projection untouched. Re-upgrade must not resurrect that stale creator. + database.db + .prepare("UPDATE session_entries SET entry_json = ?, updated_at = ? WHERE session_key = ?") + .run( + JSON.stringify({ model: "legacy-reset", sessionId: "session-1", updatedAt: 15 }), + 15, + scope.sessionKey, + ); + expect(loadSessionEntry(scope)).not.toHaveProperty("createdBy"); + await upsertSessionEntry(scope, { model: "sonnet-4.6", updatedAt: 20 }); expect(loadSessionEntry(scope)).toMatchObject({ @@ -134,6 +156,7 @@ describe("session accessor seam", () => { sessionId: "session-1", updatedAt: expect.any(Number), }); + expect(loadSessionEntry(scope)).not.toHaveProperty("createdBy"); }); it("lists retained transcript instances across same-key session rotation", async () => { diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index c5914380bd42..17f8323f4288 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -7,6 +7,7 @@ import type { } from "@openclaw/acp-core/types"; import { normalizeOptionalString, type FastMode } from "@openclaw/normalization-core/string-coerce"; import type { SessionObserverDigest } from "../../../packages/gateway-protocol/src/schema/sessions.js"; +import type { SessionCreatorIdentity } from "../../../packages/gateway-protocol/src/schema/sessions.js"; import type { SessionAgentStatus } from "../../../packages/gateway-protocol/src/session-icon.js"; import type { ChatType } from "../../channels/chat-type.js"; import type { ChannelId } from "../../channels/plugins/channel-id.types.js"; @@ -251,6 +252,8 @@ export type SessionEntry = SessionRestartRecoveryState & /** Durable one-shot prompt additions drained before the next agent turn. */ pluginNextTurnInjections?: Record; sessionId: string; + /** Operator identity captured once for this session generation. */ + createdBy?: SessionCreatorIdentity; updatedAt: number; /** Opaque owner revision used to reject stale lifecycle mutations. */ lifecycleRevision?: string; diff --git a/src/gateway/server-methods/agent-reset-phase.ts b/src/gateway/server-methods/agent-reset-phase.ts index ae0935e2de8c..b5c03a782acc 100644 --- a/src/gateway/server-methods/agent-reset-phase.ts +++ b/src/gateway/server-methods/agent-reset-phase.ts @@ -21,6 +21,7 @@ import { resolveBareSessionResetResult, runSessionResetFromAgent, } from "./agent-session-reset.js"; +import { gatewayClientSessionCreator } from "./gateway-client-identity.js"; import { emitSessionsChanged } from "./session-change-event.js"; import type { GatewayRequestHandlerOptions } from "./types.js"; @@ -96,6 +97,7 @@ export async function runAgentResetPhase(params: { ? { agentId: params.agentId } : {}), reason: resetReason, + createdBy: gatewayClientSessionCreator(params.client), assertCurrent: () => assertAgentRunLifecycleGenerationCurrent(params.lifecycleGeneration), onCommitted: (commit) => { params.setCommittedResetCompletion({ diff --git a/src/gateway/server-methods/agent-run-handler.ts b/src/gateway/server-methods/agent-run-handler.ts index 174763b8124b..f0a97892ff4a 100644 --- a/src/gateway/server-methods/agent-run-handler.ts +++ b/src/gateway/server-methods/agent-run-handler.ts @@ -23,6 +23,7 @@ import { startAgentRunExecution } from "./agent-run-execution-phase.js"; import { buildAgentSessionPatch } from "./agent-session-patch.js"; import { persistAgentSessionPhase } from "./agent-session-persist.js"; import { prepareAgentSession } from "./agent-session-prepare.js"; +import { gatewayClientSessionCreator } from "./gateway-client-identity.js"; import type { GatewayRequestHandlers } from "./types.js"; export const agentRunHandler: GatewayRequestHandlers["agent"] = async ({ @@ -297,6 +298,7 @@ export const agentRunHandler: GatewayRequestHandlers["agent"] = async ({ freshEntry === undefined ? normalizeOptionalString(client?.internal?.pluginRuntimeOwnerId) : undefined, + createdBy: gatewayClientSessionCreator(client), expectedExistingSessionId, hasRestoredCronContinuation: restoredCronContinuationIdentity !== undefined, resetPolicy, diff --git a/src/gateway/server-methods/agent-session-patch.test.ts b/src/gateway/server-methods/agent-session-patch.test.ts index 1c8c2a0d5d44..a1ed06abc0c2 100644 --- a/src/gateway/server-methods/agent-session-patch.test.ts +++ b/src/gateway/server-methods/agent-session-patch.test.ts @@ -31,6 +31,59 @@ function buildPatch(touchInteraction: boolean) { } describe("agent session patch", () => { + it("stamps a creator only when minting a new session", () => { + const patch = buildAgentSessionPatch({ + freshEntry: undefined, + initialEntry: undefined, + cfg: {}, + sessionAgentId: "main", + canonicalSessionKey: "agent:main:new", + storePath: "/tmp/openclaw-agent-creator-test.json", + normalizedSpawned: {}, + requestDeliveryHint: undefined, + createdBy: { id: "profile-ada", label: "Ada" }, + hasRestoredCronContinuation: false, + resetPolicy: resolveSessionResetPolicy({ resetType: "direct" }), + now: 1_000, + isSystemGatewayRun: false, + visibleRequest: true, + fallbackSessionId: "new-session", + touchInteraction: true, + failedSessionTranscriptMissing: () => false, + }).patch; + + expect(patch.createdBy).toEqual({ id: "profile-ada", label: "Ada" }); + }); + + it("clears a previous creator on an ownerless implicit rotation", () => { + const entry: SessionEntry = { + createdBy: { id: "profile-ada", label: "Ada" }, + sessionId: "old-session", + updatedAt: 1, + }; + const patch = buildAgentSessionPatch({ + freshEntry: entry, + initialEntry: entry, + cfg: {}, + sessionAgentId: "main", + canonicalSessionKey: "agent:main:main", + storePath: "/tmp/openclaw-agent-creator-rotation.json", + normalizedSpawned: {}, + requestDeliveryHint: undefined, + hasRestoredCronContinuation: false, + resetPolicy: resolveSessionResetPolicy({ resetType: "direct" }), + now: 2, + isSystemGatewayRun: false, + visibleRequest: true, + fallbackSessionId: "new-session", + touchInteraction: true, + failedSessionTranscriptMissing: () => true, + }).patch; + + expect(Object.hasOwn(patch, "createdBy")).toBe(true); + expect(patch.createdBy).toBeUndefined(); + }); + it("clears agent status at the next human interaction boundary", () => { const patch = buildPatch(true); expect(Object.hasOwn(patch, "agentStatus")).toBe(true); diff --git a/src/gateway/server-methods/agent-session-patch.ts b/src/gateway/server-methods/agent-session-patch.ts index f828dd80d6bb..7ba3de5c0fd5 100644 --- a/src/gateway/server-methods/agent-session-patch.ts +++ b/src/gateway/server-methods/agent-session-patch.ts @@ -50,6 +50,7 @@ export function buildAgentSessionPatch(params: { requestLabel?: string; recipientChannel?: string; pluginOwnerId?: string; + createdBy?: SessionEntry["createdBy"]; expectedExistingSessionId?: string; hasRestoredCronContinuation: boolean; resetPolicy: ReturnType; @@ -209,6 +210,9 @@ export function buildAgentSessionPatch(params: { sessionId: patchSessionId, updatedAt: params.now, ...(freshIsNewSession && !freshSessionRotatedSinceLoad ? { sessionStartedAt: params.now } : {}), + ...(freshIsNewSession && !freshSessionRotatedSinceLoad + ? { createdBy: params.createdBy ? { ...params.createdBy } : undefined } + : {}), ...(params.touchInteraction ? { lastInteractionAt: params.now, diff --git a/src/gateway/server-methods/agent-session-reset.ts b/src/gateway/server-methods/agent-session-reset.ts index feb20a2a1742..1c8a67694b48 100644 --- a/src/gateway/server-methods/agent-session-reset.ts +++ b/src/gateway/server-methods/agent-session-reset.ts @@ -1,4 +1,5 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import type { SessionCreatorIdentity } from "../../../packages/gateway-protocol/src/index.js"; import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; import type { AgentCommandOpts } from "../../agents/command/types.js"; import { agentCommandFromIngress } from "../../commands/agent.js"; @@ -19,6 +20,7 @@ export async function runSessionResetFromAgent(params: { key: string; agentId?: string; reason: "new" | "reset"; + createdBy?: SessionCreatorIdentity; assertCurrent?: () => void; onCommitted?: (commit: { key: string; sessionId: string }) => void; }) { @@ -27,6 +29,7 @@ export async function runSessionResetFromAgent(params: { ...(params.agentId ? { agentId: params.agentId } : {}), reason: params.reason, commandSource: "gateway:agent", + createdBy: params.createdBy, assertCurrent: params.assertCurrent, onCommitted: params.onCommitted, }); diff --git a/src/gateway/server-methods/chat-send-user-turn.ts b/src/gateway/server-methods/chat-send-user-turn.ts index e31083b70fe2..af26276e4d4c 100644 --- a/src/gateway/server-methods/chat-send-user-turn.ts +++ b/src/gateway/server-methods/chat-send-user-turn.ts @@ -17,6 +17,7 @@ import type { prepareChatSendAttachments } from "./chat-send-attachments.js"; import type { NormalizedChatSendRequest } from "./chat-send-request.js"; import type { PreparedChatSendSession } from "./chat-send-session.js"; import { normalizeOptionalChatText } from "./chat-text-normalization.js"; +import { gatewayClientSessionCreator } from "./gateway-client-identity.js"; import type { GatewayRequestContext, GatewayRequestHandlerOptions } from "./types.js"; type PreparedChatSendAttachments = Extract< @@ -179,6 +180,9 @@ function buildChatSendMessageContext(params: { body: commandBody, }, MessageSid: params.clientRunId, + ...(gatewayClientSessionCreator(params.client) + ? { SessionCreator: gatewayClientSessionCreator(params.client) } + : {}), ApprovalReviewerDeviceId: queuedFollowupOwnerDeviceId, ...(!isOperatorUiClient(params.clientInfo) ? { diff --git a/src/gateway/server-methods/gateway-client-identity.ts b/src/gateway/server-methods/gateway-client-identity.ts index 85f7028a683a..35747ef5241c 100644 --- a/src/gateway/server-methods/gateway-client-identity.ts +++ b/src/gateway/server-methods/gateway-client-identity.ts @@ -17,3 +17,8 @@ export function gatewayClientSenderFields(client: GatewayClient | null): { } return client?.authenticatedUserId ? { sender: { id: client.authenticatedUserId } } : {}; } + +/** Returns the trusted creator identity captured during connection admission. */ +export function gatewayClientSessionCreator(client: GatewayClient | null) { + return client?.operatorIdentity ? { ...client.operatorIdentity } : undefined; +} diff --git a/src/gateway/server-methods/session-catalog.test.ts b/src/gateway/server-methods/session-catalog.test.ts index accff7643bfc..91f5234cc7ca 100644 --- a/src/gateway/server-methods/session-catalog.test.ts +++ b/src/gateway/server-methods/session-catalog.test.ts @@ -4,9 +4,15 @@ import { gatewaySubagentState } from "../../plugins/runtime/gateway-bindings.js" import { createPluginRuntime } from "../../plugins/runtime/index.js"; import type { SessionCatalogProvider } from "../../plugins/session-catalog.js"; +type CatalogSessionEntryLoader = ( + sessionKey: string, + options?: { agentId?: string; clone?: boolean }, +) => { entry: { createdBy?: { id: string; label?: string } } | undefined }; + const hoisted = vi.hoisted(() => ({ activeRegistry: { sessionCatalogs: [] as unknown[] }, pinnedSessionExtensionRegistry: undefined as { sessionCatalogs: unknown[] } | undefined, + loadSessionEntryReadOnly: vi.fn(() => ({ entry: undefined })), recordSessionStateEvent: vi.fn(), upsertSessionUpstreamLink: vi.fn(), })); @@ -33,6 +39,10 @@ vi.mock("../../sessions/session-upstream-links.js", () => ({ vi.mock("../../plugins/session-conversation-binding.js", () => ({ bindPluginSessionConversation: conversationBindingMocks.bindPluginSessionConversation, })); +vi.mock("../session-utils.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, loadSessionEntryReadOnly: hoisted.loadSessionEntryReadOnly }; +}); const { resolveSessionCatalogCreateTarget, sessionCatalogHandlers } = await import("./session-catalog.js"); @@ -71,6 +81,8 @@ describe("session catalog Gateway methods", () => { beforeEach(() => { hoisted.activeRegistry.sessionCatalogs = []; hoisted.pinnedSessionExtensionRegistry = undefined; + hoisted.loadSessionEntryReadOnly.mockReset(); + hoisted.loadSessionEntryReadOnly.mockReturnValue({ entry: undefined }); hoisted.recordSessionStateEvent.mockClear(); hoisted.upsertSessionUpstreamLink.mockClear(); conversationBindingMocks.bindPluginSessionConversation.mockClear(); @@ -144,6 +156,98 @@ describe("session catalog Gateway methods", () => { }); }); + it("projects authoritative creator ownership onto streamed and final catalog rows", async () => { + const broadcastToConnIds = vi.fn(); + const host = { + hostId: "gateway:local", + label: "Local Claude", + kind: "gateway" as const, + connected: true, + sessions: [ + { + threadId: "owned-thread", + status: "stored", + archived: false, + sessionKey: "agent:main:owned", + createdBy: { id: "provider-spoof" }, + canContinue: true, + canArchive: false, + }, + { + threadId: "missing-thread", + status: "stored", + archived: false, + sessionKey: "agent:main:missing", + createdBy: { id: "provider-spoof" }, + canContinue: true, + canArchive: false, + }, + { + threadId: "external-thread", + status: "stored", + archived: false, + createdBy: { id: "provider-spoof" }, + canContinue: true, + canArchive: false, + }, + ], + }; + hoisted.loadSessionEntryReadOnly.mockImplementation((sessionKey: string) => ({ + entry: + sessionKey === "agent:main:owned" + ? { createdBy: { id: "profile-ada", label: "Ada" } } + : undefined, + })); + hoisted.activeRegistry.sessionCatalogs = [ + { + provider: provider("claude", { + list: vi.fn(async ({ onHost }) => { + onHost?.(host); + return [host]; + }), + }), + }, + ]; + + const respond = await call( + "sessions.catalog.list", + { progressId: "progress-creator" }, + {}, + { connId: "requester", connect: {} }, + { broadcastToConnIds }, + ); + const projectedSessions = [ + expect.objectContaining({ + threadId: "owned-thread", + createdBy: { id: "profile-ada", label: "Ada" }, + }), + expect.not.objectContaining({ createdBy: expect.anything() }), + expect.not.objectContaining({ createdBy: expect.anything() }), + ]; + + expect(broadcastToConnIds).toHaveBeenCalledWith( + "sessions.catalog.host", + expect.objectContaining({ + catalog: expect.objectContaining({ + hosts: [expect.objectContaining({ sessions: projectedSessions })], + }), + }), + new Set(["requester"]), + { dropIfSlow: true }, + ); + expect(respond).toHaveBeenCalledWith(true, { + catalogs: [ + expect.objectContaining({ + hosts: [expect.objectContaining({ sessions: projectedSessions })], + }), + ], + }); + expect(hoisted.loadSessionEntryReadOnly).toHaveBeenCalledWith("agent:main:owned", { + agentId: "main", + }); + expect(hoisted.loadSessionEntryReadOnly).toHaveBeenCalledTimes(2); + }); + it("uses the pinned Gateway catalog runtime after active registry churn", async () => { const previousNodesRuntime = gatewaySubagentState.nodes; const listNodes = vi.fn(async () => ({ nodes: [] })); diff --git a/src/gateway/server-methods/session-catalog.ts b/src/gateway/server-methods/session-catalog.ts index 4ab19397ceb7..bd2f50f03451 100644 --- a/src/gateway/server-methods/session-catalog.ts +++ b/src/gateway/server-methods/session-catalog.ts @@ -4,6 +4,8 @@ import { ErrorCodes, errorShape, type SessionCatalog, + type SessionCatalogHost, + type SessionCatalogSession, type SessionsCatalogArchiveParams, type SessionsCatalogContinueParams, type SessionsCatalogListParams, @@ -22,6 +24,7 @@ import { bindPluginSessionConversation } from "../../plugins/session-conversatio import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; import { recordSessionStateEvent } from "../../sessions/session-state-events.js"; import { upsertSessionUpstreamLink } from "../../sessions/session-upstream-links.js"; +import { loadSessionEntryReadOnly } from "../session-utils.js"; import { resolveAgentIdOrRespondError } from "./agent-id-shared.js"; import type { GatewayRequestHandlers, RespondFn } from "./types.js"; import { assertValidParams } from "./validation.js"; @@ -155,6 +158,32 @@ function catalogResult( return result; } +function projectCatalogHostCreators( + host: SessionCatalogHost, + agentId: string, + creatorBySessionKey: Map, +): SessionCatalogHost { + return { + ...host, + sessions: host.sessions.map(({ createdBy: _providerCreatedBy, ...session }) => { + // Catalog providers do not own creator identity; the persisted session entry does. + const sessionKey = session.sessionKey; + let createdBy: SessionCatalogSession["createdBy"]; + if (sessionKey && creatorBySessionKey.has(sessionKey)) { + createdBy = creatorBySessionKey.get(sessionKey); + } else { + createdBy = sessionKey + ? loadSessionEntryReadOnly(sessionKey, { agentId }).entry?.createdBy + : undefined; + if (sessionKey) { + creatorBySessionKey.set(sessionKey, createdBy); + } + } + return createdBy ? { ...session, createdBy: { ...createdBy } } : session; + }), + }; +} + export const sessionCatalogHandlers: GatewayRequestHandlers = { "sessions.catalog.list": async ({ params, respond, context, client }) => { if ( @@ -199,6 +228,7 @@ export const sessionCatalogHandlers: GatewayRequestHandlers = { const search = normalizeSessionCatalogSearch(request.search); const progressId = request.progressId; const progressConnId = progressId && client?.connId ? client.connId : undefined; + const creatorBySessionKey = new Map(); const catalogList = await Promise.all( selected.map(async (provider): Promise => { const createTarget = resolveProviderCreateTarget(provider, resolvedAgent.agentId); @@ -212,7 +242,12 @@ export const sessionCatalogHandlers: GatewayRequestHandlers = { { progressId, agentId: resolvedAgent.agentId, - catalog: catalogResult(provider, [host], undefined, createSession), + catalog: catalogResult( + provider, + [projectCatalogHostCreators(host, resolvedAgent.agentId, creatorBySessionKey)], + undefined, + createSession, + ), }, new Set([progressConnId]), { dropIfSlow: true }, @@ -227,7 +262,14 @@ export const sessionCatalogHandlers: GatewayRequestHandlers = { ...(request.cursors !== undefined ? { cursors: request.cursors } : {}), ...(onHost ? { onHost } : {}), }); - return catalogResult(provider, hosts, undefined, createSession); + return catalogResult( + provider, + hosts.map((host) => + projectCatalogHostCreators(host, resolvedAgent.agentId, creatorBySessionKey), + ), + undefined, + createSession, + ); } catch (error) { return catalogResult(provider, [], catalogError(error), createSession); } diff --git a/src/gateway/server-methods/sessions-create.ts b/src/gateway/server-methods/sessions-create.ts index 9c7e0c556613..cec4042ab5a7 100644 --- a/src/gateway/server-methods/sessions-create.ts +++ b/src/gateway/server-methods/sessions-create.ts @@ -28,6 +28,7 @@ import { resolveSessionStoreAgentId } from "../session-store-key.js"; import { readSessionMessageCountAsync } from "../session-transcript-readers.js"; import { loadSessionEntryReadOnly, resolveGatewaySessionStoreTarget } from "../session-utils.js"; import { chatHandlers } from "./chat.js"; +import { gatewayClientSessionCreator } from "./gateway-client-identity.js"; import { resolveSessionCatalogCreateTarget } from "./session-catalog.js"; import { emitSessionsChanged } from "./session-change-event.js"; import { @@ -328,6 +329,7 @@ export const sessionCreateHandlers: GatewayRequestHandlers = { ).allowed; const created = await createGatewaySession({ cfg, + createdBy: gatewayClientSessionCreator(client), key: sessionKey, agentId: sessionAgentId, label: p.label, diff --git a/src/gateway/server-methods/sessions-mutations.ts b/src/gateway/server-methods/sessions-mutations.ts index cd88268534f2..139fca10d60d 100644 --- a/src/gateway/server-methods/sessions-mutations.ts +++ b/src/gateway/server-methods/sessions-mutations.ts @@ -34,6 +34,7 @@ import { type SessionsPatchResult, } from "../session-utils.js"; import { projectSessionsPatchEntry } from "../sessions-patch.js"; +import { gatewayClientSessionCreator } from "./gateway-client-identity.js"; import { hasVisibleActiveSessionRun } from "./session-active-runs.js"; import { emitSessionsChanged } from "./session-change-event.js"; import { @@ -382,7 +383,7 @@ export const sessionMutationHandlers: GatewayRequestHandlers = { reason: "plugin-patch", }); }, - "sessions.reset": async ({ params, respond, context }) => { + "sessions.reset": async ({ params, respond, context, client }) => { if (!assertValidParams(params, validateSessionsResetParams, "sessions.reset", respond)) { return; } @@ -399,6 +400,7 @@ export const sessionMutationHandlers: GatewayRequestHandlers = { ...(p.agentId ? { agentId: p.agentId } : {}), reason, commandSource: "gateway:sessions.reset", + createdBy: gatewayClientSessionCreator(client), }); if (!result.ok) { respond(false, undefined, result.error); diff --git a/src/gateway/server-methods/shared-types.ts b/src/gateway/server-methods/shared-types.ts index 5b42f14aac0c..2c97bc0af226 100644 --- a/src/gateway/server-methods/shared-types.ts +++ b/src/gateway/server-methods/shared-types.ts @@ -1,4 +1,5 @@ import type { + SessionCreatorIdentity, SessionApprovalReplay, SystemAgentChatQuestion, } from "../../../packages/gateway-protocol/src/index.js"; @@ -77,6 +78,8 @@ export type GatewayClient = { hasAvatar: boolean; updatedAt: number; }; + /** Trusted operator identity resolved once during connection admission. */ + operatorIdentity?: SessionCreatorIdentity; pluginSurfaceUrls?: Record; pluginNodeCapabilitySurfaces?: Record; pluginNodeCapabilities?: Record; diff --git a/src/gateway/server-session-events.ts b/src/gateway/server-session-events.ts index 46b23040a5ed..74dba1cdede5 100644 --- a/src/gateway/server-session-events.ts +++ b/src/gateway/server-session-events.ts @@ -93,6 +93,7 @@ function buildGatewaySessionSnapshot(params: { const session = params.includeSession ? { ...buildGatewaySessionEventRow(sessionRow), + createdBy: sessionRow.createdBy ?? null, thinkingLevel: sessionRow.thinkingLevel ?? null, } : undefined; diff --git a/src/gateway/server.sessions.create.test.ts b/src/gateway/server.sessions.create.test.ts index 489bc3bf4e84..50565079c71c 100644 --- a/src/gateway/server.sessions.create.test.ts +++ b/src/gateway/server.sessions.create.test.ts @@ -73,6 +73,51 @@ function requireNonEmptyString(value: string | undefined, label: string): string return value; } +test("sessions.create stamps the trusted creator and preserves it until reset", async () => { + await createSessionStoreDir(); + const adaClient = { + operatorIdentity: { id: "profile-ada", label: "Ada Lovelace" }, + connect: { scopes: ["operator.admin"] }, + } as never; + const bobClient = { + operatorIdentity: { id: "profile-bob", label: "Bob Hopper" }, + connect: { scopes: ["operator.admin"] }, + } as never; + + const created = await directSessionReq<{ + key: string; + entry: { createdBy?: { id: string; label?: string } }; + }>("sessions.create", { agentId: "main" }, { client: adaClient }); + expect(created.ok).toBe(true); + expect(created.payload?.entry.createdBy).toEqual({ + id: "profile-ada", + label: "Ada Lovelace", + }); + const key = requireNonEmptyString(created.payload?.key, "created session key"); + + const reused = await directSessionReq<{ entry: { createdBy?: { id: string } } }>( + "sessions.create", + { agentId: "main", key }, + { client: bobClient }, + ); + expect(reused.payload?.entry.createdBy?.id).toBe("profile-ada"); + + const listed = await directSessionReq<{ + sessions: Array<{ key: string; createdBy?: { id: string; label?: string } }>; + }>("sessions.list", { agentId: "main" }); + expect(listed.payload?.sessions.find((row) => row.key === key)?.createdBy).toEqual({ + id: "profile-ada", + label: "Ada Lovelace", + }); + + const reset = await directSessionReq<{ entry: { createdBy?: { id: string; label?: string } } }>( + "sessions.reset", + { agentId: "main", key }, + { client: bobClient }, + ); + expect(reset.payload?.entry.createdBy).toEqual({ id: "profile-bob", label: "Bob Hopper" }); +}); + test("sessions.create provisions and reuses a session worktree for later runs", async () => { const root = await fs.mkdtemp( path.join(await fs.realpath(os.tmpdir()), "openclaw-session-worktree-"), diff --git a/src/gateway/server/ws-connection/connect-session.ts b/src/gateway/server/ws-connection/connect-session.ts index b7fa7713eeba..18c0bd7b103d 100644 --- a/src/gateway/server/ws-connection/connect-session.ts +++ b/src/gateway/server/ws-connection/connect-session.ts @@ -9,6 +9,7 @@ import { import { ConnectErrorDetailCodes } from "../../../../packages/gateway-protocol/src/connect-error-details.js"; import { ErrorCodes, PROTOCOL_VERSION } from "../../../../packages/gateway-protocol/src/index.js"; import { getRuntimeConfig } from "../../../config/io.js"; +import { getPairedDevice } from "../../../infra/device-pairing.js"; import { captureAuthenticatedNodePairingState, type NodePairingGeneration, @@ -213,6 +214,32 @@ export async function attachAuthenticatedGatewayConnect( ); } } + let pairedDeviceLabel: string | undefined; + if (device?.id) { + try { + const pairedDevice = await getPairedDevice(device.id); + pairedDeviceLabel = + normalizeOptionalString(pairedDevice?.operatorLabel) ?? + normalizeOptionalString(pairedDevice?.displayName); + } catch (error) { + // Pairing metadata is attribution-only and must not turn into a login dependency. + logWsControl.warn( + `paired device label resolution failed conn=${connId}: ${formatForLog(error)}`, + ); + } + } + // SSO identity wins over device labeling so one person keeps the same creator + // across browsers; paired-device labels cover gateways without trusted proxy auth. + const operatorIdentity = authenticatedUserProfile + ? { + id: authenticatedUserId, + label: authenticatedUserProfile.displayName ?? authenticatedUserId, + } + : authenticatedUserId + ? { id: authenticatedUserId, label: authenticatedUserId } + : device?.id && pairedDeviceLabel + ? { id: device.id, label: pairedDeviceLabel } + : undefined; const pluginSurfaceUrls: Record = {}; const pluginNodeCapabilitySurfaces = indexPluginNodeCapabilitySurfaces(pluginNodeCapabilities); @@ -308,6 +335,7 @@ export async function attachAuthenticatedGatewayConnect( presenceKey, ...(authenticatedUserId ? { authenticatedUserId } : {}), ...(authenticatedUserProfile ? { authenticatedUserProfile } : {}), + ...(operatorIdentity ? { operatorIdentity } : {}), clientIp: reportedClientIp, ...(internal ? { internal } : {}), ...(Object.keys(pluginSurfaceUrls).length > 0 ? { pluginSurfaceUrls } : {}), diff --git a/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts b/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts index eee08f93c826..a79491a40bb6 100644 --- a/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts +++ b/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts @@ -628,6 +628,7 @@ describe("attachGatewayWsMessageHandler post-connect health refresh", () => { displayName: "alice", hasAvatar: false, }, + operatorIdentity: { id: "alice@example.com", label: "alice" }, }); expect(setAvatar(profileId!, new Uint8Array([1, 2, 3]), "image/png").ok).toBe(true); @@ -664,7 +665,10 @@ describe("attachGatewayWsMessageHandler post-connect health refresh", () => { }), ); }); - expect(harness.client).toMatchObject({ authenticatedUserId: "alice@example.com" }); + expect(harness.client).toMatchObject({ + authenticatedUserId: "alice@example.com", + operatorIdentity: { id: "alice@example.com", label: "alice@example.com" }, + }); expect(harness.client).not.toMatchObject({ authenticatedUserProfile: expect.anything() }); expect(harness.logWsControl.warn).toHaveBeenCalledTimes(1); expect(harness.logWsControl.warn).toHaveBeenCalledWith( diff --git a/src/gateway/session-create-service.ts b/src/gateway/session-create-service.ts index 1ec467e41075..3057259b267a 100644 --- a/src/gateway/session-create-service.ts +++ b/src/gateway/session-create-service.ts @@ -6,6 +6,7 @@ import { import { ErrorCodes, type ErrorShape, + type SessionCreatorIdentity, errorShape, missingScopeErrorShape, } from "../../packages/gateway-protocol/src/index.js"; @@ -260,6 +261,7 @@ export async function createGatewaySession(params: { thinkingLevel?: string; /** Trusted catalog-owned model/runtime pair, persisted and locked together. */ catalogTarget?: TrustedCatalogSessionTarget; + createdBy?: SessionCreatorIdentity; parentSessionKey?: string; /** * Spawn-lineage depth declared by spawn-owned creations (visible subagent @@ -535,6 +537,7 @@ export async function createGatewaySession(params: { : {}), reason: "new", commandSource: params.commandSource, + createdBy: params.createdBy, ...(spawnedCwd ? { spawnedCwd } : {}), ...(params.worktree ? { worktree: params.worktree } : {}), ...(params.execNode ? { execNode: params.execNode } : {}), @@ -768,6 +771,9 @@ export async function createGatewaySession(params: { : undefined; const initializedEntry: SessionEntry = { ...patched.entry, + ...(existingEntry === undefined && params.createdBy + ? { createdBy: { ...params.createdBy } } + : {}), ...(catalogResolvedModel && catalogAgentRuntime ? { providerOverride: catalogResolvedModel.provider, diff --git a/src/gateway/session-event-payload.test.ts b/src/gateway/session-event-payload.test.ts new file mode 100644 index 000000000000..10179b23d1b8 --- /dev/null +++ b/src/gateway/session-event-payload.test.ts @@ -0,0 +1,21 @@ +import { expect, it } from "vitest"; +import { buildGatewaySessionEventFields } from "./session-event-payload.js"; + +it("projects creator identity and explicitly clears it for ownerless generations", () => { + expect( + buildGatewaySessionEventFields({ + sessionRow: { + key: "agent:main:owned", + kind: "direct", + updatedAt: 1, + createdBy: { id: "profile-ada", label: "Ada" }, + }, + }).createdBy, + ).toEqual({ id: "profile-ada", label: "Ada" }); + + expect( + buildGatewaySessionEventFields({ + sessionRow: { key: "agent:main:ownerless", kind: "direct", updatedAt: 2 }, + }).createdBy, + ).toBeNull(); +}); diff --git a/src/gateway/session-event-payload.ts b/src/gateway/session-event-payload.ts index 69134ac7f6d9..3db640fe5d14 100644 --- a/src/gateway/session-event-payload.ts +++ b/src/gateway/session-event-payload.ts @@ -27,6 +27,7 @@ export function buildGatewaySessionEventFields(params: { return { updatedAt: sessionRow.updatedAt ?? undefined, sessionId: sessionRow.sessionId, + createdBy: sessionRow.createdBy ?? null, kind: sessionRow.kind, channel: sessionRow.channel, subject: sessionRow.subject, diff --git a/src/gateway/session-reset-service.ts b/src/gateway/session-reset-service.ts index f3a923784e30..c92d072c8f40 100644 --- a/src/gateway/session-reset-service.ts +++ b/src/gateway/session-reset-service.ts @@ -1,7 +1,11 @@ // Gateway session reset/delete service. // Rotates transcripts and coordinates lifecycle cleanup across runtimes/hooks. import { randomUUID } from "node:crypto"; -import { ErrorCodes, errorShape } from "../../packages/gateway-protocol/src/index.js"; +import { + ErrorCodes, + errorShape, + type SessionCreatorIdentity, +} from "../../packages/gateway-protocol/src/index.js"; import { getAcpSessionManager } from "../acp/control-plane/manager.js"; import { getAcpRuntimeBackend } from "../acp/runtime/registry.js"; import { @@ -908,6 +912,7 @@ export async function performGatewaySessionReset(params: { clearSpawnedCwd?: boolean; reason: "new" | "reset"; commandSource: string; + createdBy?: SessionCreatorIdentity; assertCurrent?: () => void; onCommitted?: (commit: { key: string; sessionId: string }) => void; }): Promise< @@ -1190,6 +1195,7 @@ export async function performGatewaySessionReset(params: { }); const nextEntry: SessionEntry = { sessionId: nextSessionId, + ...(params.createdBy ? { createdBy: { ...params.createdBy } } : {}), sessionFile, updatedAt: now, systemSent: false, diff --git a/src/gateway/session-utils-creators.test.ts b/src/gateway/session-utils-creators.test.ts new file mode 100644 index 000000000000..a250f0db7ab8 --- /dev/null +++ b/src/gateway/session-utils-creators.test.ts @@ -0,0 +1,42 @@ +import { expect, it } from "vitest"; +import type { SessionEntry } from "../config/sessions.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { listSessionsFromStore } from "./session-utils.js"; + +it("returns the complete deterministic creator facet independently of pagination", () => { + const store: Record = { + "agent:main:ada": { + createdBy: { id: "profile-ada", label: "Ada" }, + sessionId: "session-ada", + updatedAt: 2, + }, + "agent:main:bob": { + createdBy: { id: "profile-bob", label: "Bob" }, + sessionId: "session-bob", + updatedAt: 1, + }, + }; + + const result = listSessionsFromStore({ + cfg: {} as OpenClawConfig, + storePath: "/tmp/openclaw-session-creators", + store, + opts: { limit: 1 }, + }); + + expect(result.count).toBe(1); + expect(result.totalCount).toBe(2); + expect(result.creators).toEqual([ + { id: "profile-ada", label: "Ada" }, + { id: "profile-bob", label: "Bob" }, + ]); + + const filtered = listSessionsFromStore({ + cfg: {} as OpenClawConfig, + storePath: "/tmp/openclaw-session-creators", + store, + opts: { creatorId: "profile-bob", limit: 1 }, + }); + expect(filtered.sessions.map((row) => row.key)).toEqual(["agent:main:bob"]); + expect(filtered.creators).toEqual(result.creators); +}); diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 87723b1a7106..ff56fb9fea1a 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -7,7 +7,10 @@ import { normalizeOptionalLowercaseString, } from "@openclaw/normalization-core/string-coerce"; import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; -import type { SessionsListParams } from "../../packages/gateway-protocol/src/index.js"; +import type { + SessionCreatorIdentity, + SessionsListParams, +} from "../../packages/gateway-protocol/src/index.js"; import { readAcpSessionMeta, readAcpSessionMetaForEntry, @@ -2234,6 +2237,7 @@ export function buildGatewaySessionRow(params: { return { key, + createdBy: entry?.createdBy, spawnedBy: subagentOwner || entry?.spawnedBy, swarmGroupId: entry?.swarmGroupId, spawnedWorkspaceDir: entry?.spawnedWorkspaceDir, @@ -2535,6 +2539,7 @@ const SESSIONS_LIST_DEFAULT_LIMIT = 100; type SessionEntrySelection = { entries: SessionEntryPair[]; + creatorEntries: SessionEntryPair[]; totalCount: number; limitApplied?: number; offset: number; @@ -2724,7 +2729,11 @@ function selectSessionEntries(params: { getRowContext?: SessionListRowContextProvider; defaultLimit?: number; }): SessionEntrySelection { - const filtered = filterSessionEntries(params); + const creatorEntries = filterSessionEntries(params); + const creatorId = normalizeOptionalString(params.opts.creatorId); + const filtered = creatorId + ? creatorEntries.filter(([, entry]) => entry.createdBy?.id === creatorId) + : creatorEntries; const limit = resolveSessionsListLimit(params.opts, params.defaultLimit); const offset = resolveSessionsListOffset(params.opts); const windowLimit = resolveSessionsListWindowLimit(limit, offset); @@ -2735,6 +2744,7 @@ function selectSessionEntries(params: { const hasMore = nextOffset < filtered.length; return { entries, + creatorEntries, totalCount: filtered.length, limitApplied: limit, offset, @@ -2743,6 +2753,27 @@ function selectSessionEntries(params: { }; } +function listSessionCreatorIdentities( + entries: readonly SessionEntryPair[], +): SessionCreatorIdentity[] { + const creators = new Map(); + for (const [, entry] of entries) { + const id = normalizeOptionalString(entry.createdBy?.id); + if (!id) { + continue; + } + const label = normalizeOptionalString(entry.createdBy?.label); + const existing = creators.get(id); + if (!existing || (label && (!existing.label || label.localeCompare(existing.label) < 0))) { + creators.set(id, { id, ...(label ? { label } : {}) }); + } + } + return [...creators.values()].toSorted((a, b) => { + const byLabel = (a.label ?? a.id).localeCompare(b.label ?? b.id); + return byLabel || a.id.localeCompare(b.id); + }); +} + export function filterAndSortSessionEntries(params: { cfg: OpenClawConfig; store: Record; @@ -2785,7 +2816,8 @@ export function listSessionsFromStore(params: { : undefined, defaultLimit: SESSIONS_LIST_DEFAULT_LIMIT, }); - const { entries, totalCount, limitApplied, offset, nextOffset, hasMore } = selection; + const { entries, creatorEntries, totalCount, limitApplied, offset, nextOffset, hasMore } = + selection; const fullRowContext = rowContext || hasSpawnedByFilter || entries.length > SESSIONS_LIST_YIELD_BATCH_SIZE ? getRowContext() @@ -2829,6 +2861,7 @@ export function listSessionsFromStore(params: { offset: offset > 0 ? offset : undefined, nextOffset, hasMore, + creators: listSessionCreatorIdentities(creatorEntries), defaults: getSessionDefaults(cfg, params.modelCatalog, { allowPluginNormalization: false }), sessions, }; diff --git a/src/gateway/session-utils.types.ts b/src/gateway/session-utils.types.ts index 8327e09f7215..d2e87f4e6fd3 100644 --- a/src/gateway/session-utils.types.ts +++ b/src/gateway/session-utils.types.ts @@ -2,6 +2,7 @@ // Keeps server methods and Control UI payloads aligned. import type { FastMode } from "@openclaw/normalization-core/string-coerce"; import type { SessionPlacement } from "../../packages/gateway-protocol/src/index.js"; +import type { SessionCreatorIdentity } from "../../packages/gateway-protocol/src/schema/sessions.js"; import type { SessionObserverDigest } from "../../packages/gateway-protocol/src/schema/sessions.js"; import type { QueueMode } from "../auto-reply/reply/queue/types.js"; import type { ChatType } from "../channels/chat-type.js"; @@ -45,6 +46,7 @@ type SessionCompactionCheckpointPreview = Pick< export type GatewaySessionRow = { key: string; + createdBy?: SessionCreatorIdentity; spawnedBy?: string; /** Collector swarm group that owns this child session, when applicable. */ swarmGroupId?: string; diff --git a/src/plugins/session-entry-slot-keys.ts b/src/plugins/session-entry-slot-keys.ts index 1a37f495aa56..30e5f9a2e3e2 100644 --- a/src/plugins/session-entry-slot-keys.ts +++ b/src/plugins/session-entry-slot-keys.ts @@ -14,6 +14,7 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [ "pluginExtensionSlotKeys", "pluginNextTurnInjections", "sessionId", + "createdBy", "lifecycleRevision", "updatedAt", "archivedAt", diff --git a/src/shared/session-types.ts b/src/shared/session-types.ts index 337455fb74d1..63bc2513831b 100644 --- a/src/shared/session-types.ts +++ b/src/shared/session-types.ts @@ -1,3 +1,5 @@ +import type { SessionCreatorIdentity } from "../../packages/gateway-protocol/src/schema/sessions.js"; + /** Agent identity fields returned by gateway session listing APIs. */ type GatewayAgentIdentity = { name?: string; @@ -61,6 +63,8 @@ export type SessionsListResultBase = { offset?: number; nextOffset?: number | null; hasMore?: boolean; + /** Complete creator facet for the filtered result, independent of pagination. */ + creators?: SessionCreatorIdentity[]; defaults: TDefaults; sessions: TRow[]; }; diff --git a/src/state/openclaw-agent-db-schema.ts b/src/state/openclaw-agent-db-schema.ts index 461532e4aa7e..e3487bbba3d9 100644 --- a/src/state/openclaw-agent-db-schema.ts +++ b/src/state/openclaw-agent-db-schema.ts @@ -252,6 +252,15 @@ function migrateOpenClawAgentSchema(db: DatabaseSync): void { backfillTranscriptMutationWatermarks(db); } +function ensureAdditiveSessionEntryColumns(db: DatabaseSync): void { + const columns = readSqliteTableColumns(db, "session_entries"); + if (columns && !columns.has("created_by_json")) { + // This nullable projection is safe for older readers and intentionally + // stays outside the schema-version migration ladder. + db.exec("ALTER TABLE session_entries ADD COLUMN created_by_json TEXT;"); + } +} + /** Backfill one generation token without copying or rewriting transcript rows. */ function migrateSessionTranscriptGenerations(db: DatabaseSync, previousVersion: number): void { if (previousVersion >= 13) { @@ -513,6 +522,7 @@ function ensureAgentSchema(db: DatabaseSync, agentId: string, pathname: string): dropLegacySessionTranscriptSearchSchema(db); migrateMemoryIndexSourcesIdentity(db); migrateOpenClawAgentSchema(db); + ensureAdditiveSessionEntryColumns(db); db.exec( previousVersion === OPENCLAW_AGENT_SCHEMA_VERSION ? OPENCLAW_AGENT_SCHEMA_WITHOUT_BOARD_SQL diff --git a/src/state/openclaw-agent-db.generated.d.ts b/src/state/openclaw-agent-db.generated.d.ts index d125b098dade..c8e4a758eae3 100644 --- a/src/state/openclaw-agent-db.generated.d.ts +++ b/src/state/openclaw-agent-db.generated.d.ts @@ -186,6 +186,7 @@ export interface SessionConversations { } export interface SessionEntries { + created_by_json: string | null; entry_json: string; session_id: string; session_key: string; diff --git a/src/state/openclaw-agent-db.test.ts b/src/state/openclaw-agent-db.test.ts index 609d3266dfd6..d845c1de94b3 100644 --- a/src/state/openclaw-agent-db.test.ts +++ b/src/state/openclaw-agent-db.test.ts @@ -1820,6 +1820,30 @@ describe("openclaw agent database", () => { expect(journalMode?.journal_mode?.toLowerCase()).toBe("wal"); }); + it("lazy-ensures the additive session creator column without a version bump", () => { + const stateDir = createTempStateDir(); + const env = { OPENCLAW_STATE_DIR: stateDir }; + const database = openOpenClawAgentDatabase({ agentId: "worker-1", env }); + const databasePath = database.path; + const schemaVersion = readSqliteNumberPragma(database.db, "user_version"); + closeOpenClawAgentDatabasesForTest(); + + const { DatabaseSync } = requireNodeSqlite(); + const legacy = new DatabaseSync(databasePath); + try { + legacy.exec("ALTER TABLE session_entries DROP COLUMN created_by_json;"); + } finally { + legacy.close(); + } + + const reopened = openOpenClawAgentDatabase({ agentId: "worker-1", env }); + const columns = reopened.db.prepare("PRAGMA table_info(session_entries)").all() as Array<{ + name: string; + }>; + expect(columns.map((column) => column.name)).toContain("created_by_json"); + expect(readSqliteNumberPragma(reopened.db, "user_version")).toBe(schemaVersion); + }); + it("backfills per-entry status while migrating a v6 agent database", () => { const stateDir = createTempStateDir(); const env = { OPENCLAW_STATE_DIR: stateDir }; diff --git a/src/state/openclaw-agent-schema.generated.ts b/src/state/openclaw-agent-schema.generated.ts index 254f47f65712..24ab105185a3 100644 --- a/src/state/openclaw-agent-schema.generated.ts +++ b/src/state/openclaw-agent-schema.generated.ts @@ -169,6 +169,7 @@ CREATE TABLE IF NOT EXISTS session_entries ( entry_json TEXT NOT NULL, updated_at INTEGER NOT NULL, status TEXT CHECK (status IS NULL OR status IN ('running', 'done', 'failed', 'killed', 'timeout')), + created_by_json TEXT, FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE ) STRICT; diff --git a/src/state/openclaw-agent-schema.sql b/src/state/openclaw-agent-schema.sql index afdf06a78c27..ab50e44ea749 100644 --- a/src/state/openclaw-agent-schema.sql +++ b/src/state/openclaw-agent-schema.sql @@ -164,6 +164,7 @@ CREATE TABLE IF NOT EXISTS session_entries ( entry_json TEXT NOT NULL, updated_at INTEGER NOT NULL, status TEXT CHECK (status IS NULL OR status IN ('running', 'done', 'failed', 'killed', 'timeout')), + created_by_json TEXT, FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE ) STRICT; diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index c32f7e2cee5d..e71aaedcf9f7 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -492,6 +492,7 @@ type SessionCompactionCheckpointPreview = Pick< export type GatewaySessionRow = { key: string; + createdBy?: import("../../../packages/gateway-protocol/src/schema/sessions.js").SessionCreatorIdentity; spawnedBy?: string; /** Collector swarm group that owns this child session, when applicable. */ swarmGroupId?: string; diff --git a/ui/src/components/app-sidebar-session-catalogs.ts b/ui/src/components/app-sidebar-session-catalogs.ts index 381946d8735e..43607e1f852a 100644 --- a/ui/src/components/app-sidebar-session-catalogs.ts +++ b/ui/src/components/app-sidebar-session-catalogs.ts @@ -101,6 +101,7 @@ type SessionCatalogGroupsParams = { loadingMoreCatalogIds: ReadonlySet; projectGrouping: CatalogProjectGrouping; liveRows: readonly GatewaySessionRow[]; + creatorId?: string | null; renderLiveRow: (row: GatewaySessionRow, display: CatalogBackingSessionDisplay) => unknown; onToggleSection: (sectionId: string) => void; onToggleProjectGrouping: () => void; @@ -167,7 +168,15 @@ export function renderSessionCatalogGroups(params: SessionCatalogGroupsParams) { const sectionId = `catalog:${catalog.id}`; const collapsed = params.collapsedSections.has(sectionId); const hosts = catalog.hosts; - const visibleHosts = hosts.filter((host) => host.sessions.length > 0); + const visibleHosts: SessionCatalogHost[] = []; + for (const host of hosts) { + const sessions = host.sessions.filter( + (session) => !params.creatorId || session.createdBy?.id === params.creatorId, + ); + if (sessions.length > 0) { + visibleHosts.push(sessions.length === host.sessions.length ? host : { ...host, sessions }); + } + } const rows = visibleHosts.flatMap((host) => host.sessions.map((session) => ({ host, session })), ); diff --git a/ui/src/components/app-sidebar-session-data.ts b/ui/src/components/app-sidebar-session-data.ts index a66fcd8f9a31..f587fd10754d 100644 --- a/ui/src/components/app-sidebar-session-data.ts +++ b/ui/src/components/app-sidebar-session-data.ts @@ -24,7 +24,6 @@ import { type SidebarSessionStatusFilter, type SidebarSessionsScrollState, } from "./app-sidebar-session-types.ts"; - /** Gateway-backed session and external-catalog synchronization. */ export abstract class AppSidebarSessionDataElement extends AppSidebarSessionCatalogDataElement { @state() protected visibleSessionLimit = SIDEBAR_SESSION_PAGE_SIZE; @@ -45,7 +44,6 @@ export abstract class AppSidebarSessionDataElement extends AppSidebarSessionCata protected sessionRowsByAgent: Record = {}; protected sessionCreatedOrder = new Map(); - private readonly subscriptions = new SubscriptionsController(this); private sessionsSource: SessionCapability | null = null; private childSessionGeneration = 0; @@ -59,8 +57,7 @@ export abstract class AppSidebarSessionDataElement extends AppSidebarSessionCata private gatewaySource: ApplicationContext["gateway"] | null = null; private gatewayClient: GatewayBrowserClient | null = null; private gatewayConnected = false; - // Mutation completions belong to one context/capability/connection epoch. - // Bumping this prevents old failures or batch tails crossing a reconnect. + // Bind mutation completions to one epoch so stale failures cannot cross reconnects. private sessionMutationEpoch = 0; private sessionsScrollElement: HTMLElement | null = null; private sessionsScrollResizeObserver: ResizeObserver | null = null; @@ -182,8 +179,7 @@ export abstract class AppSidebarSessionDataElement extends AppSidebarSessionCata } } - // Reading scrollHeight/scrollTop inside updated() forces a layout flush per - // render; one rAF-coalesced read rides the layout computed for paint anyway. + // One rAF-coalesced scroll read rides paint layout instead of flushing every update. private scheduleSessionsScrollStateSync() { if (this.sessionsScrollStateFrame !== null) { return; diff --git a/ui/src/components/app-sidebar-session-list.ts b/ui/src/components/app-sidebar-session-list.ts index 5ff62926ae95..062c74f3e5ad 100644 --- a/ui/src/components/app-sidebar-session-list.ts +++ b/ui/src/components/app-sidebar-session-list.ts @@ -139,7 +139,8 @@ export abstract class AppSidebarSessionListElement extends AppSidebarSessionNarr aria-describedby=${metaId ?? nothing} @click=${(event: MouseEvent) => this.handleSessionRowClick(event, session)} > - ${leadingIndicator} + ${leadingIndicator}${this.renderSidebarSessionOwnerChip(session)} ${session.archived @@ -559,10 +560,8 @@ export abstract class AppSidebarSessionListElement extends AppSidebarSessionNarr } return this.renderSessionSection(section, options.codingTrailing ?? nothing); } - // Threads hides its bare header when empty, except while a draft needs - // a home or a session drag needs the unpin drop target. Empty custom - // categories keep rendering: they are user-created containers and the - // "New group…" / drag-into-group flows depend on seeing them. + // Threads hides its bare empty header; unfiltered custom categories stay + // visible because creation and drag flows depend on them as drop targets. if ( section.id === "ungrouped" && section.totalRowCount === 0 && @@ -653,6 +652,7 @@ export abstract class AppSidebarSessionListElement extends AppSidebarSessionNarr ` : nothing}