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
This commit is contained in:
Peter Steinberger
2026-07-22 05:47:21 -07:00
committed by GitHub
parent 0226ad50fa
commit cf2f591161
70 changed files with 1481 additions and 35 deletions
+4
View File
@@ -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:
@@ -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"
@@ -1 +1 @@
011b9ec0e0fa64b5a4036648fcd61a50674b813a92280e8e3fe8746f6acdb62d sqlite-session-transcript-schema-baseline.sql
0852c1b681df33646f60d239afcac6de53fbc498bd5daa58077376331205ccdf sqlite-session-transcript-schema-baseline.sql
+20
View File
@@ -1606,5 +1606,25 @@
{
"source": "Baseten (Inkling + Model APIs)",
"target": "BasetenInkling + Model APIs"
},
{
"source": "Multi-user mode",
"target": "多用户模式"
},
{
"source": "The main session",
"target": "主会话"
},
{
"source": "Session management",
"target": "会话管理"
},
{
"source": "Presence",
"target": "在线状态"
},
{
"source": "Gateway security",
"target": "Gateway 安全"
}
]
+39
View File
@@ -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)
+2 -1
View File
@@ -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 @@
}
]
}
}
}
+9
View File
@@ -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
+1
View File
@@ -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 {
@@ -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,
@@ -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()),
@@ -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<typeof SessionCreatorIdentitySchema>;
/** 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()),
+4 -5
View File
@@ -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,
@@ -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),
@@ -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");
});
+7
View File
@@ -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
+2
View File
@@ -276,6 +276,8 @@ export type MsgContext = {
OwnerAllowFrom?: Array<string | number>;
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;
@@ -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),
}),
),
);
@@ -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;
}
@@ -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 () => {
+3
View File
@@ -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<string, SessionPluginNextTurnInjection[]>;
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;
@@ -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({
@@ -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,
@@ -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);
@@ -50,6 +50,7 @@ export function buildAgentSessionPatch(params: {
requestLabel?: string;
recipientChannel?: string;
pluginOwnerId?: string;
createdBy?: SessionEntry["createdBy"];
expectedExistingSessionId?: string;
hasRestoredCronContinuation: boolean;
resetPolicy: ReturnType<typeof import("../../config/sessions.js").resolveSessionResetPolicy>;
@@ -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,
@@ -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,
});
@@ -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)
? {
@@ -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;
}
@@ -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<CatalogSessionEntryLoader>(() => ({ 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<typeof import("../session-utils.js")>();
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: [] }));
+44 -2
View File
@@ -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<string, SessionCatalogSession["createdBy"]>,
): 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<string, SessionCatalogSession["createdBy"]>();
const catalogList = await Promise.all(
selected.map(async (provider): Promise<SessionCatalog> => {
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);
}
@@ -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,
@@ -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);
@@ -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<string, string>;
pluginNodeCapabilitySurfaces?: Record<string, PluginNodeCapabilitySurface>;
pluginNodeCapabilities?: Record<string, { capability: string; expiresAtMs: number }>;
+1
View File
@@ -93,6 +93,7 @@ function buildGatewaySessionSnapshot(params: {
const session = params.includeSession
? {
...buildGatewaySessionEventRow(sessionRow),
createdBy: sessionRow.createdBy ?? null,
thinkingLevel: sessionRow.thinkingLevel ?? null,
}
: undefined;
@@ -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-"),
@@ -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<string, string> = {};
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 } : {}),
@@ -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(
+6
View File
@@ -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,
+21
View File
@@ -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();
});
+1
View File
@@ -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,
+7 -1
View File
@@ -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,
@@ -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<string, SessionEntry> = {
"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);
});
+36 -3
View File
@@ -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<string, SessionCreatorIdentity>();
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<string, SessionEntry>;
@@ -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,
};
+2
View File
@@ -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;
+1
View File
@@ -14,6 +14,7 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [
"pluginExtensionSlotKeys",
"pluginNextTurnInjections",
"sessionId",
"createdBy",
"lifecycleRevision",
"updatedAt",
"archivedAt",
+4
View File
@@ -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<TDefaults, TRow> = {
offset?: number;
nextOffset?: number | null;
hasMore?: boolean;
/** Complete creator facet for the filtered result, independent of pagination. */
creators?: SessionCreatorIdentity[];
defaults: TDefaults;
sessions: TRow[];
};
+10
View File
@@ -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
+1
View File
@@ -186,6 +186,7 @@ export interface SessionConversations {
}
export interface SessionEntries {
created_by_json: string | null;
entry_json: string;
session_id: string;
session_key: string;
+24
View File
@@ -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 };
@@ -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;
+1
View File
@@ -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;
+1
View File
@@ -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;
@@ -101,6 +101,7 @@ type SessionCatalogGroupsParams = {
loadingMoreCatalogIds: ReadonlySet<string>;
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 })),
);
@@ -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<string, SessionsListResult["sessions"]> = {};
protected sessionCreatedOrder = new Map<string, number>();
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<RouteId>["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;
@@ -139,7 +139,8 @@ export abstract class AppSidebarSessionListElement extends AppSidebarSessionNarr
aria-describedby=${metaId ?? nothing}
@click=${(event: MouseEvent) => this.handleSessionRowClick(event, session)}
>
<span class="sidebar-session-indicator">${leadingIndicator}</span>
<span class="sidebar-session-indicator">${leadingIndicator}</span
>${this.renderSidebarSessionOwnerChip(session)}
<span class="sidebar-recent-session__text">
<span class="sidebar-recent-session__name hover-marquee"
>${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}
<div class="sidebar-recent-sessions" aria-label=${titleForRoute("sessions")}>
${this.renderSidebarSessionCreatorFilter()}
${this.renderSessionListBody(visibleSessions, {
showDraft:
Boolean(this.draftSessionAgentId) &&
@@ -690,6 +690,7 @@ export abstract class AppSidebarSessionListElement extends AppSidebarSessionNarr
...(this.sessionsResult?.sessions ?? []),
...Object.values(this.sessionRowsByAgent).flat(),
],
creatorId: this.activeSessionCreatorId,
renderLiveRow: (row, display) =>
this.renderRecentSession(navigationState.toSidebarSession(row), display),
onToggleSection: (sectionId) => this.toggleSessionSection(sectionId),
@@ -40,7 +40,7 @@ import {
adoptedCatalogSessionKeys,
formatSidebarTimestamp,
} from "./app-sidebar-session-catalogs.ts";
import { AppSidebarSessionProjectionElement } from "./app-sidebar-session-projection.ts";
import { AppSidebarSessionOwnershipElement } from "./app-sidebar-session-ownership.ts";
import { projectSessionTree } from "./app-sidebar-session-tree.ts";
import {
limitSidebarSessionRows,
@@ -55,7 +55,7 @@ import {
import { isStoppableCloudWorkerPlacement } from "./session-row-badges.ts";
/** Session-row projection, selection, sorting, and agent scope navigation. */
export abstract class AppSidebarSessionNavigationElement extends AppSidebarSessionProjectionElement {
export abstract class AppSidebarSessionNavigationElement extends AppSidebarSessionOwnershipElement {
@state() protected selectedSessionKeys: ReadonlySet<string> = new Set();
@state() protected expandedChildSessionKeys: ReadonlySet<string> = new Set();
@state() protected collapsedActiveChildSessionKeys: ReadonlySet<string> = new Set();
@@ -148,6 +148,7 @@ export abstract class AppSidebarSessionNavigationElement extends AppSidebarSessi
}
return {
key: row.key,
createdBy: row.createdBy,
// The sidebar's zone structure already says what forked from what;
// a "Subagent:" prefix on named threads is noise (other surfaces keep it).
label: resolveSessionDisplayName(row.key, row, {
@@ -249,7 +250,11 @@ export abstract class AppSidebarSessionNavigationElement extends AppSidebarSessi
const sections = groupSidebarSessionRows(rows, {
grouping: this.sessionsGrouping,
knownGroups: this.sessionsGrouping === "category" ? this.knownSessionGroups() : undefined,
}).filter((section) => section.id !== "pinned");
}).filter(
(section) =>
section.id !== "pinned" &&
!this.hideEmptyCreatorFilteredGroup(section.category, section.rows.length),
);
const expandedRows = sections.flatMap((section) =>
this.isSessionSectionCollapsed(section.id) ? [] : section.rows,
);
@@ -612,7 +617,7 @@ export abstract class AppSidebarSessionNavigationElement extends AppSidebarSessi
// `adopted` holds only catalog-bound keys (adoptedCatalogSessionKeys), not
// fetched child rows: a catalog-adopted promoted child intentionally
// renders as its live row inside the Coding catalog, never as a thread.
return projectSessionTree({
const projected = projectSessionTree({
roots: orderedRootRows.filter((row) => !adopted.has(row.key)),
agentRows: rows,
childRowsByParent: this.childSessionRowsByParent,
@@ -620,6 +625,9 @@ export abstract class AppSidebarSessionNavigationElement extends AppSidebarSessi
knownSessionAttention: this.knownSessionAttention(),
toSidebarSession: navigationState.toSidebarSession,
});
const creatorFacet =
rows === this.sessionsResult?.sessions ? this.sessionsResult.creators : undefined;
return this.applySessionCreatorFilter(projected, rows, creatorFacet);
}
/** Canonical main-session key for the selected (or given) agent. */
@@ -0,0 +1,103 @@
import { state } from "lit/decorators.js";
import { AppSidebarSessionProjectionElement } from "./app-sidebar-session-projection.ts";
import type { SidebarRecentSession } from "./app-sidebar-session-types.ts";
import {
listSessionCreators,
renderSessionCreatorFilter,
renderSessionOwnerChip,
type SessionCreatedBy,
} from "./session-owner-chip.ts";
/** Creator attribution, solo dormancy, and filtering shared by sidebar session surfaces. */
export abstract class AppSidebarSessionOwnershipElement extends AppSidebarSessionProjectionElement {
@state() protected sessionCreatorFilterId: string | null = null;
protected sessionCreatorOptions: readonly SessionCreatedBy[] = [];
protected activeSessionCreatorId: string | null = null;
protected sessionCreatorFilterActive = false;
protected sessionOwnershipVisible = false;
override updated() {
super.updated();
const selectedId = this.sessionCreatorFilterId;
const creators = this.sessionsResult?.creators;
if (
selectedId &&
creators &&
(creators.length < 2 || !creators.some((creator) => creator.id === selectedId))
) {
this.sessionCreatorFilterId = null;
void this.context?.sessions.setCreatorFilter(null);
}
}
protected applySessionCreatorFilter(
projected: readonly SidebarRecentSession[],
creatorRows: readonly { createdBy?: SessionCreatedBy }[] = [],
creatorFacet?: readonly SessionCreatedBy[],
): SidebarRecentSession[] {
const flattened: SidebarRecentSession[] = [];
const pending = [...projected];
while (pending.length > 0) {
const row = pending.shift();
if (row) {
flattened.push(row);
pending.push(...row.children);
}
}
const completeFacet = creatorFacet ?? this.sessionsResult?.creators;
this.sessionCreatorOptions = listSessionCreators([
...(completeFacet ?? []).map((createdBy) => ({ createdBy })),
...flattened,
...creatorRows,
]);
this.sessionOwnershipVisible = this.sessionCreatorOptions.length >= 2;
const creatorId = this.sessionOwnershipVisible
? this.sessionCreatorOptions.some((creator) => creator.id === this.sessionCreatorFilterId)
? this.sessionCreatorFilterId
: null
: null;
this.sessionCreatorFilterActive = creatorId !== null;
this.activeSessionCreatorId = creatorId;
if (!creatorId) {
return [...projected];
}
const filterTree = (treeRows: readonly SidebarRecentSession[]): SidebarRecentSession[] => {
const filtered: SidebarRecentSession[] = [];
for (const row of treeRows) {
const children = filterTree(row.children);
if (row.createdBy?.id === creatorId) {
filtered.push({ ...row, children });
} else {
for (const child of children) {
filtered.push({ ...child, isChild: false });
}
}
}
return filtered;
};
return filterTree(projected);
}
protected renderSidebarSessionOwnerChip(session: SidebarRecentSession) {
return renderSessionOwnerChip(
this.sessionOwnershipVisible ? session.createdBy : undefined,
"row",
);
}
protected renderSidebarSessionCreatorFilter() {
return renderSessionCreatorFilter({
creators: this.sessionOwnershipVisible ? this.sessionCreatorOptions : [],
selectedId: this.sessionCreatorFilterActive ? this.sessionCreatorFilterId : null,
onChange: (creatorId) => {
this.sessionCreatorFilterId = creatorId;
void this.context?.sessions.setCreatorFilter(creatorId);
},
});
}
protected hideEmptyCreatorFilteredGroup(category: string | undefined, rowCount: number): boolean {
return this.sessionCreatorFilterActive && Boolean(category) && rowCount === 0;
}
}
@@ -1,5 +1,6 @@
import type { SessionCatalogPullRequestSummary } from "../../../packages/gateway-protocol/src/schema/sessions-catalog.js";
import type { SessionObserverDigest } from "../../../packages/gateway-protocol/src/schema/sessions.js";
import type { SessionCreatorIdentity } from "../../../packages/gateway-protocol/src/schema/sessions.js";
import type { SessionAgentAttentionIconId } from "../../../packages/gateway-protocol/src/session-icon.js";
import type { GatewayBrowserClient } from "../api/gateway.ts";
import type { SessionRunStatus } from "../api/types.ts";
@@ -50,6 +51,7 @@ export function sidebarSessionAttentionPriority(attention: SidebarSessionAttenti
export type SidebarRecentSession = {
key: string;
createdBy?: SessionCreatorIdentity;
label: string;
meta: string;
/** Compact repo/branch/node line for work sessions. */
+1
View File
@@ -17,5 +17,6 @@ import "../test-helpers/app-sidebar-cases/narration.ts";
import "../test-helpers/app-sidebar-cases/pull-request-state.ts";
import "../test-helpers/app-sidebar-cases/sidebar-scroll.ts";
import "../test-helpers/app-sidebar-cases/sessions.ts";
import "../test-helpers/app-sidebar-cases/session-ownership.ts";
import "../test-helpers/app-sidebar-cases/session-list-sections.ts";
import "../test-helpers/app-sidebar-cases/sidebar-zone.ts";
+130
View File
@@ -0,0 +1,130 @@
import { html, nothing } from "lit";
import { property } from "lit/decorators.js";
import type { SessionCreatorIdentity } from "../../../packages/gateway-protocol/src/schema/sessions.js";
import { t } from "../i18n/index.ts";
import { OpenClawLightDomElement } from "../lit/openclaw-element.ts";
export type SessionCreatedBy = SessionCreatorIdentity;
export function listSessionCreators(
sessions: readonly { createdBy?: SessionCreatedBy }[],
): SessionCreatedBy[] {
const creators = new Map<string, SessionCreatedBy>();
for (const session of sessions) {
const id = session.createdBy?.id.trim();
if (!id) {
continue;
}
const label = session.createdBy?.label?.trim();
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 renderSessionOwnerChip(
createdBy: SessionCreatedBy | null | undefined,
size: "row" | "header",
) {
return createdBy
? html`<openclaw-session-owner-chip
.createdBy=${createdBy}
size=${size}
></openclaw-session-owner-chip>`
: nothing;
}
export function renderSessionCreatorFilter(params: {
creators: readonly SessionCreatedBy[];
selectedId: string | null;
onChange: (creatorId: string | null) => void;
}) {
if (params.creators.length < 2) {
return nothing;
}
return html`<label class="sidebar-session-creator-filter">
<span>${t("sessionsView.filterByCreator")}</span>
<select
aria-label=${t("sessionsView.filterByCreator")}
.value=${params.selectedId ?? ""}
@change=${(event: Event) =>
params.onChange((event.currentTarget as HTMLSelectElement).value || null)}
>
<option value="">${t("sessionsView.allCreators")}</option>
${params.creators.map(
(creator) => html`<option value=${creator.id}>${creator.label ?? creator.id}</option>`,
)}
</select>
</label>`;
}
function ownerInitials(createdBy: SessionCreatedBy): string {
const source = createdBy.label?.trim() || createdBy.id.trim();
if (!source) {
return "";
}
const parts = source
.replace(/@.*$/u, "")
.split(/[\s._-]+/u)
.filter(Boolean);
const initials = ((parts[0]?.[0] ?? "") + (parts[1]?.[0] ?? "")).toUpperCase();
return initials || source[0]!.toUpperCase();
}
// Deterministic hue per identity so a person keeps one color everywhere.
function ownerHue(id: string): number {
let hash = 0;
for (let i = 0; i < id.length; i += 1) {
hash = (hash * 31 + id.charCodeAt(i)) | 0;
}
return Math.abs(hash) % 360;
}
/**
* Permanent session-owner avatar. Ownership is provenance, not presence:
* this chip is solid and never pulses/expires, in deliberate contrast to the
* translucent, ring-styled live-presence chips. Render only when the gateway
* has 2+ distinct creator identities (solo mode shows no attribution chrome).
*/
class SessionOwnerChip extends OpenClawLightDomElement {
@property({ attribute: false }) createdBy: SessionCreatedBy | null = null;
@property({ type: String }) size: "row" | "header" = "row";
override render() {
const createdBy = this.createdBy;
if (!createdBy) {
return nothing;
}
const initials = ownerInitials(createdBy);
if (!initials) {
return nothing;
}
const title = createdBy.label || createdBy.id;
const accessibleLabel = t("sessionsView.createdBy", { name: title });
return html`
<span
class="session-owner-chip session-owner-chip--${this.size}"
style="--owner-hue: ${ownerHue(createdBy.id)}"
role="img"
aria-label=${accessibleLabel}
title=${accessibleLabel}
>${initials}</span
>
`;
}
}
if (!customElements.get("openclaw-session-owner-chip")) {
customElements.define("openclaw-session-owner-chip", SessionOwnerChip);
}
declare global {
interface HTMLElementTagNameMap {
"openclaw-session-owner-chip": SessionOwnerChip;
}
}
+131
View File
@@ -0,0 +1,131 @@
// Control UI E2E tests cover session ownership dormancy and creator filtering.
import { chromium, type Browser, type Page } from "playwright";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
canRunPlaywrightChromium,
installMockGateway,
resolvePlaywrightChromiumExecutablePath,
startControlUiE2eServer,
type ControlUiE2eServer,
} from "../test-helpers/control-ui-e2e.ts";
const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
let browser: Browser;
let page: Page | undefined;
let server: ControlUiE2eServer | undefined;
function sessionsList(creators: [string, string]) {
const creatorFacet = [
{ id: creators[0], label: "Ada" },
...(creators[1] === creators[0] ? [] : [{ id: creators[1], label: "Bob" }]),
];
return {
count: 2,
creators: creatorFacet,
defaults: { contextTokens: null, model: null, modelProvider: null },
path: "",
sessions: [
{
key: "agent:main:ada",
kind: "direct",
label: "Ada research",
category: "Research",
createdBy: { id: creators[0], label: "Ada" },
updatedAt: 2,
},
{
key: "agent:main:bob",
kind: "direct",
label: "Bob operations",
category: "Operations",
createdBy: { id: creators[1], label: creators[1] === creators[0] ? "Ada" : "Bob" },
updatedAt: 1,
},
],
ts: 1,
};
}
describeControlUiE2e("Control UI session ownership", () => {
beforeAll(async () => {
browser = await chromium.launch({ executablePath: chromiumExecutablePath });
try {
server = await startControlUiE2eServer();
} catch (error) {
await browser.close();
throw error;
}
});
afterEach(async () => {
await page
?.context()
.close()
.catch(() => {});
page = undefined;
});
afterAll(async () => {
await browser?.close().catch(() => {});
await server?.close();
});
it("shows permanent owner chips and filters existing custom groups", async () => {
const context = await browser.newContext({ viewport: { height: 800, width: 1200 } });
const currentPage = await context.newPage();
page = currentPage;
const gateway = await installMockGateway(currentPage, {
sessionKey: "agent:main:ada",
historyMessages: [{ role: "assistant", content: [{ type: "text", text: "Ready." }] }],
methodResponses: { "sessions.list": sessionsList(["profile-ada", "profile-bob"]) },
});
await currentPage.goto(`${server?.baseUrl ?? ""}chat`);
await currentPage.getByText("Ada research", { exact: true }).first().waitFor();
await currentPage.getByText("Bob operations", { exact: true }).first().waitFor();
await currentPage.locator('[data-session-key="agent:main:ada"] a').click();
await currentPage.getByText("Ready.", { exact: true }).waitFor();
await expect.poll(() => currentPage.locator("openclaw-session-owner-chip").count()).toBe(3);
await currentPage.getByLabel("Filter by creator").selectOption("profile-ada");
await currentPage.getByText("Ada research", { exact: true }).first().waitFor();
await expect
.poll(() => currentPage.locator('[data-session-key="agent:main:bob"]').count())
.toBe(0);
expect(await currentPage.locator('[data-session-section="category:Research"]').count()).toBe(1);
expect(await currentPage.locator('[data-session-section="category:Operations"]').count()).toBe(
0,
);
await expect
.poll(async () =>
(await gateway.getRequests("sessions.list")).some(
(request) =>
(request.params as { creatorId?: unknown } | undefined)?.creatorId === "profile-ada",
),
)
.toBe(true);
});
it("renders zero ownership chrome for a single creator", async () => {
const context = await browser.newContext({ viewport: { height: 800, width: 1200 } });
const currentPage = await context.newPage();
page = currentPage;
await installMockGateway(currentPage, {
sessionKey: "agent:main:ada",
historyMessages: [{ role: "assistant", content: [{ type: "text", text: "Ready." }] }],
methodResponses: { "sessions.list": sessionsList(["profile-ada", "profile-ada"]) },
});
await currentPage.goto(`${server?.baseUrl ?? ""}chat`);
await currentPage.getByText("Ada research", { exact: true }).first().waitFor();
await currentPage.getByText("Bob operations", { exact: true }).first().waitFor();
await currentPage.locator('[data-session-key="agent:main:ada"] a').click();
await currentPage.getByText("Ready.", { exact: true }).waitFor();
expect(await currentPage.getByLabel("Filter by creator").count()).toBe(0);
expect(await currentPage.locator("openclaw-session-owner-chip").count()).toBe(0);
});
});
+3
View File
@@ -584,6 +584,9 @@ export const en: TranslationMap = {
active: "Updated within",
limit: "Limit",
filters: "Filters",
createdBy: "Created by {name}",
filterByCreator: "Filter by creator",
allCreators: "All people",
filterControls: "Thread filters",
sourceFilters: "Thread source filters",
global: "Global",
+13
View File
@@ -79,6 +79,7 @@ export type SessionListOptions = {
spawnedBy?: string;
activeMinutes?: number;
search?: string;
creatorId?: string;
offset?: number;
limit?: number;
includeGlobal?: boolean;
@@ -191,6 +192,7 @@ export type SessionCapability = {
/** Advances only when a canonical sessions.list response is published. */
readonly canonicalListRevision: number;
list: (options?: SessionListOptions) => Promise<SessionsListResult | null>;
setCreatorFilter: (creatorId: string | null) => Promise<void>;
reconcile: (
row: GatewaySessionRow | undefined,
defaults?: SessionsListResult["defaults"],
@@ -376,6 +378,7 @@ function buildSessionListParams(options: SessionListOptions = {}): Record<string
const agentId = options.agentId?.trim();
const spawnedBy = options.spawnedBy?.trim();
const search = options.search?.trim();
const creatorId = options.creatorId?.trim();
if (agentId) {
params.agentId = agentId;
}
@@ -385,6 +388,9 @@ function buildSessionListParams(options: SessionListOptions = {}): Record<string
if (search) {
params.search = search;
}
if (creatorId) {
params.creatorId = creatorId;
}
if (typeof options.offset === "number" && options.offset > 0) {
params.offset = Math.floor(options.offset);
}
@@ -1070,6 +1076,12 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
return refresh({ ...options, force: true });
};
const setCreatorFilter = (creatorId: string | null) => {
const options = { ...lastListOptions, creatorId: creatorId?.trim() || undefined };
delete options.offset;
return refresh({ ...options, force: true });
};
const createResult = async (
params: SessionCreateParams = {},
options: { reconciliation?: SessionCreateReconciliation } = {},
@@ -1878,6 +1890,7 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
return canonicalListRevision;
},
list: requestList,
setCreatorFilter,
reconcile,
reconcileChanged,
reconcileRunTerminal,
+39
View File
@@ -43,6 +43,45 @@ test("sessions.changed removes a label when the event carries null", () => {
expect(reconciled.result?.sessions[0]?.displayName).toBeUndefined();
});
test("sessions.changed invalidates the complete creator facet until canonical refresh", () => {
const key = "agent:main:main";
const result = buildResult([
{
key,
kind: "global",
updatedAt: 1,
createdBy: { id: "profile-ada", label: "Ada" },
},
]);
result.creators = [{ id: "profile-ada", label: "Ada" }];
const reconciled = reconcileSessionChanged(result, {
sessionKey: key,
reason: "reset",
updatedAt: 2,
createdBy: { id: "profile-bob", label: "Bob" },
});
expect(reconciled.result?.sessions[0]?.createdBy?.id).toBe("profile-bob");
expect(reconciled.result?.creators).toBeUndefined();
});
test("sessions.changed preserves the creator facet when ownership is unchanged", () => {
const key = "agent:main:main";
const createdBy = { id: "profile-ada", label: "Ada" };
const result = buildResult([{ key, kind: "global", updatedAt: 1, createdBy }]);
result.creators = [createdBy];
const reconciled = reconcileSessionChanged(result, {
sessionKey: key,
reason: "send",
updatedAt: 2,
createdBy,
});
expect(reconciled.result?.creators).toEqual([createdBy]);
});
describe("reconcileSessionChanged", () => {
it("drops a cleared icon from the merged row", () => {
const key = "agent:main:main";
+11 -1
View File
@@ -378,6 +378,9 @@ export function reconcileSessionChanged(
if (rowFields.displayName === null) {
delete row.displayName;
}
if (rowFields.createdBy === null) {
delete row.createdBy;
}
if (rowFields.thinkingLevel === null) {
delete row.thinkingLevel;
}
@@ -395,7 +398,14 @@ export function reconcileSessionChanged(
return { applied: false, result };
}
const eventTs = typeof event.ts === "number" && Number.isFinite(event.ts) ? event.ts : null;
const reconciledResult = eventTs === null ? next : { ...next, ts: Math.max(next.ts, eventTs) };
const timestamped = eventTs === null ? next : { ...next, ts: Math.max(next.ts, eventTs) };
const ownershipChanged =
Object.hasOwn(rowFields, "createdBy") &&
(existing?.createdBy?.id !== row.createdBy?.id ||
existing?.createdBy?.label !== row.createdBy?.label);
// The facet covers unloaded pages, so an ownership event invalidates it until
// the session capability's canonical list refresh supplies a complete replacement.
const reconciledResult = ownershipChanged ? { ...timestamped, creators: undefined } : timestamped;
const reconciledRow = reconciledResult.sessions.find((candidate) =>
matchesExistingSession(
candidate,
+6
View File
@@ -64,6 +64,7 @@ import {
import "../../components/modal-dialog.ts";
import { createDockPanelLayout } from "../../components/dock-panel-layout.ts";
import { icons } from "../../components/icons.ts";
import { listSessionCreators } from "../../components/session-owner-chip.ts";
import { isCloudWorkerPlacementState } from "../../components/session-row-badges.ts";
import { t } from "../../i18n/index.ts";
import { resolveBoardChatLayoutWidth } from "../../lib/board/chat-layout.ts";
@@ -3194,6 +3195,11 @@ class ChatPane extends OpenClawLightDomElement {
mergedChrome: this.mergedChrome,
title: this.paneTitle,
session: row,
showOwnerChip:
(
this.state?.sessionsResult?.creators ??
listSessionCreators(this.state?.sessionsResult?.sessions ?? [])
).length >= 2,
catalog,
editing: this.headerEditing && this.headerRenameSessionKey === row?.key,
renameValue: this.headerRenameValue,
@@ -109,6 +109,20 @@ describe("chat pane header", () => {
expect(props.onBeginRename).toHaveBeenCalledOnce();
});
it("renders the permanent owner chip only when attribution chrome is enabled", () => {
const shown = mount({
showOwnerChip: true,
session: row({ createdBy: { id: "profile-ada", label: "Ada" } }),
});
expect(shown.container.querySelector("openclaw-session-owner-chip")).not.toBeNull();
const dormant = mount({
showOwnerChip: false,
session: row({ createdBy: { id: "profile-ada", label: "Ada" } }),
});
expect(dormant.container.querySelector("openclaw-session-owner-chip")).toBeNull();
});
it("routes Enter and Escape from the rename input", () => {
const enter = mount({ editing: true, renameValue: " Updated " });
const enterInput = enter.container.querySelector<HTMLInputElement>("input");
@@ -7,6 +7,7 @@ import {
type ShellNavDrawerToggleDetail,
} from "../../../components/command-palette-contract.ts";
import { icons } from "../../../components/icons.ts";
import { renderSessionOwnerChip } from "../../../components/session-owner-chip.ts";
import { isCloudWorkerPlacementState } from "../../../components/session-row-badges.ts";
import "../../../components/tooltip.ts";
import "../../../components/web-awesome.ts";
@@ -21,6 +22,7 @@ type ChatPaneHeaderProps = {
mergedChrome: boolean;
title: string;
session: GatewaySessionRow | undefined;
showOwnerChip?: boolean;
catalog: boolean;
editing: boolean;
renameValue: string;
@@ -192,6 +194,10 @@ export function renderChatPaneHeader(props: ChatPaneHeaderProps) {
>
${props.title}
</button>`}
${renderSessionOwnerChip(
props.showOwnerChip ? props.session?.createdBy : undefined,
"header",
)}
${!props.catalog && props.workspaceLabel
? html`
<wa-dropdown
+50
View File
@@ -15,6 +15,56 @@
animation: fade-in 0.3s var(--ease-out) 0.2s forwards;
}
openclaw-session-owner-chip {
display: inline-flex;
flex: 0 0 auto;
}
.session-owner-chip {
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid hsl(var(--owner-hue) 52% 18% / 0.9);
border-radius: 999px;
background: hsl(var(--owner-hue) 58% 26%);
box-shadow: 0 1px 2px rgb(0 0 0 / 0.24);
color: white;
font-weight: 700;
line-height: 1;
}
.session-owner-chip--row {
width: 16px;
height: 16px;
font-size: 8px;
}
.session-owner-chip--header {
width: 20px;
height: 20px;
font-size: 10px;
}
.sidebar-session-creator-filter {
display: flex;
align-items: center;
gap: 8px;
padding: 2px 8px 6px;
color: var(--muted);
font-size: 10px;
}
.sidebar-session-creator-filter select {
min-width: 0;
flex: 1 1 auto;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg);
color: var(--text);
font: inherit;
padding: 3px 22px 3px 6px;
}
.connect-splash__logo {
width: 44px;
height: 44px;
@@ -0,0 +1,258 @@
import { describe, expect, it } from "vitest";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import { createGateway, createSessionsHarness, mountSidebar } from "../app-sidebar.ts";
import "../../components/app-sidebar.ts";
describe("AppSidebar session ownership", () => {
it("uses the complete facet and requests unloaded creators from the Gateway", async () => {
const gateway = createGateway({} as GatewayBrowserClient);
const harness = createSessionsHarness("main", ["agent:main:main", "agent:main:ada"]);
const result = harness.sessions.state.result;
if (!result) {
throw new Error("expected session list");
}
const ada = result.sessions.find((row) => row.key.endsWith(":ada"));
if (!ada) {
throw new Error("expected creator row");
}
ada.createdBy = { id: "profile-ada", label: "Ada" };
result.creators = [
{ id: "profile-ada", label: "Ada" },
{ id: "profile-bob", label: "Bob" },
];
const { sidebar } = await mountSidebar(gateway, harness.sessions);
harness.publishList({ result, agentId: "main" });
await sidebar.updateComplete;
expect(sidebar.sessionsResult?.creators).toHaveLength(2);
expect(sidebar.querySelector('[data-session-key="agent:main:ada"]')).not.toBeNull();
expect(sidebar.querySelectorAll("openclaw-session-owner-chip")).toHaveLength(1);
const select = sidebar.querySelector<HTMLSelectElement>(
'.sidebar-session-creator-filter select[aria-label="Filter by creator"]',
);
select!.value = "profile-bob";
select!.dispatchEvent(new Event("change", { bubbles: true }));
await sidebar.updateComplete;
expect(harness.setCreatorFilter).toHaveBeenCalledWith("profile-bob");
result.creators = [{ id: "profile-bob", label: "Bob" }];
harness.publishList({ result, agentId: "main" });
await sidebar.updateComplete;
await sidebar.updateComplete;
expect(harness.setCreatorFilter).toHaveBeenLastCalledWith(null);
});
it("renders no ownership chrome when the listed sessions have fewer than two creators", async () => {
const gateway = createGateway({} as GatewayBrowserClient);
const harness = createSessionsHarness("main", [
"agent:main:main",
"agent:main:a",
"agent:main:b",
]);
const result = harness.sessions.state.result;
if (!result) {
throw new Error("expected session list");
}
for (const row of result.sessions) {
row.createdBy = { id: "profile-ada", label: "Ada" };
}
const { sidebar } = await mountSidebar(gateway, harness.sessions);
harness.publishList({ result, agentId: "main" });
await sidebar.updateComplete;
expect(sidebar.querySelector(".sidebar-session-creator-filter")).toBeNull();
expect(sidebar.querySelector("openclaw-session-owner-chip")).toBeNull();
});
it("filters by creator and hides custom groups without matching sessions", async () => {
const gateway = createGateway({} as GatewayBrowserClient);
const harness = createSessionsHarness("main", [
"agent:main:main",
"agent:main:ada",
"agent:main:bob",
]);
const result = harness.sessions.state.result;
if (!result) {
throw new Error("expected session list");
}
const ada = result.sessions.find((row) => row.key.endsWith(":ada"));
const bob = result.sessions.find((row) => row.key.endsWith(":bob"));
if (!ada || !bob) {
throw new Error("expected creator rows");
}
ada.createdBy = { id: "profile-ada", label: "Ada" };
ada.category = "Research";
bob.createdBy = { id: "profile-bob", label: "Bob" };
bob.category = "Operations";
harness.publish({ groups: ["Research", "Operations"] });
const { sidebar } = await mountSidebar(gateway, harness.sessions);
harness.publishList({ result, agentId: "main" });
await sidebar.updateComplete;
const select = sidebar.querySelector<HTMLSelectElement>(
'.sidebar-session-creator-filter select[aria-label="Filter by creator"]',
);
expect(select).not.toBeNull();
expect(sidebar.querySelectorAll("openclaw-session-owner-chip")).toHaveLength(2);
select!.value = "profile-ada";
select!.dispatchEvent(new Event("change", { bubbles: true }));
await sidebar.updateComplete;
expect(sidebar.querySelector('[data-session-key="agent:main:ada"]')).not.toBeNull();
expect(sidebar.querySelector('[data-session-key="agent:main:bob"]')).toBeNull();
expect(sidebar.querySelector('[data-session-section="category:Research"]')).not.toBeNull();
expect(sidebar.querySelector('[data-session-section="category:Operations"]')).toBeNull();
});
it("filters catalog rows by authoritative creator ownership", async () => {
const gateway = createGateway({} as GatewayBrowserClient);
const backingSessionKey = "agent:main:claude-bound";
const harness = createSessionsHarness("main", [
"agent:main:main",
"agent:main:ada",
backingSessionKey,
]);
const result = harness.sessions.state.result;
if (!result) {
throw new Error("expected session list");
}
const ada = result.sessions.find((row) => row.key.endsWith(":ada"));
const adopted = result.sessions.find((row) => row.key === backingSessionKey);
if (!ada || !adopted) {
throw new Error("expected ownership rows");
}
ada.createdBy = { id: "profile-ada", label: "Ada" };
adopted.createdBy = { id: "profile-bob", label: "Bob" };
result.creators = [
{ id: "profile-ada", label: "Ada" },
{ id: "profile-bob", label: "Bob" },
];
const { sidebar } = await mountSidebar(gateway, harness.sessions);
sidebar.sessionCatalogs = [
{
id: "claude",
label: "Claude Code",
capabilities: { continueSession: true, archive: false },
hosts: [
{
hostId: "gateway:local",
label: "Local Claude",
kind: "gateway",
connected: true,
sessions: [
{
threadId: "claude-thread",
name: "Claude session",
status: "stored",
archived: false,
sessionKey: backingSessionKey,
createdBy: { id: "profile-bob", label: "Bob" },
canContinue: true,
canArchive: false,
},
{
threadId: "external-thread",
name: "External unowned session",
status: "stored",
archived: false,
canContinue: true,
canArchive: false,
},
],
},
],
},
];
harness.publishList({ result, agentId: "main" });
await sidebar.updateComplete;
expect(sidebar.querySelector(`[data-session-key="${backingSessionKey}"]`)).not.toBeNull();
expect(sidebar.textContent).toContain("External unowned session");
const select = sidebar.querySelector<HTMLSelectElement>(
'.sidebar-session-creator-filter select[aria-label="Filter by creator"]',
);
select!.value = "profile-ada";
select!.dispatchEvent(new Event("change", { bubbles: true }));
await sidebar.updateComplete;
expect(sidebar.querySelector(`[data-session-key="${backingSessionKey}"]`)).toBeNull();
expect(sidebar.textContent).not.toContain("External unowned session");
harness.publishList({
result: { ...result, count: 1, sessions: [ada] },
agentId: "main",
});
await sidebar.updateComplete;
expect(sidebar.querySelector(`[data-session-key="${backingSessionKey}"]`)).toBeNull();
expect(sidebar.textContent).not.toContain("External unowned session");
});
it("keeps catalog rows whose backing ownership is outside the loaded page", async () => {
const gateway = createGateway({} as GatewayBrowserClient);
const harness = createSessionsHarness("main", [
"agent:main:main",
"agent:main:ada",
"agent:main:bob",
]);
const result = harness.sessions.state.result;
if (!result) {
throw new Error("expected session list");
}
const ada = result.sessions.find((row) => row.key.endsWith(":ada"));
const bob = result.sessions.find((row) => row.key.endsWith(":bob"));
if (!ada || !bob) {
throw new Error("expected creator rows");
}
ada.createdBy = { id: "profile-ada", label: "Ada" };
bob.createdBy = { id: "profile-bob", label: "Bob" };
result.creators = [
{ id: "profile-ada", label: "Ada" },
{ id: "profile-bob", label: "Bob" },
];
const unloadedSessionKey = "agent:main:beyond-loaded-page";
const { sidebar } = await mountSidebar(gateway, harness.sessions);
sidebar.sessionCatalogs = [
{
id: "claude",
label: "Claude Code",
capabilities: { continueSession: true, archive: false },
hosts: [
{
hostId: "gateway:local",
label: "Local Claude",
kind: "gateway",
connected: true,
sessions: [
{
threadId: "unloaded-thread",
name: "Unloaded backing session",
status: "stored",
archived: false,
sessionKey: unloadedSessionKey,
createdBy: { id: "profile-ada", label: "Ada" },
canContinue: true,
canArchive: false,
},
],
},
],
},
];
harness.publishList({ result, agentId: "main" });
await sidebar.updateComplete;
const select = sidebar.querySelector<HTMLSelectElement>(
'.sidebar-session-creator-filter select[aria-label="Filter by creator"]',
);
select!.value = "profile-ada";
select!.dispatchEvent(new Event("change", { bubbles: true }));
await sidebar.updateComplete;
expect(sidebar.querySelector(`[data-session-key="${unloadedSessionKey}"]`)).not.toBeNull();
});
});
+3
View File
@@ -203,6 +203,7 @@ export function createSessionsHarness(agentId: string, keys: string[]) {
);
const refresh = vi.fn(() => Promise.resolve());
const refreshReplacement = vi.fn(() => Promise.resolve());
const setCreatorFilter = vi.fn(() => Promise.resolve());
const subscribeMessages = vi.fn((key: string, options?: { agentId?: string | null }) =>
Promise.resolve({ key, agentId: options?.agentId ?? null }),
);
@@ -244,6 +245,7 @@ export function createSessionsHarness(agentId: string, keys: string[]) {
delete: deleteSession,
deleteMany,
list,
setCreatorFilter,
refresh,
refreshReplacement,
subscribeMessages,
@@ -265,6 +267,7 @@ export function createSessionsHarness(agentId: string, keys: string[]) {
deleteSession,
deleteMany,
list,
setCreatorFilter,
refresh,
refreshReplacement,
subscribeMessages,