diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index e66dd529a623..57a0fb041c9a 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -1530,6 +1530,8 @@ public struct PresenceEntry: Codable, Sendable { public let tags: [String]? public let text: String? public let ts: Int + public let onlinesince: Int? + public let lastactivityat: Int? public let deviceid: String? public let roles: [String]? public let scopes: [String]? @@ -1551,6 +1553,8 @@ public struct PresenceEntry: Codable, Sendable { tags: [String]? = nil, text: String? = nil, ts: Int, + onlinesince: Int? = nil, + lastactivityat: Int? = nil, deviceid: String? = nil, roles: [String]? = nil, scopes: [String]? = nil, @@ -1571,6 +1575,8 @@ public struct PresenceEntry: Codable, Sendable { self.tags = tags self.text = text self.ts = ts + self.onlinesince = onlinesince + self.lastactivityat = lastactivityat self.deviceid = deviceid self.roles = roles self.scopes = scopes @@ -1593,6 +1599,8 @@ public struct PresenceEntry: Codable, Sendable { case tags case text case ts + case onlinesince = "onlineSince" + case lastactivityat = "lastActivityAt" case deviceid = "deviceId" case roles case scopes diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index f5c65e59ba54..8a0023442f6a 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -1960,7 +1960,6 @@ src/agents/prepared-model-catalog.ts 1 src/agents/prepared-model-catalog.worker.ts 1 src/agents/prepared-model-runtime.configured.ts 2 src/agents/prepared-model-runtime.facts.ts 1 -src/agents/prepared-model-runtime.plugin-context.ts 2 src/agents/prepared-model-runtime.ts 1 src/agents/provider-attribution.ts 14 src/agents/provider-auth-aliases.ts 2 @@ -3033,7 +3032,7 @@ src/gateway/server.auth.control-ui.mobile-bootstrap.suite.ts 7 src/gateway/server.auth.control-ui.owner-bootstrap.suite.ts 5 src/gateway/server.auth.control-ui.pairing.suite.ts 6 src/gateway/server.auth.control-ui.trusted-proxy.suite.ts 5 -src/gateway/server.auth.default-token.suite.ts 11 +src/gateway/server.auth.default-token.suite.ts 10 src/gateway/server.auth.modes.suite.ts 1 src/gateway/server/hooks-request-handler.ts 4 src/gateway/server/http-listen.ts 1 diff --git a/docs/concepts/multi-user.md b/docs/concepts/multi-user.md index 6be639fca996..03a3694b814f 100644 --- a/docs/concepts/multi-user.md +++ b/docs/concepts/multi-user.md @@ -60,6 +60,18 @@ When several people watch the same session, the transcript also shows a live typ When the loaded session list contains fewer than two distinct owner identities and no session has recorded outside participants, OpenClaw hides all ownership and owner-filter chrome. A single-user gateway therefore looks unchanged. +## People cards + +Hover or focus a person in the sidebar's **Online** section to open their information card. The details button also opens the card on touch devices. Selecting the person's name or **View activity** opens their Activity page. + +The card shows how long the person has been continuously connected, their reported app/device context and time zone, and their last observed activity during that online period. Opening a different session, typing, and sending a new message count as activity; connection heartbeats and agent responses do not. **Not observed yet** means no qualifying activity has been recorded, not that the person is inactive. These timing facts are ephemeral and reset after the person's final connection closes or the Gateway restarts. + +People presence is shared with operators who have read access (`operator.read`, also implied by `operator.write` or `operator.admin`). Those readers may see other people's online and activity timing and reported time zone whether or not the person is watching a session. Node and pairing-only connections receive neither the presence inventory nor its activity-driven events. This does not change cross-reader IP visibility or provide isolation for all Gateway metadata; see [Who can see presence](/concepts/presence#who-can-see-presence). + +**Viewing now** and **Recent sessions** link only to sessions available in your loaded session list. Recent sessions reflect reliable ownership or creation attribution, not a complete history of the person's contributions. Session update times describe the session, not when that person last acted. Connection descriptions and time zones are client-reported hints, not verified physical locations. + +The Gateway also filters watched-session references for each recipient using `sessions.list` visibility rules, across connect snapshots, presence RPC responses, and events. Hidden or missing references are omitted without counts or placeholders; opening someone's card never borrows that person's session access. + ## Agent-spawned sessions Sessions an agent creates with `sessions_spawn` (`visible: true`) are attributed to the requesting agent: the creator and initial owner is the agent itself, and the sidebar shows the agent's configured identity name and avatar rather than an internal session key. diff --git a/docs/concepts/presence.md b/docs/concepts/presence.md index 8a4538f04ee9..604cfb353f91 100644 --- a/docs/concepts/presence.md +++ b/docs/concepts/presence.md @@ -33,33 +33,38 @@ Presence entries are structured objects with fields like: - `lastInputSeconds`: seconds since last user input, if known - `reason`: free-form client-supplied string; the Gateway itself only emits `self`, `connect`, and `disconnect` - `deviceId`, `roles`, `scopes`: device identity and role/scope hints from the connect handshake -- `ts`: last update timestamp (ms since epoch) +- `ts`: last presence update timestamp (ms since epoch), including heartbeat updates; not a user-activity timestamp +- `onlineSince`: start of an authenticated person's current continuous online period, shared across overlapping connections +- `lastActivityAt`: latest observed accepted interaction during that online period; absent until activity is observed - `watchedSessions`: session keys the client explicitly declares it is viewing, filtered for the recipient -### Watched session references +## Who can see presence -Hello snapshots, `system-presence`, and `presence` events include only watched -session references that the recipient can see under the current session-list -visibility rules. Drafts, incognito sessions, and operator role restrictions -follow those same rules. Missing or deleted sessions are omitted, including for -admins. Keys retain their agent scope, including agent-qualified `global` and -`unknown` references. +The presence roster is shared with operators who have `operator.read` access; +`operator.write` and `operator.admin` also grant read access. Readers can see other +people's online and activity timing and reported `timeZone`, including people who +are not watching a session. Node connections, pairing-only operators, and other +connections without read access receive an empty presence roster in the connect +snapshot and no `presence` events. The `system-presence` RPC requires the same +operator read access. -Session references require an operator connection with read access -(`operator.read`, `operator.write`, or `operator.admin`). Nodes, pairing-only -clients, and non-admin clients awaiting authenticated profile verification still -receive the non-session presence roster in hello snapshots and events, without -watched references. An established admin grant does not depend on profile -verification. `system-presence` itself requires operator read access. +Watched-session references are filtered separately for each recipient using the +same visibility rules as `sessions.list`. Hidden or missing sessions are omitted +entirely, without counts or placeholders. This filtering applies to connect +snapshots, `system-presence` responses, and presence events; the person being +viewed does not grant the recipient access to their sessions. -Filtering preserves the connection and person metadata and the presence timestamp. -When no references are visible, `watchedSessions` is omitted, just as when the -client declares no watches; there are no hidden-session counts or markers. +Drafts, incognito sessions, and operator role restrictions follow those list +rules. Missing or deleted references are omitted even for admins. Keys retain +their agent scope, including agent-qualified `global` and `unknown` references. +Non-admin readers awaiting authenticated profile verification receive person +metadata but no watched references; established admin grants retain admin list +visibility. When no references are visible, `watchedSessions` is omitted. Message subscriptions alone do not declare viewer presence. -These are coordination features within a shared agent, not isolation between -mutually untrusted users. Everyone operating an agent shares that agent's -capabilities. See [Multi-user trust boundary](/concepts/multi-user#trust-boundary). +This policy does not change which IP addresses are shared between readers and +does not isolate all Gateway metadata. Use separate Gateway trust boundaries +when readers must not see each other's presence or other shared metadata. ## Producers (where presence comes from) @@ -111,6 +116,10 @@ by parsed host or other beacon metadata. A stable `instanceId` helps consumers associate rows with the same client; it does not merge separate user WebSocket connections. Ephemeral control-plane clients are excluded from tracking entirely. +The Control UI groups connection rows by authenticated identity when displaying +people. The [people card](/concepts/multi-user#people-cards) keeps online duration +and observed activity separate from each entry's heartbeat freshness. + ## TTL and bounded size Presence is intentionally ephemeral: diff --git a/packages/gateway-protocol/src/schema/snapshot.test.ts b/packages/gateway-protocol/src/schema/snapshot.test.ts index 31d6c5b53580..77ebcb695bb9 100644 --- a/packages/gateway-protocol/src/schema/snapshot.test.ts +++ b/packages/gateway-protocol/src/schema/snapshot.test.ts @@ -19,6 +19,8 @@ describe("SnapshotSchema", () => { SnapshotSchema, snapshotWithPresence({ ts: 1, + onlineSince: 0, + lastActivityAt: 1, user: { id: "alice@example.com", email: "alice@example.com" }, }), ), @@ -29,6 +31,14 @@ describe("SnapshotSchema", () => { expect(Value.Check(SnapshotSchema, snapshotWithPresence({ ts: 1 }))).toBe(true); }); + it.each(["onlineSince", "lastActivityAt"])("rejects non-millisecond %s values", (field) => { + for (const value of [-1, 1.5, "1000", null]) { + expect(Value.Check(SnapshotSchema, snapshotWithPresence({ ts: 1, [field]: value }))).toBe( + false, + ); + } + }); + it("accepts optional watched session keys", () => { expect( Value.Check( diff --git a/packages/gateway-protocol/src/schema/snapshot.ts b/packages/gateway-protocol/src/schema/snapshot.ts index ee0428c4bbf2..c61196ff5368 100644 --- a/packages/gateway-protocol/src/schema/snapshot.ts +++ b/packages/gateway-protocol/src/schema/snapshot.ts @@ -26,21 +26,25 @@ export const PresenceEntrySchema = closedObject({ reason: Type.Optional(NonEmptyString), tags: Type.Optional(Type.Array(NonEmptyString)), text: Type.Optional(Type.String()), + /** Heartbeat freshness, not online duration or user activity. */ ts: Type.Integer({ minimum: 0 }), + /** Server timestamps for the person's continuous online interval and last accepted activity. */ + onlineSince: Type.Optional(Type.Integer({ minimum: 0 })), + lastActivityAt: Type.Optional(Type.Integer({ minimum: 0 })), deviceId: Type.Optional(NonEmptyString), roles: Type.Optional(Type.Array(NonEmptyString)), scopes: Type.Optional(Type.Array(NonEmptyString)), instanceId: Type.Optional(NonEmptyString), user: Type.Optional( closedObject({ - /** Opaque identity key: authenticated email today, durable profile id later. Clients group presence by this. */ + /** Canonical profile id when resolved, otherwise authenticated identity. Clients group presence by this. */ id: NonEmptyString, email: Type.Optional(NonEmptyString), name: Type.Optional(NonEmptyString), avatarUrl: Type.Optional(NonEmptyString), }), ), - /** Session keys this connection is actively subscribed to (watching). Sorted lexicographically for deterministic snapshots. */ + /** Sessions this connection declares it is viewing, independent of transport subscriptions. Sorted lexicographically. */ watchedSessions: Type.Optional(Type.Array(NonEmptyString)), }); diff --git a/scripts/control-ui-mock-dev.ts b/scripts/control-ui-mock-dev.ts index 6680e82389a6..69dc3d5e7da0 100644 --- a/scripts/control-ui-mock-dev.ts +++ b/scripts/control-ui-mock-dev.ts @@ -1698,7 +1698,8 @@ async function createChatPickerScenario( ] : []; const workboardMocks = buildWorkboardMocks(baseTime); - const activitySessions = buildActivitySessionRows(Date.now()); + const activityTime = Date.now(); + const activitySessions = buildActivitySessionRows(activityTime); const sessions = [ ...activitySessions, ...(fixture === "workboard" @@ -2059,8 +2060,38 @@ async function createChatPickerScenario( name: selfProfile.displayName ?? undefined, email: selfProfile.emails[0], }, - { id: "presence-colin", name: "Colin", email: "colin@example.com" }, - { id: "presence-patricia", email: "patricia.erichsen@example.com" }, + { + id: "presence-colin", + name: "Colin", + email: "colin@example.com", + onlineSince: activityTime - 47 * 60_000, + lastActivityAt: activityTime - 2 * 60_000, + deviceFamily: "Mac", + platform: "macOS", + timeZone: "America/Los_Angeles", + watchedSessions: ["agent:activity:design-review", "agent:main:main"], + }, + { + id: "presence-colin", + name: "Colin", + email: "colin@example.com", + onlineSince: activityTime - 47 * 60_000, + lastActivityAt: activityTime - 2 * 60_000, + deviceFamily: "Mac", + platform: "macOS", + timeZone: "America/Los_Angeles", + watchedSessions: ["agent:activity:design-review"], + }, + { + id: "presence-patricia", + email: "patricia.erichsen@example.com", + onlineSince: activityTime - 12 * 60_000, + lastActivityAt: activityTime - 30_000, + deviceFamily: "iPhone", + platform: "iOS", + timeZone: "Europe/Stockholm", + watchedSessions: ["agent:activity:support-handoff"], + }, ], methodResponses: { ...buildBackgroundTasksMock(baseTime), diff --git a/src/agents/openclaw-plugin-tools.ts b/src/agents/openclaw-plugin-tools.ts index 0bd5de21414e..5308a4a271d8 100644 --- a/src/agents/openclaw-plugin-tools.ts +++ b/src/agents/openclaw-plugin-tools.ts @@ -15,6 +15,7 @@ import { getPluginRuntimeGatewayRequestScope, withPluginRuntimeRegistryScope, } from "../plugins/runtime/gateway-request-scope.js"; +import { getPluginRuntimeLoadContext } from "../plugins/runtime/load-context.js"; import type { OpenClawPluginToolDelivery } from "../plugins/tool-types.js"; import { resolvePluginTools } from "../plugins/tools.js"; import type { OpenClawPluginToolContext } from "../plugins/types.js"; @@ -31,7 +32,6 @@ import { resolveOpenClawPluginToolInputs, type OpenClawPluginToolOptions, } from "./openclaw-tools.plugin-context.js"; -import { getPreparedPluginRuntimeLoadContext } from "./prepared-model-runtime.plugin-context.js"; import type { PreparedModelRuntimeSnapshot } from "./prepared-model-runtime.types.js"; import { resolveAgentRuntimeToolConfig } from "./tool-runtime-config.js"; import type { AnyAgentTool } from "./tools/common.js"; @@ -307,7 +307,7 @@ export function resolveOpenClawPluginToolsForOptions(params: { ...(preparedModelRuntime ? { preparedRuntime: { - loadContext: getPreparedPluginRuntimeLoadContext(preparedModelRuntime.pluginRegistry), + loadContext: getPluginRuntimeLoadContext(preparedModelRuntime.pluginRegistry), metadataSnapshot: preparedModelRuntime.metadataSnapshot, registry: preparedModelRuntime.pluginRegistry, }, diff --git a/src/agents/openclaw-tools.browser-plugin.integration.test.ts b/src/agents/openclaw-tools.browser-plugin.integration.test.ts index 09cbbed7372e..d7eabba2c371 100644 --- a/src/agents/openclaw-tools.browser-plugin.integration.test.ts +++ b/src/agents/openclaw-tools.browser-plugin.integration.test.ts @@ -19,15 +19,13 @@ import { loadWebMediaRaw } from "../media/web-media.js"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js"; import { withPluginRuntimeRegistryScope } from "../plugins/runtime/gateway-request-scope.js"; +import { getPluginRuntimeLoadContext } from "../plugins/runtime/load-context.js"; import { activateSecretsRuntimeSnapshot, clearSecretsRuntimeSnapshot } from "../secrets/runtime.js"; import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js"; import { getRuntimeAuthProfileStoreCredentialsRevision } from "./auth-profiles/runtime-snapshots.js"; import { resolveOpenClawPluginToolsForOptions } from "./openclaw-plugin-tools.js"; import { createOpenClawTools } from "./openclaw-tools.js"; -import { - getPreparedPluginRuntimeLoadContext, - prepareOwnedPluginLoadContext, -} from "./prepared-model-runtime.plugin-context.js"; +import { prepareOwnedPluginLoadContext } from "./prepared-model-runtime.plugin-context.js"; import { jsonResult } from "./tools/common.js"; const hoisted = vi.hoisted(() => ({ @@ -491,7 +489,7 @@ describe("createOpenClawTools browser plugin integration", () => { metadataSnapshot, ), ).toBe(metadataSnapshot); - const loadContext = getPreparedPluginRuntimeLoadContext(pluginRegistry); + const loadContext = getPluginRuntimeLoadContext(pluginRegistry); if (!loadContext) { throw new Error("expected prepared plugin load context"); } diff --git a/src/agents/prepared-model-runtime.inbound-registry.test.ts b/src/agents/prepared-model-runtime.inbound-registry.test.ts index 3e39c2d66a1a..32c00eda3618 100644 --- a/src/agents/prepared-model-runtime.inbound-registry.test.ts +++ b/src/agents/prepared-model-runtime.inbound-registry.test.ts @@ -10,6 +10,7 @@ import { createDeferred } from "../../test/helpers/promise.js"; import { retainLegacyDefaultAgentId } from "../config/legacy.default-agent-owner.js"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; import { getPluginRuntimeGenerationRegistry } from "../plugins/runtime/generation-scope.js"; +import { getPluginRuntimeLoadContext } from "../plugins/runtime/load-context.js"; import { acquireAgentRunPreparedModelRuntime, getPreparedModelRuntimeSnapshot, @@ -17,7 +18,6 @@ import { registerPreparedModelRuntimePublicationListener, refreshPreparedModelRuntimeSnapshots, } from "./prepared-model-runtime.js"; -import { getPreparedPluginRuntimeLoadContext } from "./prepared-model-runtime.plugin-context.js"; const mocks = getPreparedModelRuntimeMocks(); @@ -197,7 +197,7 @@ describe("prepared reply dispatch runtime", () => { pluginGeneration: configuredRuntimeBefore.pluginGeneration, }); const dynamicSelectedBefore = dynamicLease.snapshot.pluginRegistry; - expect(getPreparedPluginRuntimeLoadContext(dynamicSelectedBefore)).toMatchObject({ + expect(getPluginRuntimeLoadContext(dynamicSelectedBefore)).toMatchObject({ preferBuiltPluginArtifacts: true, }); dynamicLease.release(); diff --git a/src/agents/prepared-model-runtime.plugin-context.test.ts b/src/agents/prepared-model-runtime.plugin-context.test.ts index 721c4dee9b89..a8776e5fc59e 100644 --- a/src/agents/prepared-model-runtime.plugin-context.test.ts +++ b/src/agents/prepared-model-runtime.plugin-context.test.ts @@ -7,10 +7,8 @@ import * as currentPluginMetadata from "../plugins/current-plugin-metadata-snaps import { clearPluginMetadataLifecycleCaches } from "../plugins/plugin-metadata-lifecycle.js"; import * as pluginMetadata from "../plugins/plugin-metadata-snapshot.js"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; -import { - getPreparedPluginRuntimeLoadContext, - prepareOwnedPluginLoadContext, -} from "./prepared-model-runtime.plugin-context.js"; +import { getPluginRuntimeLoadContext } from "../plugins/runtime/load-context.js"; +import { prepareOwnedPluginLoadContext } from "./prepared-model-runtime.plugin-context.js"; import { withPreparedPluginGenerationScope } from "./prepared-model-runtime.plugin-generation.js"; describe("prepared model runtime plugin metadata ownership", () => { @@ -45,7 +43,7 @@ describe("prepared model runtime plugin metadata ownership", () => { expect( prepareOwnedPluginLoadContext(input, process.env, registry, gatewaySnapshot, true), ).toBe(gatewaySnapshot); - expect(getPreparedPluginRuntimeLoadContext(registry)).toMatchObject({ + expect(getPluginRuntimeLoadContext(registry)).toMatchObject({ metadataSnapshot: gatewaySnapshot, preferBuiltPluginArtifacts: true, }); @@ -86,7 +84,7 @@ describe("prepared model runtime plugin metadata ownership", () => { registry, ), ).toBe(directSnapshot); - expect(getPreparedPluginRuntimeLoadContext(registry)).toMatchObject({ + expect(getPluginRuntimeLoadContext(registry)).toMatchObject({ metadataSnapshot: directSnapshot, preferBuiltPluginArtifacts: false, }); diff --git a/src/agents/prepared-model-runtime.plugin-context.ts b/src/agents/prepared-model-runtime.plugin-context.ts index ee5d1a85de2d..b74e8813e622 100644 --- a/src/agents/prepared-model-runtime.plugin-context.ts +++ b/src/agents/prepared-model-runtime.plugin-context.ts @@ -5,25 +5,14 @@ import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot import type { PluginRegistry } from "../plugins/registry-types.js"; import { resolvePluginRuntimeLoadContext, + setPluginRuntimeLoadContext, type PluginRuntimeLoadContext, } from "../plugins/runtime/load-context.js"; import { createAgentRuntimeMetadataPluginIdScope } from "./harness/runtime-plugin-load-plan.js"; import type { PreparedModelRuntimeInput } from "./prepared-model-runtime.types.js"; -const preparedPluginRuntimeLoadContext = Symbol("preparedPluginRuntimeLoadContext"); const emptyPluginDiscovery: PluginDiscoveryResult = { candidates: [], diagnostics: [] }; -type PreparedPluginRegistry = PluginRegistry & { - [preparedPluginRuntimeLoadContext]?: PluginRuntimeLoadContext; -}; - -function setPreparedPluginRuntimeLoadContext( - registry: PluginRegistry, - context: PluginRuntimeLoadContext, -): void { - (registry as PreparedPluginRegistry)[preparedPluginRuntimeLoadContext] = context; -} - function preparePluginLoadContext( input: PreparedModelRuntimeInput, env: NodeJS.ProcessEnv, @@ -52,7 +41,7 @@ function preparePluginLoadContext( }; if (registry) { // The prepared registry is the lifecycle-owned carrier; standalone callers keep the cold path. - setPreparedPluginRuntimeLoadContext(registry, context); + setPluginRuntimeLoadContext(registry, context); } return context; } @@ -94,9 +83,3 @@ function resolveColdMetadataSnapshot( }); return resolvedMetadataSnapshot; } - -/** Reads plugin facts carried by a lifecycle-owned prepared runtime snapshot. */ -export const getPreparedPluginRuntimeLoadContext = ( - registry: PluginRegistry | undefined, -): PluginRuntimeLoadContext | undefined => - (registry as PreparedPluginRegistry | undefined)?.[preparedPluginRuntimeLoadContext]; diff --git a/src/agents/prepared-model-runtime.test.ts b/src/agents/prepared-model-runtime.test.ts index 9d0c14bdd6d2..400cc8cfde9f 100644 --- a/src/agents/prepared-model-runtime.test.ts +++ b/src/agents/prepared-model-runtime.test.ts @@ -8,6 +8,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; import { requireActivePluginRegistry } from "../plugins/runtime.js"; +import { getPluginRuntimeLoadContext } from "../plugins/runtime/load-context.js"; import { getPreparedModelRuntimeAuthStore } from "./prepared-model-runtime-auth.js"; import { startSerializedSnapshotBuild } from "./prepared-model-runtime.build.js"; import { prepareWorkspacePluginRegistries } from "./prepared-model-runtime.inbound-registry.js"; @@ -23,7 +24,6 @@ import { rejectPendingPreparedModelRuntimeReplacement, refreshPreparedModelRuntimeSnapshots, } from "./prepared-model-runtime.js"; -import { getPreparedPluginRuntimeLoadContext } from "./prepared-model-runtime.plugin-context.js"; const mocks = getPreparedModelRuntimeMocks(); @@ -294,7 +294,7 @@ describe("prepared model runtime snapshots", () => { env, }); - expect(getPreparedPluginRuntimeLoadContext(snapshot.pluginRegistry)).toMatchObject({ + expect(getPluginRuntimeLoadContext(snapshot.pluginRegistry)).toMatchObject({ rawConfig: config, env, }); diff --git a/src/gateway/gateway-misc.test.ts b/src/gateway/gateway-misc.test.ts index 7c8ce8788c2c..07facb75b525 100644 --- a/src/gateway/gateway-misc.test.ts +++ b/src/gateway/gateway-misc.test.ts @@ -748,20 +748,12 @@ describe("gateway broadcaster", () => { expectSentEvents(pairingSocket, [ "heartbeat", - "presence", - "health", - "tick", - "shutdown", - "update.available", - ]); - expectSentEvents(nodeSocket, [ - "heartbeat", - "presence", "health", "tick", "shutdown", "update.available", ]); + expectSentEvents(nodeSocket, ["heartbeat", "health", "tick", "shutdown", "update.available"]); expectSentEvents(readSocket, [ "cron", "voicewake.changed", @@ -776,7 +768,6 @@ describe("gateway broadcaster", () => { expectSentEvents(talkSocket, [ "talk.mode", "heartbeat", - "presence", "health", "tick", "shutdown", @@ -807,9 +798,14 @@ describe("gateway broadcaster", () => { readSocket, ); - const { broadcast } = createGatewayBroadcaster({ clients }); + const { broadcast, broadcastToConnIds } = createGatewayBroadcaster({ + clients, + preparePresenceProjection: (presence) => () => presence, + }); broadcast("chat", chatPayload()); + broadcast("presence", { presence: [] }); + broadcastToConnIds("presence", { presence: [] }, new Set(["c-pairing", "c-read"])); broadcast("heartbeat", { ts: 1 }); broadcast("chat.side_result", chatSideResultPayload()); broadcast("tick", { ts: 2 }); @@ -820,9 +816,11 @@ describe("gateway broadcaster", () => { ]); expect(sentEventSeq(readSocket)).toEqual([ ["chat", 1], - ["heartbeat", 2], - ["chat.side_result", 3], - ["tick", 4], + ["presence", 2], + ["presence", 3], + ["heartbeat", 4], + ["chat.side_result", 5], + ["tick", 6], ]); }); diff --git a/src/gateway/presence-projection.ts b/src/gateway/presence-projection.ts index 20fc04b706ba..6d5f0414d4e6 100644 --- a/src/gateway/presence-projection.ts +++ b/src/gateway/presence-projection.ts @@ -44,20 +44,26 @@ export function createPresenceRecipientProjection(params: { return targets.get(sessionKey); }; return (client) => { - const canRead = - client?.connect != null && - (client.connect.role ?? "operator") === "operator" && - authorizeOperatorScopesForRequiredScope(READ_SCOPE, client.connect.scopes ?? []).allowed && + // Match system-presence RPC access before projecting any rows: even idle + // people expose timing through ts and roster ordering, not just named fields. + if ( + !client?.connect || + (client.connect.role ?? "operator") !== "operator" || + !authorizeOperatorScopesForRequiredScope(READ_SCOPE, client.connect.scopes ?? []).allowed + ) { + return []; + } + const canReadSessions = // Match session reads: an established admin grant does not depend on profile verification. - (isGatewayAdmin(client) || !isGatewayClientProfilePending(client)); - const entryFilter = canRead + isGatewayAdmin(client) || !isGatewayClientProfilePending(client); + const entryFilter = canReadSessions ? createSessionListEntryFilter({ cfg: params.cfg, client }) : undefined; return params.presence.map((row) => { if (!row.watchedSessions) { return row; } - const watchedSessions = canRead + const watchedSessions = canReadSessions ? row.watchedSessions.filter((key) => { const target = resolveTarget(key); return target && (entryFilter?.(target.canonicalKey, target.entry) ?? true); diff --git a/src/gateway/server-broadcast.serialization.test.ts b/src/gateway/server-broadcast.serialization.test.ts index 428ff31917a2..fb8ae54137d9 100644 --- a/src/gateway/server-broadcast.serialization.test.ts +++ b/src/gateway/server-broadcast.serialization.test.ts @@ -283,11 +283,14 @@ describe("presence recipient projection", () => { const person = { text: "watcher", ts: 42, + onlineSince: 30, + lastActivityAt: 40, + timeZone: "Europe/Vienna", instanceId: "watcher", user: { id: "creator", name: "Creator" }, }; const watcher = { ...person, watchedSessions: [...keys, "agent:main:deleted"] }; - const presence = [watcher, { text: "idle", ts: 41 }]; + const presence = [watcher, { ...person, text: "idle", instanceId: "idle", ts: 41 }]; Object.freeze(watcher.watchedSessions); presence.forEach(Object.freeze); Object.freeze(presence); @@ -373,8 +376,16 @@ describe("presence recipient projection", () => { createdActor: { type: "human", id: "creator" }, }, ); - const person = { text: "watcher", ts: 1 }; - const presence = [{ ...person, watchedSessions: [key] }]; + const person = { + text: "watcher", + ts: 3, + onlineSince: 1, + lastActivityAt: 2, + timeZone: "Europe/Vienna", + user: { id: "creator" }, + }; + const idle = { ...person, text: "idle" }; + const presence = [{ ...person, watchedSessions: [key] }, idle]; const solo = makeClient("solo").client; solo.connect.scopes = ["operator.write"]; const pending = makeClient("pending").client; @@ -387,16 +398,25 @@ describe("presence recipient projection", () => { node.connect.scopes = ["operator.admin"]; const noRead = makeClient("no-read").client; noRead.connect.scopes = []; + const worker = makeClient("worker").client; + worker.connect.role = "worker"; + worker.connect.scopes = []; + worker.connectionKind = "worker"; const project = createPresenceRecipientProjection({ cfg: {}, presence }); expect(project(solo)).toEqual(presence); - for (const client of [pending, node, noRead, null]) { - expect(project(client)).toEqual([person]); + solo.connect.scopes = []; + expect(project(solo)).toEqual([]); + solo.connect.scopes = ["operator.write"]; + expect(project(solo)).toEqual(presence); + expect(project(pending)).toEqual([person, idle]); + for (const client of [node, worker, noRead, null]) { + expect(project(client)).toEqual([]); } pending.connect.scopes = ["operator.admin"]; expect(project(pending)).toEqual(presence); const cfg: OpenClawConfig = { gateway: { roles: { definitions: {} } } }; const restrictedProject = createPresenceRecipientProjection({ cfg, presence }); - expect(restrictedProject(solo)).toEqual([person]); + expect(restrictedProject(solo)).toEqual([person, idle]); solo.internal = { operatorRoleActor: { kind: "system" } }; expect(restrictedProject(solo)).toEqual(presence); pending.authenticatedUserProfile = { diff --git a/src/gateway/server-broadcast.ts b/src/gateway/server-broadcast.ts index 3feda2229816..4a2faa91ab9b 100644 --- a/src/gateway/server-broadcast.ts +++ b/src/gateway/server-broadcast.ts @@ -60,7 +60,8 @@ const EVENT_SCOPE_GUARDS: Record = { "plugin.approval.resolved": [APPROVALS_SCOPE], "openclaw.approval.requested": [APPROVALS_SCOPE], "openclaw.approval.resolved": [APPROVALS_SCOPE], - presence: [], + // The frame cadence itself exposes person activity; match system-presence access. + presence: [READ_SCOPE], shutdown: [], tick: [], "talk.event": [READ_SCOPE], diff --git a/src/gateway/server-core-runtime.ts b/src/gateway/server-core-runtime.ts index 05ea67fb370f..a921fa1aa763 100644 --- a/src/gateway/server-core-runtime.ts +++ b/src/gateway/server-core-runtime.ts @@ -583,6 +583,12 @@ export async function startGatewayCoreRuntime(input: { channelManager.setAmbientAutostartSuppressedChannelIds( nextAmbientAutostartSuppressedChannelIds, ); + const nextPluginMetadataSnapshot = completePluginMetadataSnapshot({ + snapshot: nextPluginLookUpTable, + config: params.sourceConfig, + env: params.env, + workspaceDir: pluginWorkspaceDir, + }); loaded = prepareGatewayPluginLoad({ cfg: params.nextConfig, activationSourceConfig: params.sourceConfig, @@ -592,15 +598,10 @@ export async function startGatewayCoreRuntime(input: { hostServices: pluginHostServices, baseMethods, pluginLookUpTable: nextPluginLookUpTable, + pluginMetadataSnapshot: nextPluginMetadataSnapshot, ambientEnvTriggers, resolveGatewayContext: resolvePluginGatewayContext, }); - const nextPluginMetadataSnapshot = completePluginMetadataSnapshot({ - snapshot: nextPluginLookUpTable, - config: params.sourceConfig, - env: params.env, - workspaceDir: pluginWorkspaceDir, - }); setCurrentPluginMetadataSnapshot(nextPluginMetadataSnapshot, { config: params.sourceConfig, compatibleConfigs: [params.nextConfig], diff --git a/src/gateway/server-lifecycle.ts b/src/gateway/server-lifecycle.ts index 833f389d7b71..9d0a78dfd953 100644 --- a/src/gateway/server-lifecycle.ts +++ b/src/gateway/server-lifecycle.ts @@ -334,17 +334,10 @@ export async function prepareGatewayLifecycle(params: { isConnectionActive, }); runtimeState.sessionViewerPresence = createSessionViewerPresenceDeclarations({ - isConnectionActive, - onReplace: (connId, sessionKeys) => { - const client = clients.getByConnectionId(connId); - if (!client?.presenceKey) { - return; - } - upsertPresence(client.presenceKey, { - watchedSessions: sessionKeys.length > 0 ? [...sessionKeys] : undefined, - }); - broadcastPresenceSnapshot({ broadcast, incrementPresenceVersion, getHealthVersion }); - }, + clients, + broadcast, + incrementPresenceVersion, + getHealthVersion, }); deps.cron = runtimeState.cronState.cron; const pluginHostServices = { diff --git a/src/gateway/server-methods/chat-send-handler.ts b/src/gateway/server-methods/chat-send-handler.ts index 75e1a44c4938..2a65b8d90047 100644 --- a/src/gateway/server-methods/chat-send-handler.ts +++ b/src/gateway/server-methods/chat-send-handler.ts @@ -274,6 +274,7 @@ async function handleChatSendWithOptions( // post-ACK cleanupAdmittedRun must not race that persist with a discard. admitted.value.setDiscardAbandonedPreparedMedia(undefined); respond(true, ackPayload, undefined, { runId: clientRunId }); + context.recordClientActivity?.(client); const chatSendAckedAtMs = chatSendTiming?.ackedAtMs ?? performance.now(); startChatDispatch({ admissionStartedAt, diff --git a/src/gateway/server-methods/chat.error-broadcast.test.ts b/src/gateway/server-methods/chat.error-broadcast.test.ts index 76fb05321394..c0ef7411499a 100644 --- a/src/gateway/server-methods/chat.error-broadcast.test.ts +++ b/src/gateway/server-methods/chat.error-broadcast.test.ts @@ -2,10 +2,21 @@ // error-state broadcasts for connected UI clients. import { expectDefined } from "@openclaw/normalization-core"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it, onTestFinished, vi } from "vitest"; +import { createDeferred } from "../../../test/helpers/promise.js"; +import { withOpenClawTestState } from "../../test-utils/openclaw-test-state.js"; import { createChatRunState } from "../server-chat-state.js"; +import { recordClientPresenceActivity } from "../server/client-presence.js"; +import type { GatewayWsClient } from "../server/ws-types.js"; +import { handleChatSend } from "./chat-send-handler.js"; import { chatHandlers } from "./chat.js"; -import type { GatewayRequestContext } from "./types.js"; +import type { GatewayClient, GatewayRequestContext } from "./types.js"; + +vi.mock("./chat-send-agent-dispatch.js", () => ({ + startChatDispatch: () => { + throw new Error("dispatch failed after admission ACK"); + }, +})); function createMockContext() { const broadcast = vi.fn(); @@ -25,6 +36,7 @@ function createMockContext() { logGateway: { warn: vi.fn(), debug: vi.fn(), error: vi.fn() }, addChatRun: vi.fn(), removeChatRun: vi.fn(), + recordClientActivity: vi.fn<(client: GatewayClient | null) => void>(), }; } @@ -60,6 +72,7 @@ describe("chat.send error broadcast", () => { ); expect(ctx.addChatRun).not.toHaveBeenCalled(); expect(ctx.broadcast).not.toHaveBeenCalled(); + expect(ctx.recordClientActivity).not.toHaveBeenCalled(); }); it("rejects a stale expected session routing contract before dispatch", async () => { @@ -127,8 +140,95 @@ describe("chat.send error broadcast", () => { undefined, { cached: true }, ); + expect(ctx.recordClientActivity).not.toHaveBeenCalled(); }); + it.each([false, true])( + "records new admission activity only if the socket remains live (closed=%s)", + async (closedDuringAdmission) => { + await withOpenClawTestState({ scenario: "minimal" }, async () => { + const ctx = createMockContext(); + const client: GatewayWsClient = { + connId: "activity-send", + presenceKey: "activity-send", + usesSharedGatewayAuth: false, + socket: { readyState: 1 } as GatewayWsClient["socket"], + connect: { + minProtocol: 1, + maxProtocol: 1, + role: "operator", + scopes: ["operator.admin"], + client: { id: "openclaw-tui", version: "test", platform: "test", mode: "cli" }, + }, + authenticatedUserId: "send@activity.test", + personPresence: { onlineSince: Date.now() - 1_000 }, + }; + const clients = new Set([client]); + ctx.recordClientActivity.mockImplementation((requestClient) => { + recordClientPresenceActivity(clients, requestClient); + }); + const entered = createDeferred(); + const release = createDeferred(); + onTestFinished(() => release.resolve()); + const respond = vi.fn(); + const options = { + params: { + sessionKey: "main", + message: "accepted activity", + idempotencyKey: "activity-send", + }, + client, + context: ctx as unknown as GatewayRequestContext, + req: { type: "req" as const, id: "activity-send", method: "chat.send" }, + isWebchatConnect: () => false, + respond, + }; + const sending = handleChatSend(options, async () => { + entered.resolve(); + await release.promise; + return true; + }); + await Promise.race([ + entered.promise, + sending.then(() => { + throw new Error("send finished before reaching admission"); + }), + ]); + expect(client.personPresence?.lastActivityAt).toBeUndefined(); + const duplicateResponse = vi.fn(); + await handleChatSend({ ...options, respond: duplicateResponse }); + expect(duplicateResponse).toHaveBeenCalledWith( + true, + { runId: "activity-send", status: "in_flight" }, + undefined, + expect.objectContaining({ cached: true }), + ); + expect(ctx.recordClientActivity).not.toHaveBeenCalled(); + if (closedDuringAdmission) { + clients.delete(client); + } + const admittedAt = Date.now() + 1_000; + const clock = vi.spyOn(Date, "now").mockReturnValue(admittedAt); + onTestFinished(() => clock.mockRestore()); + release.resolve(); + await sending; + expect(respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ runId: "activity-send", status: "started" }), + undefined, + { runId: "activity-send" }, + ); + expect(client.personPresence?.lastActivityAt).toBe( + closedDuringAdmission ? undefined : admittedAt, + ); + const cachedResponse = vi.fn(); + await handleChatSend({ ...options, respond: cachedResponse }); + expect(cachedResponse.mock.calls[0]?.[3]).toMatchObject({ cached: true }); + expect(ctx.recordClientActivity).toHaveBeenCalledExactlyOnceWith(client); + }); + }, + ); + it("rejects a stale routing contract before a stop side effect", async () => { const ctx = createMockContext(); const respond = vi.fn(); diff --git a/src/gateway/server-methods/sessions-suggestions.ts b/src/gateway/server-methods/sessions-suggestions.ts index 3e328d61a1b7..e389fc698a97 100644 --- a/src/gateway/server-methods/sessions-suggestions.ts +++ b/src/gateway/server-methods/sessions-suggestions.ts @@ -561,6 +561,9 @@ export const sessionSuggestionHandlers: GatewayRequestHandlers = { respond(true, { ok: true, broadcast: false }); return; } + if (params.typing) { + context.recordClientActivity?.(client); + } const sessionKeys = new Set([ params.sessionKey, target.canonicalKey, diff --git a/src/gateway/server-methods/sessions-typing.test.ts b/src/gateway/server-methods/sessions-typing.test.ts index f5e663e102b0..06bd44ee0870 100644 --- a/src/gateway/server-methods/sessions-typing.test.ts +++ b/src/gateway/server-methods/sessions-typing.test.ts @@ -252,6 +252,8 @@ describe("session typing handler", () => { ]; const broadcast = vi.fn(); const requestContext = context(broadcast); + const recordClientActivity = vi.fn(); + requestContext.recordClientActivity = recordClientActivity; const params = { sessionKey, sessionId: "session-main", context: requestContext }; const tabOne = client("multi", "multi-tab-1"); const tabTwo = client("multi", "multi-tab-2"); @@ -277,6 +279,9 @@ describe("session typing handler", () => { }); await vi.advanceTimersByTimeAsync(500); expect(broadcast.mock.calls.map((call) => call[1].typing)).toEqual([true, false]); + expect(recordClientActivity.mock.calls).toEqual([[tabOne], [tabTwo]]); + await vi.advanceTimersByTimeAsync(3_000); + expect(recordClientActivity).toHaveBeenCalledTimes(2); }); }); @@ -376,6 +381,8 @@ describe("session typing handler", () => { client: client("alice", "alice-tab"), context: context(broadcast), }; + const recordClientActivity = vi.fn(); + params.context.recordClientActivity = recordClientActivity; expect(await callTyping({ ...params, typing: true })).toEqual({ ok: true, @@ -394,6 +401,41 @@ describe("session typing handler", () => { }); await vi.advanceTimersByTimeAsync(900); expect(broadcast).toHaveBeenCalledTimes(1); + expect(recordClientActivity).toHaveBeenCalledTimes(2); + await callTyping({ ...params, typing: true }); + expect(recordClientActivity).toHaveBeenCalledTimes(2); + }); + }); + + it("does not record malformed, hidden, or unauthorized typing", async () => { + await withOpenClawTestState({ scenario: "minimal" }, async () => { + const sessionKey = "agent:main:typing-activity-authorization"; + const scope = { agentId: "main", sessionKey }; + const entry = { + sessionId: "typing-activity", + updatedAt: 1, + createdActor: { type: "human" as const, id: "owner" }, + }; + await upsertSessionEntryCore(scope, { ...entry, visibility: "draft" }); + const requestContext = context(); + const recordClientActivity = vi.fn(); + requestContext.recordClientActivity = recordClientActivity; + const params = { + sessionKey, + sessionId: entry.sessionId, + typing: true, + client: client("viewer", "typing-viewer"), + context: requestContext, + }; + await callTyping({ ...params, sessionKey: "" }); + await callTyping(params); + expect(recordClientActivity).not.toHaveBeenCalled(); + await upsertSessionEntryCore(scope, { ...entry, visibility: "shared" }); + await callTyping(params); + expect(recordClientActivity).toHaveBeenCalledExactlyOnceWith(params.client); + await upsertSessionEntryCore(scope, { ...entry, visibility: "shared", incognito: true }); + await callTyping(params); + expect(recordClientActivity).toHaveBeenCalledExactlyOnceWith(params.client); }); }); }); diff --git a/src/gateway/server-methods/shared-types.ts b/src/gateway/server-methods/shared-types.ts index 6ff4608334d5..8c6b36d8f090 100644 --- a/src/gateway/server-methods/shared-types.ts +++ b/src/gateway/server-methods/shared-types.ts @@ -340,6 +340,8 @@ type GatewayTransportContext = { nodeUnsubscribeAll: (nodeId: string) => void; hasConnectedTalkNode: () => Promise; isConnectionActive?: (connId: string) => boolean; + /** Server-stamped activity from an accepted request on the exact live person connection. */ + recordClientActivity?: (client: GatewayClient | null) => void; hasExecApprovalClients?: (excludeConnId?: string) => boolean; getApprovalClientConnIds?: (params?: { approvalKind?: "exec" | "plugin" | "system-agent"; diff --git a/src/gateway/server-plugin-bootstrap.ts b/src/gateway/server-plugin-bootstrap.ts index 64a80b0a7a75..1422d943b29c 100644 --- a/src/gateway/server-plugin-bootstrap.ts +++ b/src/gateway/server-plugin-bootstrap.ts @@ -6,6 +6,7 @@ import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { ChannelPluginLoadIntent } from "../plugins/loader-types.js"; import type { PluginLookUpTable } from "../plugins/plugin-lookup-table.js"; +import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js"; import type { PluginRegistryParams } from "../plugins/registry-types.js"; import type { PluginRegistry } from "../plugins/registry.js"; import { @@ -41,6 +42,7 @@ type GatewayPluginBootstrapParams = { baseMethods: string[]; pluginIds?: string[]; pluginLookUpTable?: PluginLookUpTable; + pluginMetadataSnapshot?: PluginMetadataSnapshot; channelPluginLoadIntent?: ChannelPluginLoadIntent; suppressPluginInfoLogs?: boolean; logDiagnostics?: boolean; @@ -127,6 +129,7 @@ export function prepareGatewayPluginLoad(params: GatewayPluginBootstrapParams) { baseMethods: params.baseMethods, pluginIds: params.pluginIds, pluginLookUpTable: params.pluginLookUpTable, + pluginMetadataSnapshot: params.pluginMetadataSnapshot, channelPluginLoadIntent: params.channelPluginLoadIntent ?? "full", suppressPluginInfoLogs: params.suppressPluginInfoLogs, startupTrace: params.startupTrace, diff --git a/src/gateway/server-plugins.test.ts b/src/gateway/server-plugins.test.ts index ac8138ec43d7..26c7cf37fc85 100644 --- a/src/gateway/server-plugins.test.ts +++ b/src/gateway/server-plugins.test.ts @@ -59,9 +59,15 @@ vi.mock("../plugins/loader.js", () => ({ loadOpenClawPlugins, })); -vi.mock("../plugins/runtime/load-context.js", () => ({ - createPluginRuntimeLoaderLogger: () => pluginRuntimeLoaderLogger, -})); +vi.mock("../plugins/runtime/load-context.js", async (importOriginal) => { + const { buildPluginRuntimeLoadOptions, setPluginRuntimeLoadContext } = + await importOriginal(); + return { + buildPluginRuntimeLoadOptions, + setPluginRuntimeLoadContext, + createPluginRuntimeLoaderLogger: () => pluginRuntimeLoaderLogger, + }; +}); vi.mock("../plugins/plugin-lookup-table.js", () => ({ loadPluginLookUpTable, diff --git a/src/gateway/server-plugins.ts b/src/gateway/server-plugins.ts index 661e01706479..1f0d0f1d544d 100644 --- a/src/gateway/server-plugins.ts +++ b/src/gateway/server-plugins.ts @@ -9,11 +9,13 @@ import { allowsProcessHomeSessionScan } from "../config/paths.js"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizePluginsConfig } from "../plugins/config-state.js"; +import { getCurrentPluginMetadataSnapshot } from "../plugins/current-plugin-metadata-snapshot.js"; import { extractPluginInstallRecordsFromInstalledPluginIndex } from "../plugins/installed-plugin-index-install-records.js"; import { activatePluginRegistry } from "../plugins/loader-shared.js"; import type { ChannelPluginLoadIntent } from "../plugins/loader-types.js"; import { loadAndActivateRootPluginRegistry } from "../plugins/loader.js"; import { loadPluginLookUpTable, type PluginLookUpTable } from "../plugins/plugin-lookup-table.js"; +import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js"; import { getPluginModuleLoaderStats } from "../plugins/plugin-module-loader-cache.js"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; import type { PluginRegistryParams } from "../plugins/registry-types.js"; @@ -23,7 +25,12 @@ import { getPluginRuntimeGatewayRequestScope, withPluginRuntimeGatewayContextResolver, } from "../plugins/runtime/gateway-request-scope.js"; -import { createPluginRuntimeLoaderLogger } from "../plugins/runtime/load-context.js"; +import { + buildPluginRuntimeLoadOptions, + createPluginRuntimeLoaderLogger, + setPluginRuntimeLoadContext, + type PluginRuntimeLoadContext, +} from "../plugins/runtime/load-context.js"; import { resolvePluginSubagentCompletionRequester } from "../plugins/runtime/subagent-requester-context.js"; import type { CreatePluginRuntimeOptions, @@ -564,6 +571,7 @@ export function loadGatewayPlugins(params: { baseMethods: string[]; pluginIds?: string[]; pluginLookUpTable?: PluginLookUpTable; + pluginMetadataSnapshot?: PluginMetadataSnapshot; channelPluginLoadIntent?: ChannelPluginLoadIntent; suppressPluginInfoLogs?: boolean; startupTrace?: { @@ -588,28 +596,19 @@ export function loadGatewayPlugins(params: { : undefined; const autoEnableMs = performance.now() - started; const autoEnabled = - params.activationSourceConfig !== undefined + params.activationSourceConfig !== undefined || params.autoEnabledReasons !== undefined ? { config: params.cfg, - changes: activationAutoEnabled?.changes ?? [], autoEnabledReasons: params.autoEnabledReasons ?? activationAutoEnabled?.autoEnabledReasons ?? {}, } - : params.autoEnabledReasons !== undefined - ? { - config: params.cfg, - changes: [], - autoEnabledReasons: params.autoEnabledReasons, - } - : applyPluginAutoEnable({ - config: params.cfg, - env: process.env, - ...(params.pluginLookUpTable?.manifestRegistry - ? { manifestRegistry: params.pluginLookUpTable.manifestRegistry } - : {}), - discovery: params.pluginLookUpTable?.discovery, - ambientEnvTriggers: params.ambientEnvTriggers, - }); + : applyPluginAutoEnable({ + config: params.cfg, + env: process.env, + manifestRegistry: params.pluginLookUpTable?.manifestRegistry, + discovery: params.pluginLookUpTable?.discovery, + ambientEnvTriggers: params.ambientEnvTriggers, + }); const resolvedConfigMs = performance.now() - started; const resolvedConfig = autoEnabled.config; const pluginIds = params.pluginIds ?? [ @@ -625,8 +624,36 @@ export function loadGatewayPlugins(params: { ).startup.pluginIds, ]; const pluginIdsMs = performance.now() - started; + const metadataSnapshot = + params.pluginMetadataSnapshot ?? + getCurrentPluginMetadataSnapshot({ + config: params.cfg, + workspaceDir: params.workspaceDir, + }); + const loaderMetadata = metadataSnapshot ?? params.pluginLookUpTable; + const loadContext: PluginRuntimeLoadContext = { + rawConfig: params.cfg, + config: resolvedConfig, + activationSourceConfig: params.activationSourceConfig ?? params.cfg, + autoEnabledReasons: autoEnabled.autoEnabledReasons, + workspaceDir: params.workspaceDir, + env: process.env, + logger: createGatewayPluginRegistrationLogger({ + suppressInfoLogs: params.suppressPluginInfoLogs, + }), + preferBuiltPluginArtifacts: true, + metadataSnapshot, + ...(loaderMetadata + ? { + manifestRegistry: loaderMetadata.manifestRegistry, + installRecords: extractPluginInstallRecordsFromInstalledPluginIndex(loaderMetadata.index), + } + : {}), + }; if (pluginIds.length === 0) { const pluginRegistry = createEmptyPluginRegistry(); + // An empty startup registry still owns the artifact policy for later capability loads. + setPluginRuntimeLoadContext(pluginRegistry, loadContext); activatePluginRegistry(pluginRegistry, null, "gateway-bindable", params.workspaceDir); params.startupTrace?.detail("plugins.gateway-load", [ ["autoEnableMs", autoEnableMs], @@ -650,42 +677,22 @@ export function loadGatewayPlugins(params: { resolvePluginSubagentOverridePolicies(resolvedConfig), ); const pluginRegistry = loadAndActivateRootPluginRegistry({ - config: resolvedConfig, + ...buildPluginRuntimeLoadOptions(loadContext), + // Startup registration stays scoped; later capability loads use the complete bound generation. + manifestRegistry: params.pluginLookUpTable?.manifestRegistry ?? loadContext.manifestRegistry, allowProcessHomeSessionCatalogs, - activationSourceConfig: params.activationSourceConfig ?? params.cfg, - autoEnabledReasons: autoEnabled.autoEnabledReasons, - workspaceDir: params.workspaceDir, onlyPluginIds: pluginIds, - logger: createGatewayPluginRegistrationLogger({ - suppressInfoLogs: params.suppressPluginInfoLogs, - }), - ...(params.coreGatewayHandlers !== undefined && { - coreGatewayHandlers: params.coreGatewayHandlers, - }), - ...(params.coreGatewayMethodNames !== undefined && { - coreGatewayMethodNames: params.coreGatewayMethodNames, - }), - ...(params.hostServices !== undefined && { - hostServices: params.hostServices, - }), + coreGatewayHandlers: params.coreGatewayHandlers, + coreGatewayMethodNames: params.coreGatewayMethodNames, + hostServices: params.hostServices, runtimeOptions: { allowGatewaySubagentBinding: true, ...gatewayRuntimeBindings.runtime, }, channelPluginLoadIntent: params.channelPluginLoadIntent, - preferBuiltPluginArtifacts: true, - ...(params.startupTrace !== undefined && { - startupTrace: params.startupTrace, - }), - ...(params.pluginLookUpTable - ? { - manifestRegistry: params.pluginLookUpTable.manifestRegistry, - installRecords: extractPluginInstallRecordsFromInstalledPluginIndex( - params.pluginLookUpTable.index, - ), - } - : {}), + startupTrace: params.startupTrace, }); + setPluginRuntimeLoadContext(pluginRegistry, loadContext); const loadMs = performance.now() - beforeLoad; const loaderStatsAfter = getPluginModuleLoaderStats(); const pluginMethods = Object.keys(pluginRegistry.gatewayHandlers); diff --git a/src/gateway/server-request-context.test.ts b/src/gateway/server-request-context.test.ts index 721db350dc76..998b6bc306aa 100644 --- a/src/gateway/server-request-context.test.ts +++ b/src/gateway/server-request-context.test.ts @@ -1,12 +1,13 @@ /** * Gateway request context construction tests. */ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it, onTestFinished, vi } from "vitest"; import { GATEWAY_CLIENT_CAPS, GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES, } from "../../packages/gateway-protocol/src/client-info.js"; +import { listSystemPresence } from "../infra/system-presence.js"; import { ensureProfileForEmail, getUserProfileDisplay, @@ -17,6 +18,7 @@ import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js"; import { createChatRunState } from "./server-chat-state.js"; import type { GatewayServerLiveState } from "./server-live-state.js"; import { createGatewayRequestContext } from "./server-request-context.js"; +import type { GatewayWsClient } from "./server/ws-types.js"; type GatewayRequestContextParams = Parameters[0]; type TestCronState = GatewayServerLiveState["cronState"]; @@ -149,7 +151,7 @@ function makeGatewayClient(params: { scopes: params.scopes ?? [], caps: params.caps ?? [], }, - socket: { close: vi.fn() }, + socket: { close: vi.fn(), readyState: 1 }, ...(params.approvalRuntime ? { internal: { approvalRuntime: true } } : {}), ...(params.invalidated ? { invalidated: true } : {}), }; @@ -414,6 +416,18 @@ describe("createGatewayRequestContext", () => { updatedAt: source.updatedAt, }, presenceKey: "profile-refresh-merge-source", + personPresence: { onlineSince: 1_000, lastActivityAt: 2_000 }, + }; + const targetClient = { + ...sourceClient, + connId: "merge-target", + authenticatedUserId: "merge-target@example.test", + authenticatedUserProfile: { + ...sourceClient.authenticatedUserProfile, + profileId: target.id, + }, + presenceKey: "profile-refresh-merge-target", + personPresence: { onlineSince: 1_500, lastActivityAt: 3_000 }, }; const unrelatedClient = { ...makeGatewayClient({ @@ -432,7 +446,7 @@ describe("createGatewayRequestContext", () => { }; const capturedProfile = sourceClient.authenticatedUserProfile; const params = makeContextParams({ - clients: new Set([sourceClient, unrelatedClient]) as never, + clients: new Set([sourceClient, targetClient, unrelatedClient]) as never, }); const context = createGatewayRequestContext(params); @@ -453,6 +467,13 @@ describe("createGatewayRequestContext", () => { updatedAt: linked.updatedAt, }); expect(unrelatedClient.authenticatedUserProfile.profileId).toBe(unrelatedProfile.id); + for (const email of ["merge-source@example.test", "merge-target@example.test"]) { + expect(listSystemPresence().find((entry) => entry.user?.email === email)).toMatchObject({ + user: { id: target.id }, + onlineSince: 1_000, + lastActivityAt: 3_000, + }); + } const presence = vi.mocked(params.broadcast).mock.calls[0]?.[1] as { presence?: Array<{ user?: { id?: string; email?: string; avatarUrl?: string } }>; }; @@ -470,6 +491,79 @@ describe("createGatewayRequestContext", () => { }); }); + it("publishes only server-stamped activity from the exact live client", () => { + const now = vi.spyOn(Date, "now").mockReturnValue(10_000); + onTestFinished(() => now.mockRestore()); + const client: GatewayWsClient = { + ...makeGatewayClient({ connId: "activity-live", clientId: GATEWAY_CLIENT_IDS.CONTROL_UI }), + socket: { readyState: 1 } as GatewayWsClient["socket"], + usesSharedGatewayAuth: false, + presenceKey: "activity-live", + authenticatedUserId: "live@activity.test", + personPresence: { onlineSince: 9_000 }, + }; + const clients = new Set([client]); + const params = makeContextParams({ clients }); + const context = createGatewayRequestContext(params); + context.recordClientActivity?.({ ...client }); + expect(params.broadcast).not.toHaveBeenCalled(); + context.recordClientActivity?.(client); + expect(params.broadcast).toHaveBeenCalledExactlyOnceWith( + "presence", + { + presence: expect.arrayContaining([ + expect.objectContaining({ + user: { id: "live@activity.test", email: "live@activity.test" }, + onlineSince: 9_000, + lastActivityAt: 10_000, + }), + ]), + }, + { dropIfSlow: true, stateVersion: { presence: 1, health: 1 } }, + ); + now.mockReturnValue(11_000); + clients.delete(client); + context.recordClientActivity?.(client); + expect(params.broadcast).toHaveBeenCalledOnce(); + }); + + it.each(["removed", "invalidated", "closing"] as const)( + "does not refresh a %s profile connection or resurrect its presence", + (state) => { + const client: GatewayWsClient = { + ...makeGatewayClient({ + connId: `profile-${state}`, + clientId: GATEWAY_CLIENT_IDS.CONTROL_UI, + }), + socket: { readyState: state === "closing" ? 2 : 1 } as GatewayWsClient["socket"], + usesSharedGatewayAuth: false, + authenticatedUserId: `${state}@profile.test`, + authenticatedUserProfile: { + profileId: `inactive-${state}`, + displayName: "Before", + avatarRevision: "1", + hasAvatar: false, + updatedAt: 1, + }, + presenceKey: `profile-${state}`, + invalidated: state === "invalidated", + }; + const params = makeContextParams({ clients: new Set(state === "removed" ? [] : [client]) }); + createGatewayRequestContext(params).refreshConnectedUserProfile?.({ + id: `inactive-${state}`, + displayName: "After", + avatarRevision: "2", + hasAvatar: false, + updatedAt: 2, + }); + expect(client.authenticatedUserProfile?.displayName).toBe("Before"); + expect(params.broadcast).not.toHaveBeenCalled(); + expect( + listSystemPresence().some((entry) => entry.user?.email === `${state}@profile.test`), + ).toBe(false); + }, + ); + it("preserves the Gravatar-backed route when a changed profile has no upload", () => { const client = { ...makeGatewayClient({ diff --git a/src/gateway/server-request-context.ts b/src/gateway/server-request-context.ts index 83c4587d9903..43489d2d5461 100644 --- a/src/gateway/server-request-context.ts +++ b/src/gateway/server-request-context.ts @@ -7,15 +7,16 @@ import { type GatewayClientId, } from "../../packages/gateway-protocol/src/client-info.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { upsertPresence } from "../infra/system-presence.js"; import { resolveUserProfileId } from "../state/user-profiles.js"; -import { buildAuthenticatedPresenceUser } from "./authenticated-presence-user.js"; import { NODE_DESKTOP_SERVICE_CONTEXT } from "./desktop/node-source-context.js"; import { ScopeUpgradeCoordinator } from "./device-scope-upgrade.js"; +import { WEBSOCKET_OPEN_READY_STATE } from "./server-constants.js"; import type { GatewayServerLiveState } from "./server-live-state.js"; import type { GatewayClient, GatewayRequestContext } from "./server-methods/types.js"; import { disconnectAllSharedGatewayAuthClients } from "./server-shared-auth-generation.js"; +import { recordClientPresenceActivity, refreshClientPresence } from "./server/client-presence.js"; import { broadcastPresenceSnapshot } from "./server/presence-events.js"; +import type { GatewayWsClient } from "./server/ws-types.js"; import type { SessionCompanionService } from "./session-companion.js"; import type { SessionObserverService } from "./session-observer-contract.js"; @@ -70,7 +71,7 @@ type GatewayRequestContextParams = { nodeUnsubscribe: GatewayRequestContext["nodeUnsubscribe"]; nodeUnsubscribeAll: GatewayRequestContext["nodeUnsubscribeAll"]; hasConnectedTalkNode: GatewayRequestContext["hasConnectedTalkNode"]; - clients: Set; + clients: Set; isConnectionActive: NonNullable; invalidateDeviceTransports?: ( deviceId: string, @@ -230,6 +231,11 @@ export function createGatewayRequestContext( nodeUnsubscribeAll: params.nodeUnsubscribeAll, hasConnectedTalkNode: params.hasConnectedTalkNode, isConnectionActive: params.isConnectionActive, + recordClientActivity: (client) => { + if (recordClientPresenceActivity(params.clients, client)) { + broadcastPresenceSnapshot(params); + } + }, hasExecApprovalClients: (excludeConnId?: string) => { for (const gatewayClient of params.clients) { if (excludeConnId && gatewayClient.connId === excludeConnId) { @@ -284,6 +290,12 @@ export function createGatewayRequestContext( refreshConnectedUserProfile: (profile) => { let presenceChanged = false; for (const gatewayClient of params.clients) { + if ( + gatewayClient.invalidated || + gatewayClient.socket.readyState !== WEBSOCKET_OPEN_READY_STATE + ) { + continue; + } const authenticatedUserProfile = gatewayClient.authenticatedUserProfile; if (!authenticatedUserProfile) { continue; @@ -302,22 +314,7 @@ export function createGatewayRequestContext( hasAvatar: profile.hasAvatar, updatedAt: profile.updatedAt, }); - if (!gatewayClient.presenceKey || !gatewayClient.authenticatedUserId) { - continue; - } - upsertPresence(gatewayClient.presenceKey, { - user: buildAuthenticatedPresenceUser({ - authenticatedUserId: gatewayClient.authenticatedUserId, - authenticatedUserIsTailscaleProvider: - gatewayClient.authenticatedUserIsTailscaleProvider, - authenticatedUserProfile: { - profileId: profile.id, - displayName: profile.displayName, - avatarRevision: profile.avatarRevision, - }, - }), - }); - presenceChanged = true; + presenceChanged = refreshClientPresence(params.clients, gatewayClient) || presenceChanged; } if (presenceChanged) { broadcastPresenceSnapshot({ diff --git a/src/gateway/server.auth.default-token.suite.ts b/src/gateway/server.auth.default-token.suite.ts index b4acb71f27c5..6894319bd707 100644 --- a/src/gateway/server.auth.default-token.suite.ts +++ b/src/gateway/server.auth.default-token.suite.ts @@ -405,8 +405,8 @@ export function registerDefaultAuthTokenSuite(): void { const { randomUUID } = await import("node:crypto"); const os = await import("node:os"); const path = await import("node:path"); - // Fresh identity: avoid leaking prior scopes (presence merges lists). - const { identity, device } = await createSignedDevice({ + // Fresh identity avoids inheriting a previously paired device's grant. + const { device } = await createSignedDevice({ token, scopes: [], clientId: GATEWAY_CLIENT_NAMES.TEST, @@ -421,22 +421,12 @@ export function registerDefaultAuthTokenSuite(): void { device, }); expect(connectRes.ok).toBe(true); - const helloOk = connectRes.payload as - | { - snapshot?: { - presence?: Array<{ deviceId?: unknown; scopes?: unknown }>; - }; - } - | undefined; - const presence = helloOk?.snapshot?.presence; - expect(Array.isArray(presence)).toBe(true); - const mine = presence?.find((entry) => entry.deviceId === identity.deviceId); - if (!mine) { - throw new Error(`expected presence entry for device ${identity.deviceId}`); - } - const presenceScopes = Array.isArray(mine?.scopes) ? mine?.scopes : []; - expect(presenceScopes).toEqual([]); - expect(presenceScopes).not.toContain("operator.admin"); + expect(readHelloOkAuth(connectRes.payload)).toMatchObject({ role: "operator", scopes: [] }); + expect(connectRes.payload).toMatchObject({ snapshot: { presence: [] } }); + const presence = await rpcReq(ws, "system-presence"); + expect(presence.ok).toBe(false); + expect(presence.error?.message).toBe("missing scope: operator.read"); + expect(presence.payload).toBeUndefined(); await expectStatusMissingScopeButHealthAvailable(ws); diff --git a/src/gateway/server.auth.identity-scopes.test.ts b/src/gateway/server.auth.identity-scopes.test.ts index 424d4871e102..452791475ab1 100644 --- a/src/gateway/server.auth.identity-scopes.test.ts +++ b/src/gateway/server.auth.identity-scopes.test.ts @@ -1,24 +1,18 @@ import { randomUUID } from "node:crypto"; import os from "node:os"; import path from "node:path"; -import { Type } from "typebox"; -import { Value } from "typebox/value"; import { afterEach, describe, expect, test } from "vitest"; -import { PresenceEntrySchema } from "../../packages/gateway-protocol/src/schema/snapshot.js"; import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; import { writeConfigFile } from "../config/config.js"; -import { upsertSessionEntryCore } from "../config/sessions/session-accessor.js"; import type { GatewayAuthConfig, GatewayOperatorRolesConfig } from "../config/types.gateway.js"; import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js"; import { getPairedDevice, listDevicePairing } from "../infra/device-pairing.js"; -import { listSystemPresence, type SystemPresence } from "../infra/system-presence.js"; import { ensureProfileForEmail, setUserProfileRole } from "../state/user-profiles.js"; import { connectReq, CONTROL_UI_CLIENT, installGatewayTestHooks, NODE_CLIENT, - onceMessage, openTailscaleWs, openWs, rpcReq, @@ -65,190 +59,6 @@ function responseScopes(response: Awaited>): strin } describe("gateway identity scope grants", () => { - test("projects watched sessions for each authenticated presence recipient across hello, RPC, and events", async () => { - await configureGatewayAuth( - { - mode: "trusted-proxy", - identityScopes: { "admin@example.com": ["operator.admin"] }, - trustedProxy: { - userHeader: "x-forwarded-user", - requiredHeaders: ["x-forwarded-proto"], - allowLoopback: true, - }, - }, - { - roles: { - default: "reader", - definitions: { - reader: { sessions: { others: "view" }, agents: "*", scopes: ["operator.read"] }, - restricted: { sessions: { others: "none" }, agents: "*", scopes: ["operator.read"] }, - maintainer: { sessions: { others: "write" }, agents: "*", scopes: ["operator.admin"] }, - pairing: { sessions: { others: "none" }, agents: [], scopes: ["operator.pairing"] }, - }, - }, - }, - ); - const creator = ensureProfileForEmail("creator@example.com"); - const restricted = ensureProfileForEmail("restricted@example.com"); - setUserProfileRole(restricted.id, "restricted"); - setUserProfileRole(ensureProfileForEmail("admin@example.com").id, "maintainer"); - setUserProfileRole(ensureProfileForEmail("pairing@example.com").id, "pairing"); - const sharedKey = "agent:main:presence-shared"; - const draftKey = "agent:main:presence-draft"; - const incognitoKey = "agent:main:dashboard:incognito-presence"; - const restrictedKey = "agent:main:presence-restricted-draft"; - const missingKey = "agent:main:presence-missing"; - const watchedKeys = [sharedKey, draftKey, incognitoKey, restrictedKey, missingKey].toSorted(); - const watcherInstanceId = `presence-watcher-${randomUUID()}`; - const identityDir = tempDirs.make("openclaw-presence-identities-"); - - await withGatewayServer(async ({ port }) => { - for (const [sessionKey, profileId, visibility, incognito] of [ - [sharedKey, creator.id, "shared", false], - [draftKey, creator.id, "draft", false], - [incognitoKey, creator.id, "shared", true], - [restrictedKey, restricted.id, "draft", false], - ] as const) { - await upsertSessionEntryCore( - { agentId: "main", sessionKey }, - { - sessionId: randomUUID(), - updatedAt: Date.now(), - createdActor: { type: "human", id: profileId }, - visibility, - ...(incognito ? { incognito: true } : {}), - }, - ); - } - const sockets: Awaited>[] = []; - const openRecipient = async ( - name: string, - scopes: string[], - role: "operator" | "node" = "operator", - ) => { - const ws = await openWs(port, { - ...TRUSTED_PROXY_HEADERS, - "x-forwarded-user": `${name}@example.com`, - }); - sockets.push(ws); - const connected = await connectReq(ws, { - skipDefaultAuth: true, - prePairDevice: true, - scopes, - role, - client: { - ...(role === "node" ? NODE_CLIENT : CONTROL_UI_CLIENT), - instanceId: sockets.length === 1 ? watcherInstanceId : `presence-${name}`, - }, - deviceIdentityPath: path.join(identityDir, `${name}-${sockets.length}.sqlite`), - browserOrigin: BROWSER_ORIGIN, - }); - expect(connected.ok, `${name} connect: ${JSON.stringify(connected.error)}`).toBe(true); - expect(responseScopes(connected), `${name} effective scopes`).toEqual(scopes); - return { - ws, - hello: connected.payload as { snapshot: { presence: SystemPresence[] } }, - }; - }; - try { - const watcher = await openRecipient("admin", ["operator.admin"]); - const declared = await rpcReq(watcher.ws, "sessions.viewers.set", { - sessionKeys: watchedKeys, - }); - expect(declared).toMatchObject({ ok: true, payload: { sessionKeys: watchedKeys } }); - const rawWatcher = listSystemPresence().find( - (entry) => entry.instanceId === watcherInstanceId, - ); - expect(rawWatcher?.watchedSessions).toEqual(watchedKeys); - const { watchedSessions: _watchedSessions, ...person } = rawWatcher!; - expect(person.user?.id).toBe(ensureProfileForEmail("admin@example.com").id); - expect(person.ts).toBeGreaterThan(0); - - const recipients = []; - for (const scenario of [ - { name: "creator", scopes: ["operator.read"], allowed: [sharedKey, draftKey] }, - { name: "reader", scopes: ["operator.read"], allowed: [sharedKey] }, - { name: "restricted", scopes: ["operator.read"], allowed: [restrictedKey] }, - { - name: "admin", - scopes: ["operator.admin"], - allowed: [sharedKey, draftKey, incognitoKey, restrictedKey], - }, - { name: "pairing", scopes: ["operator.pairing"], allowed: [] }, - { name: "node", scopes: [], allowed: [] }, - ]) { - const recipient = await openRecipient( - scenario.name, - scenario.scopes, - scenario.name === "node" ? "node" : "operator", - ); - const canRead = scenario.name !== "pairing" && scenario.name !== "node"; - const listed = await rpcReq<{ sessions: Array<{ key: string }> }>( - recipient.ws, - "sessions.list", - { agentId: "main" }, - ); - expect(listed.ok, `${scenario.name} sessions.list scope`).toBe(canRead); - if (canRead) { - expect( - listed.payload?.sessions - .map((entry) => entry.key) - .filter((key) => watchedKeys.includes(key)) - .toSorted(), - `${scenario.name} canonical sessions.list visibility`, - ).toEqual(scenario.allowed.toSorted()); - } - const presence = await rpcReq(recipient.ws, "system-presence"); - expect(presence.ok, `${scenario.name} system-presence scope`).toBe(canRead); - recipients.push({ ...recipient, ...scenario, canRead, rpcPresence: presence.payload }); - } - - const eventPromises = recipients.map(({ ws }) => - onceMessage<{ type: string; event: string; payload: { presence: SystemPresence[] } }>( - ws, - (frame) => frame.type === "event" && frame.event === "presence", - ), - ); - expect( - await rpcReq(watcher.ws, "system-event", { text: "presence recipient repro" }), - ).toMatchObject({ ok: true }); - const events = await Promise.all(eventPromises); - for (const [index, recipient] of recipients.entries()) { - for (const [surface, rows] of [ - ["hello", recipient.hello.snapshot.presence], - ["system-presence", recipient.rpcPresence], - ["presence event", events[index]!.payload.presence], - ] as const) { - if (surface === "system-presence" && !recipient.canRead) { - continue; // The RPC is rejected for pairing-only operators and nodes. - } - if (!Value.Check(Type.Array(PresenceEntrySchema), rows)) { - throw new Error(`${recipient.name} ${surface} returned invalid presence rows`); - } - const received = rows.find((entry) => entry.instanceId === watcherInstanceId); - const { watchedSessions, ...receivedPerson } = received ?? {}; - expect - .soft( - receivedPerson, - `${recipient.name} ${surface} preserves the person and timestamp without hidden counts`, - ) - .toEqual(person); - expect - .soft( - watchedSessions ?? [], - `${recipient.name} ${surface} watched session disclosure`, - ) - .toEqual(recipient.allowed.toSorted()); - } - } - } finally { - for (const ws of sockets) { - ws.close(); - } - } - }); - }); - test.each([ { label: "unassigned default guest", diff --git a/src/gateway/server.auth.presence-audience.test.ts b/src/gateway/server.auth.presence-audience.test.ts new file mode 100644 index 000000000000..14f7ee041b0b --- /dev/null +++ b/src/gateway/server.auth.presence-audience.test.ts @@ -0,0 +1,380 @@ +import { randomUUID } from "node:crypto"; +import { once } from "node:events"; +import path from "node:path"; +import { rawDataToString } from "@openclaw/gateway-client/websocket-data"; +import { Type } from "typebox"; +import { Value } from "typebox/value"; +import { afterEach, describe, expect, test } from "vitest"; +import { PresenceEntrySchema } from "../../packages/gateway-protocol/src/schema/snapshot.js"; +import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; +import { writeConfigFile } from "../config/config.js"; +import { upsertSessionEntryCore } from "../config/sessions/session-accessor.js"; +import type { GatewayAuthConfig, GatewayOperatorRolesConfig } from "../config/types.gateway.js"; +import { listSystemPresence, type SystemPresence } from "../infra/system-presence.js"; +import { ensureProfileForEmail, setUserProfileRole } from "../state/user-profiles.js"; +import { + connectReq, + CONTROL_UI_CLIENT, + installGatewayTestHooks, + NODE_CLIENT, + onceMessage, + openWs, + rpcReq, + testState, + withGatewayServer, +} from "./server.auth.test-helpers.js"; + +installGatewayTestHooks({ scope: "suite" }); + +const tempDirs = useAutoCleanupTempDirTracker(afterEach); + +const BROWSER_ORIGIN = "https://control.example.com"; +const TRUSTED_PROXY_HEADERS = { + origin: BROWSER_ORIGIN, + "x-forwarded-for": "203.0.113.50", + "x-forwarded-proto": "https", + "x-forwarded-user": "admin@example.com", +}; + +async function configureGatewayAuth( + auth: GatewayAuthConfig, + roles: GatewayOperatorRolesConfig, +): Promise { + testState.gatewayAuth = auth; + testState.gatewayControlUi = { allowedOrigins: [BROWSER_ORIGIN] }; + await writeConfigFile({ + gateway: { + auth, + trustedProxies: ["127.0.0.1"], + roles, + controlUi: { allowedOrigins: [BROWSER_ORIGIN] }, + }, + }); +} + +function responseScopes(response: Awaited>): string[] | undefined { + return (response.payload as { auth?: { scopes?: string[] } } | undefined)?.auth?.scopes; +} + +describe("gateway presence audience", () => { + test("shares people only with readers and filters their session references across hello, RPC, and activity events", async () => { + await configureGatewayAuth( + { + mode: "trusted-proxy", + identityScopes: { + "admin@example.com": ["operator.admin"], + "watcher@example.com": ["operator.admin"], + }, + trustedProxy: { + userHeader: "x-forwarded-user", + requiredHeaders: ["x-forwarded-proto"], + allowLoopback: true, + }, + }, + { + default: "reader", + definitions: { + reader: { sessions: { others: "view" }, agents: "*", scopes: ["operator.read"] }, + writer: { sessions: { others: "write" }, agents: "*", scopes: ["operator.write"] }, + restricted: { sessions: { others: "none" }, agents: "*", scopes: ["operator.read"] }, + maintainer: { sessions: { others: "write" }, agents: "*", scopes: ["operator.admin"] }, + pairing: { sessions: { others: "none" }, agents: [], scopes: ["operator.pairing"] }, + }, + }, + ); + const creator = ensureProfileForEmail("creator@example.com"); + const restricted = ensureProfileForEmail("restricted@example.com"); + setUserProfileRole(restricted.id, "restricted"); + setUserProfileRole(ensureProfileForEmail("admin@example.com").id, "maintainer"); + setUserProfileRole(ensureProfileForEmail("watcher@example.com").id, "maintainer"); + setUserProfileRole(ensureProfileForEmail("writer@example.com").id, "writer"); + setUserProfileRole(ensureProfileForEmail("pairing@example.com").id, "pairing"); + const sharedKey = "agent:main:presence-shared"; + const sharedSessionId = randomUUID(); + const draftKey = "agent:main:presence-draft"; + const incognitoKey = "agent:main:dashboard:incognito-presence"; + const restrictedKey = "agent:main:presence-restricted-draft"; + const missingKey = "agent:main:presence-missing"; + const watchedKeys = [sharedKey, draftKey, incognitoKey, restrictedKey, missingKey].toSorted(); + const watcherInstanceId = `presence-watcher-${randomUUID()}`; + const identityDir = tempDirs.make("openclaw-presence-identities-"); + + await withGatewayServer(async ({ port }) => { + for (const [sessionKey, profileId, visibility, incognito] of [ + [sharedKey, creator.id, "shared", false], + [draftKey, creator.id, "draft", false], + [incognitoKey, creator.id, "shared", true], + [restrictedKey, restricted.id, "draft", false], + ] as const) { + await upsertSessionEntryCore( + { agentId: "main", sessionKey }, + { + sessionId: sessionKey === sharedKey ? sharedSessionId : randomUUID(), + updatedAt: Date.now(), + createdActor: { type: "human", id: profileId }, + visibility, + ...(incognito ? { incognito: true } : {}), + }, + ); + } + const sockets: Awaited>[] = []; + const observePresence = (ws: Awaited>) => { + const events: SystemPresence[][] = []; + ws.on("message", (data) => { + const frame = JSON.parse(rawDataToString(data)) as { + type: string; + event?: string; + payload: { presence: SystemPresence[] }; + }; + if (frame.type === "event" && frame.event === "presence") { + events.push(frame.payload.presence); + } + }); + return events; + }; + const openRecipient = async ( + name: string, + scopes: string[], + role: "operator" | "node" = "operator", + ) => { + const ws = await openWs(port, { + ...TRUSTED_PROXY_HEADERS, + "x-forwarded-user": `${name}@example.com`, + }); + sockets.push(ws); + const events = observePresence(ws); + const client = { + ...(role === "node" ? NODE_CLIENT : CONTROL_UI_CLIENT), + instanceId: name === "watcher" ? watcherInstanceId : `presence-${name}`, + timeZone: "Europe/Vienna", + }; + const connected = await connectReq(ws, { + skipDefaultAuth: true, + prePairDevice: true, + scopes, + role, + client, + deviceIdentityPath: path.join(identityDir, `${name}-${sockets.length}.sqlite`), + browserOrigin: BROWSER_ORIGIN, + }); + expect(connected.ok, `${name} connect: ${JSON.stringify(connected.error)}`).toBe(true); + expect(responseScopes(connected), `${name} effective scopes`).toEqual(scopes); + return { + ws, + events, + hello: connected.payload as { snapshot: { presence: SystemPresence[] } }, + }; + }; + try { + const watcher = await openRecipient("watcher", ["operator.admin"]); + const idle = await openRecipient("idle", ["operator.read"]); + // A response on the watcher is a transport barrier, not a presence refresh. + expect((await rpcReq(watcher.ws, "health")).ok).toBe(true); + expect + .soft(watcher.events.at(-1), "first connect publishes without activity") + .toEqual( + expect.arrayContaining([ + expect.objectContaining({ instanceId: "presence-idle", reason: "connect" }), + ]), + ); + for (const sessionKeys of [[sharedKey], []]) { + expect(await rpcReq(idle.ws, "sessions.viewers.set", { sessionKeys })).toMatchObject({ + ok: true, + }); + } + const idlePerson = listSystemPresence().find( + (entry) => entry.instanceId === "presence-idle", + )!; + expect(idlePerson.watchedSessions).toBeUndefined(); + expect(idlePerson).toMatchObject({ + onlineSince: expect.any(Number), + lastActivityAt: expect.any(Number), + timeZone: "Europe/Vienna", + }); + const declared = await rpcReq(watcher.ws, "sessions.viewers.set", { + sessionKeys: watchedKeys, + }); + expect(declared).toMatchObject({ ok: true, payload: { sessionKeys: watchedKeys } }); + const rawWatcher = listSystemPresence().find( + (entry) => entry.instanceId === watcherInstanceId, + ); + expect(rawWatcher?.watchedSessions).toEqual(watchedKeys); + const { watchedSessions: _watchedSessions, ...person } = rawWatcher!; + expect(person.user?.id).toBe(ensureProfileForEmail("watcher@example.com").id); + expect(person.ts).toBeGreaterThan(0); + expect(person).toMatchObject({ + onlineSince: expect.any(Number), + lastActivityAt: expect.any(Number), + timeZone: "Europe/Vienna", + }); + + const recipients = []; + for (const scenario of [ + { name: "creator", scopes: ["operator.read"], allowed: [sharedKey, draftKey] }, + { name: "reader", scopes: ["operator.read"], allowed: [sharedKey] }, + { name: "writer", scopes: ["operator.write"], allowed: [sharedKey] }, + { name: "restricted", scopes: ["operator.read"], allowed: [restrictedKey] }, + { + name: "admin", + scopes: ["operator.admin"], + allowed: [sharedKey, draftKey, incognitoKey, restrictedKey], + }, + { name: "pairing", scopes: ["operator.pairing"], allowed: [] }, + { name: "no-read", scopes: [], allowed: [] }, + { name: "node", scopes: [], allowed: [] }, + ]) { + const recipient = await openRecipient( + scenario.name, + scenario.scopes, + scenario.name === "node" ? "node" : "operator", + ); + const canRead = !["pairing", "node", "no-read"].includes(scenario.name); + const listed = await rpcReq<{ sessions: Array<{ key: string }> }>( + recipient.ws, + "sessions.list", + { agentId: "main" }, + ); + expect(listed.ok, `${scenario.name} sessions.list scope`).toBe(canRead); + if (canRead) { + expect( + listed.payload?.sessions + .map((entry) => entry.key) + .filter((key) => watchedKeys.includes(key)) + .toSorted(), + `${scenario.name} canonical sessions.list visibility`, + ).toEqual(scenario.allowed.toSorted()); + } + const presence = await rpcReq(recipient.ws, "system-presence"); + expect(presence.ok, `${scenario.name} system-presence scope`).toBe(canRead); + recipients.push({ ...recipient, ...scenario, canRead, rpcPresence: presence.payload }); + } + + const unauthenticated = await openWs(port, { origin: BROWSER_ORIGIN }); + sockets.push(unauthenticated); + const unauthenticatedEvents = observePresence(unauthenticated); + const readers = recipients.filter(({ canRead }) => canRead); + const eventPromises = readers.map(({ ws }) => + onceMessage<{ type: string; event: string; payload: { presence: SystemPresence[] } }>( + ws, + (frame) => frame.type === "event" && frame.event === "presence", + ), + ); + expect( + await rpcReq(watcher.ws, "session.typing", { + sessionKey: sharedKey, + sessionId: sharedSessionId, + typing: true, + }), + ).toMatchObject({ ok: true }); + const events = await Promise.all(eventPromises); + const activeWatcher = listSystemPresence().find( + (entry) => entry.instanceId === watcherInstanceId, + )!; + const { watchedSessions: _activeWatches, ...activePerson } = activeWatcher; + expect(activePerson.lastActivityAt).toBeGreaterThanOrEqual(person.lastActivityAt!); + expect(activeWatcher.watchedSessions).toEqual(watchedKeys); + for (const [index, recipient] of readers.entries()) { + for (const [surface, rows] of [ + ["hello", recipient.hello.snapshot.presence], + ["system-presence", recipient.rpcPresence], + ["presence event", events[index]!.payload.presence], + ] as const) { + if (!Value.Check(Type.Array(PresenceEntrySchema), rows)) { + throw new Error(`${recipient.name} ${surface} returned invalid presence rows`); + } + const received = rows.find((entry) => entry.instanceId === watcherInstanceId); + const { watchedSessions, ...receivedPerson } = received ?? {}; + expect + .soft( + receivedPerson, + `${recipient.name} ${surface} preserves the person and timestamp without hidden counts`, + ) + .toEqual(surface === "presence event" ? activePerson : person); + expect(rows.find((entry) => entry.instanceId === "presence-idle")).toEqual(idlePerson); + expect + .soft( + watchedSessions ?? [], + `${recipient.name} ${surface} watched session disclosure`, + ) + .toEqual(recipient.allowed.toSorted()); + } + } + for (const recipient of recipients.filter(({ canRead }) => !canRead)) { + expect(recipient.hello.snapshot.presence, `${recipient.name} hello inventory`).toEqual( + [], + ); + expect(recipient.rpcPresence).toBeUndefined(); + // The activity fanout is synchronous. A later response on this socket + // is a transport barrier, so absence does not depend on sleeping. + expect((await rpcReq(recipient.ws, "health")).ok).toBe(true); + expect(recipient.events, `${recipient.name} activity-driven frames`).toEqual([]); + } + const preauthRead = await rpcReq(unauthenticated, "system-presence"); + expect(preauthRead.ok).toBe(false); + expect(preauthRead.payload).toBeUndefined(); + expect(unauthenticatedEvents).toEqual([]); + + const liveIdleRows = async () => { + expect((await rpcReq(watcher.ws, "health")).ok).toBe(true); + return watcher.events + .at(-1)! + .filter( + (entry) => entry.user?.id === idlePerson.user?.id && entry.reason !== "disconnect", + ); + }; + const overlap = await openRecipient("idle", ["operator.read"]); + const overlappingRows = await liveIdleRows(); + expect.soft(overlappingRows, "overlapping connect publishes both sockets").toHaveLength(2); + for (const entry of overlappingRows) { + expect(entry.onlineSince).toBe(idlePerson.onlineSince); + expect(entry.lastActivityAt).toBe(idlePerson.lastActivityAt); + } + for (const [connection, remaining] of [ + [idle, 1], + [overlap, 0], + ] as const) { + const closed = once(connection.ws, "close"); + connection.ws.close(); + await closed; + const rows = await liveIdleRows(); + expect(rows, "disconnect publishes only the surviving sockets").toHaveLength(remaining); + if (remaining) { + expect(rows[0]?.onlineSince).toBe(idlePerson.onlineSince); + } + } + const reconnected = await openRecipient("idle", ["operator.read"]); + const returnedPerson = reconnected.hello.snapshot.presence.find( + (entry) => entry.user?.id === idlePerson.user?.id && entry.reason === "connect", + )!; + expect(returnedPerson.onlineSince).toBeGreaterThan(idlePerson.onlineSince!); + expect(returnedPerson.lastActivityAt).toBeUndefined(); + expect(returnedPerson.watchedSessions).toBeUndefined(); + expect( + await liveIdleRows(), + "reconnect publishes without profile edit or activity", + ).toEqual([returnedPerson]); + for (const recipient of recipients.filter(({ canRead }) => !canRead)) { + expect((await rpcReq(recipient.ws, "health")).ok).toBe(true); + expect(recipient.events, `${recipient.name} connection-driven frames`).toEqual([]); + } + + const rejected = await openWs(port, { origin: BROWSER_ORIGIN }); + sockets.push(rejected); + const rejectedEvents = observePresence(rejected); + const connect = await connectReq(rejected, { + skipDefaultAuth: true, + device: null, + scopes: ["operator.read", "operator.admin"], + client: CONTROL_UI_CLIENT, + }); + expect(connect.ok).toBe(false); + expect(connect.payload).toBeUndefined(); + expect(rejectedEvents).toEqual([]); + } finally { + for (const ws of sockets) { + ws.close(); + } + } + }); + }); +}); diff --git a/src/gateway/server.health.test.ts b/src/gateway/server.health.test.ts index 6319a382836b..a9a2a49faf6c 100644 --- a/src/gateway/server.health.test.ts +++ b/src/gateway/server.health.test.ts @@ -23,13 +23,14 @@ const FINGERPRINT_TIMEOUT_MS = 3_000; const CLI_PRESENCE_TIMEOUT_MS = 3_000; let harness: GatewayServerHarness; +let harnessClose: Promise | undefined; beforeAll(async () => { harness = await startGatewayServerHarness(); }); afterAll(async () => { - await harness.close(); + await (harnessClose ?? harness.close()); }); describe("gateway server health/presence", () => { @@ -186,20 +187,6 @@ describe("gateway server health/presence", () => { ws.close(); }); - test("shutdown event is broadcast on close", { timeout: PRESENCE_EVENT_TIMEOUT_MS }, async () => { - const localHarness = await startGatewayServerHarness(); - const { ws } = await localHarness.openClient(); - const shutdownP = onceMessage( - ws, - (o) => o.type === "event" && o.event === "shutdown", - SHUTDOWN_EVENT_TIMEOUT_MS, - ); - await localHarness.close(); - const evt = await shutdownP; - const evtPayload = evt.payload as { reason?: unknown } | undefined; - expect(evtPayload?.reason).toBe("gateway stopping"); - }); - test( "presence broadcast reaches multiple clients", { timeout: PRESENCE_EVENT_TIMEOUT_MS }, @@ -313,4 +300,19 @@ describe("gateway server health/presence", () => { ws.close(); }); + + // Close the suite owner last; another startup would reset process-wide config under live peers. + test("shutdown event is broadcast on close", { timeout: PRESENCE_EVENT_TIMEOUT_MS }, async () => { + const { ws } = await harness.openClient(); + const shutdownP = onceMessage( + ws, + (o) => o.type === "event" && o.event === "shutdown", + SHUTDOWN_EVENT_TIMEOUT_MS, + ); + harnessClose = harness.close(); + await harnessClose; + const evt = await shutdownP; + const evtPayload = evt.payload as { reason?: unknown } | undefined; + expect(evtPayload?.reason).toBe("gateway stopping"); + }); }); diff --git a/src/gateway/server/client-presence.test.ts b/src/gateway/server/client-presence.test.ts new file mode 100644 index 000000000000..00cf6ec504e7 --- /dev/null +++ b/src/gateway/server/client-presence.test.ts @@ -0,0 +1,235 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + listSystemPresence, + updateSystemPresence, + upsertPresence, +} from "../../infra/system-presence.js"; +import { recordClientPresenceActivity, refreshClientPresence } from "./client-presence.js"; +import { GatewayClientRegistry } from "./client-registry.js"; +import { attachGatewayWsConnectionHandler } from "./ws-connection.js"; +import { + attachGatewayWsForTest, + createGatewayWsTestRequestContext, +} from "./ws-connection.test-helpers.js"; +import type { GatewayWsMessageHandlerParams } from "./ws-connection/message-handler.js"; +import type { GatewayWsClient } from "./ws-types.js"; + +const { attachMessageHandler } = vi.hoisted(() => ({ + attachMessageHandler: vi.fn<(params: GatewayWsMessageHandlerParams) => void>(), +})); +vi.mock("./ws-connection/message-handler.js", () => ({ + attachGatewayWsMessageHandler: attachMessageHandler, +})); + +describe("live person presence timing", () => { + const clients = new GatewayClientRegistry(); + const sockets: ReturnType["socket"][] = []; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2040-01-01T00:00:00Z")); + attachMessageHandler.mockClear(); + }); + afterEach(() => { + for (const socket of sockets.splice(0)) { + socket.readyState = 3; + socket.emit("close", 1000, Buffer.alloc(0)); + } + clients.clear(); + vi.setSystemTime(Date.now() + 300_001); + listSystemPresence(); + vi.useRealTimers(); + }); + + async function connect(email: string, profileId = "timing-person") { + const { socket } = attachGatewayWsForTest({ + attach: attachGatewayWsConnectionHandler, + clients, + options: { + buildRequestContext: () => + createGatewayWsTestRequestContext({ + nodeRegistry: { get: vi.fn(() => undefined), unregister: vi.fn(() => null) } as never, + }) as never, + }, + }); + sockets.push(socket); + await vi.dynamicImportSettled(); + const handler = attachMessageHandler.mock.lastCall?.[0]; + if (!handler) { + throw new Error("message handler was not attached"); + } + const client: GatewayWsClient = { + socket: socket as unknown as GatewayWsClient["socket"], + connId: handler.connId, + presenceKey: handler.connId, + usesSharedGatewayAuth: false, + connect: { + minProtocol: 1, + maxProtocol: 1, + role: "operator", + client: { id: "openclaw-control-ui", version: "test", platform: "test", mode: "webchat" }, + }, + authenticatedUserId: email, + authenticatedUserProfile: { + profileId, + displayName: "Timing Person", + avatarRevision: "1", + hasAvatar: false, + updatedAt: 1, + }, + }; + return { client, handler, socket }; + } + + function row(email: string) { + return listSystemPresence().find((entry) => entry.user?.email === email); + } + + it("retains the oldest online interval across overlapping sockets but not a full reconnect", async () => { + const first = await connect("first@timing.test"); + const started = Date.now(); + expect(first.handler.setClient(first.client)).toBe(true); + expect(row("first@timing.test")).toMatchObject({ onlineSince: started }); + expect(row("first@timing.test")?.lastActivityAt).toBeUndefined(); + + vi.setSystemTime(started + 1_000); + const second = await connect("second@timing.test"); + expect(second.handler.setClient(second.client)).toBe(true); + expect(recordClientPresenceActivity(clients, second.client)).toBe(true); + first.socket.readyState = 3; + first.socket.emit("close", 1000, Buffer.alloc(0)); + + vi.setSystemTime(started + 2_000); + const third = await connect("third@timing.test"); + expect(third.handler.setClient(third.client)).toBe(true); + expect(row("third@timing.test")).toMatchObject({ + onlineSince: started, + lastActivityAt: started + 1_000, + }); + for (const connection of [second, third]) { + connection.socket.readyState = 3; + connection.socket.emit("close", 1000, Buffer.alloc(0)); + } + vi.setSystemTime(started + 3_000); + const fresh = await connect("fresh@timing.test"); + expect(fresh.handler.setClient(fresh.client)).toBe(true); + expect(row("fresh@timing.test")).toMatchObject({ onlineSince: started + 3_000 }); + expect(row("fresh@timing.test")?.lastActivityAt).toBeUndefined(); + }); + + it("keeps heartbeat freshness and cache eviction independent of person timing", async () => { + const first = await connect("heartbeat@timing.test", "heartbeat-person"); + const started = Date.now(); + first.handler.setClient(first.client); + recordClientPresenceActivity(clients, first.client); + vi.setSystemTime(started + 10_000); + first.socket.emit("pong"); + updateSystemPresence({ + instanceId: first.client.presenceKey, + text: "heartbeat", + lastInputSeconds: 0, + }); + expect(row("heartbeat@timing.test")).toMatchObject({ + ts: started + 10_000, + onlineSince: started, + lastActivityAt: started, + }); + + vi.setSystemTime(started + 20_000); + for (let index = 0; index < 201; index++) { + upsertPresence(`timing-eviction-${index}`, { text: "cache pressure" }); + } + expect(row("heartbeat@timing.test")).toBeUndefined(); + const overlap = await connect("eviction@timing.test", "heartbeat-person"); + overlap.handler.setClient(overlap.client); + expect(row("eviction@timing.test")).toMatchObject({ + onlineSince: started, + lastActivityAt: started, + }); + + vi.setSystemTime(started + 400_000); + expect(row("eviction@timing.test")).toBeUndefined(); + expect(recordClientPresenceActivity(clients, overlap.client)).toBe(true); + expect(row("eviction@timing.test")).toMatchObject({ + onlineSince: started, + lastActivityAt: started + 400_000, + }); + }); + + it("keeps delayed identity timing on its accepted socket without reviving closed clients", async () => { + const delayed = await connect("pending@timing.test", "delayed-person"); + const profile = delayed.client.authenticatedUserProfile; + delete delayed.client.authenticatedUserProfile; + delayed.client.authenticatedGitHubIdentitySync = async () => ({ + profileId: "delayed-person", + updatedAt: 1, + }); + const started = Date.now(); + delayed.handler.setClient(delayed.client); + expect(row("pending@timing.test")).toBeUndefined(); + expect(recordClientPresenceActivity(clients, delayed.client)).toBe(false); + vi.setSystemTime(started + 1_000); + delayed.client.authenticatedUserProfile = profile; + refreshClientPresence(clients, delayed.client); + expect(row("pending@timing.test")).toMatchObject({ onlineSince: started }); + delayed.socket.readyState = 3; + delayed.socket.emit("close", 1000, Buffer.alloc(0)); + vi.setSystemTime(started + 400_000); + expect(row("pending@timing.test")).toBeUndefined(); + expect(refreshClientPresence(clients, delayed.client)).toBe(false); + expect(recordClientPresenceActivity(clients, delayed.client)).toBe(false); + expect(row("pending@timing.test")).toBeUndefined(); + }); + + it("rejects copied, invalidated, closing, and unregistered clients without changing activity", async () => { + const live = await connect("exact@timing.test", "exact-person"); + live.handler.setClient(live.client); + expect(recordClientPresenceActivity(clients, { ...live.client })).toBe(false); + live.client.invalidated = true; + expect(recordClientPresenceActivity(clients, live.client)).toBe(false); + live.client.invalidated = false; + live.socket.readyState = 2; + expect(recordClientPresenceActivity(clients, live.client)).toBe(false); + expect(row("exact@timing.test")?.lastActivityAt).toBeUndefined(); + const rejected = await connect("rejected@timing.test"); + rejected.socket.emit("close", 1000, Buffer.alloc(0)); + expect(rejected.handler.setClient(rejected.client)).toBe(false); + expect(rejected.client.personPresence).toBeUndefined(); + expect(row("rejected@timing.test")).toBeUndefined(); + }); + + it.each(["ephemeral", "unidentified", "node"])( + "does not create person timing for %s clients", + async (kind) => { + const connection = await connect(`${kind}@timing.test`); + if (kind === "ephemeral") { + delete connection.client.presenceKey; + } else if (kind === "node") { + connection.client.connect.role = "node"; + } else { + delete connection.client.authenticatedUserId; + delete connection.client.authenticatedUserProfile; + } + expect(connection.handler.setClient(connection.client)).toBe(true); + expect(connection.client.personPresence).toBeUndefined(); + expect(recordClientPresenceActivity(clients, connection.client)).toBe(false); + }, + ); + + it("does not refresh a node's heartbeat or timing when its person is active", async () => { + const node = await connect("node@timing.test", "node-person"); + node.client.connect.role = "node"; + node.handler.setClient(node.client); + const nodeHeartbeat = Date.now(); + upsertPresence(node.client.presenceKey!, { + user: { id: "node-person", email: "node@timing.test" }, + }); + vi.setSystemTime(nodeHeartbeat + 1_000); + const person = await connect("person@timing.test", "node-person"); + person.handler.setClient(person.client); + recordClientPresenceActivity(clients, person.client); + expect(row("node@timing.test")).toMatchObject({ ts: nodeHeartbeat }); + expect(row("node@timing.test")?.onlineSince).toBeUndefined(); + expect(row("node@timing.test")?.lastActivityAt).toBeUndefined(); + }); +}); diff --git a/src/gateway/server/client-presence.ts b/src/gateway/server/client-presence.ts new file mode 100644 index 000000000000..bf21798c9284 --- /dev/null +++ b/src/gateway/server/client-presence.ts @@ -0,0 +1,80 @@ +import { upsertPresence } from "../../infra/system-presence.js"; +import { buildAuthenticatedPresenceUser } from "../authenticated-presence-user.js"; +import { WEBSOCKET_OPEN_READY_STATE } from "../server-constants.js"; +import type { GatewayClient } from "../server-methods/types.js"; +import type { GatewayWsClient } from "./ws-types.js"; + +function isLiveClient(client: GatewayWsClient): boolean { + return !client.invalidated && client.socket.readyState === WEBSOCKET_OPEN_READY_STATE; +} + +function presenceIdentity(client: GatewayWsClient): string | undefined { + return ( + client.authenticatedUserProfile?.profileId ?? + (client.authenticatedGitHubIdentitySync ? undefined : client.authenticatedUserId) + ); +} + +/** Reconciles canonical identity and timing using only currently registered sockets. */ +export function refreshClientPresence( + clients: ReadonlySet, + client: GatewayWsClient, +): boolean { + if (!clients.has(client) || !isLiveClient(client) || !client.presenceKey) { + return false; + } + const identity = presenceIdentity(client); + if (!identity || !client.authenticatedUserId) { + return false; + } + const peers = [...clients].filter( + (peer) => + isLiveClient(peer) && + peer.presenceKey && + presenceIdentity(peer) === identity && + (peer === client || (client.personPresence && peer.personPresence)), + ); + const timing = client.personPresence; + for (const peer of peers) { + if (timing && peer.personPresence) { + timing.onlineSince = Math.min(timing.onlineSince, peer.personPresence.onlineSince); + const activity = peer.personPresence.lastActivityAt; + if (activity !== undefined) { + timing.lastActivityAt = Math.max(timing.lastActivityAt ?? activity, activity); + } + } + } + for (const peer of peers) { + // Nodes retain their device lifecycle. Only person sockets share the interval, + // including its original start after the oldest socket closes or a profile merges. + if (timing && peer.personPresence) { + peer.personPresence = timing; + } + upsertPresence(peer.presenceKey!, { + user: buildAuthenticatedPresenceUser(peer), + ...peer.personPresence, + }); + } + return true; +} + +/** Records accepted human activity; copies and clients closed during admission cannot write. */ +export function recordClientPresenceActivity( + clients: ReadonlySet, + client: GatewayClient | null, +): boolean { + for (const live of clients) { + if ( + live !== client || + !isLiveClient(live) || + !live.presenceKey || + !live.personPresence || + !presenceIdentity(live) + ) { + continue; + } + live.personPresence.lastActivityAt = Date.now(); + return refreshClientPresence(clients, live); + } + return false; +} diff --git a/src/gateway/server/ws-connection.startup.test.ts b/src/gateway/server/ws-connection.startup.test.ts index d6171194ffa7..037c1953ba69 100644 --- a/src/gateway/server/ws-connection.startup.test.ts +++ b/src/gateway/server/ws-connection.startup.test.ts @@ -130,8 +130,6 @@ async function attachStartupNodeConnect(params: { const requestContext = { ...createGatewayWsTestRequestContext(), nodeRegistry, - broadcast: vi.fn(), - nodeUnsubscribeAll: vi.fn(), }; const pendingSetup = vi.fn(params.isPendingWorkerNodeSetup); attachGatewayWsForTest({ diff --git a/src/gateway/server/ws-connection.test-helpers.ts b/src/gateway/server/ws-connection.test-helpers.ts index 758220416354..2ca91b172997 100644 --- a/src/gateway/server/ws-connection.test-helpers.ts +++ b/src/gateway/server/ws-connection.test-helpers.ts @@ -51,6 +51,9 @@ export function createGatewayWsTestRequestContext( unsubscribeAllSessionEvents: vi.fn(), nodeRegistry: overrides.nodeRegistry ?? { unregister: vi.fn() }, nodeUnsubscribeAll: vi.fn(), + broadcast: vi.fn(), + incrementPresenceVersion: vi.fn(() => 1), + getHealthVersion: vi.fn(() => 1), }; } diff --git a/src/gateway/server/ws-connection.ts b/src/gateway/server/ws-connection.ts index a4415e8bf672..2521f52f2753 100644 --- a/src/gateway/server/ws-connection.ts +++ b/src/gateway/server/ws-connection.ts @@ -35,6 +35,7 @@ import { } from "../stale-install.js"; import { cleanupTalkConnection } from "../talk-session-registry.js"; import { formatForLog, logWs } from "../ws-log.js"; +import { refreshClientPresence } from "./client-presence.js"; import { getHealthVersion, incrementPresenceVersion } from "./health-state.js"; import type { PreauthConnectionBudget } from "./preauth-connection-budget.js"; import { broadcastPresenceSnapshot } from "./presence-events.js"; @@ -512,12 +513,7 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti context.terminalSessions?.handleDisconnect(connId); let currentDisconnectedNodeId: string | null = null; let disconnectedNodeHistory: - | { - nodeId: string; - connectedAtMs: number; - disconnectedAtMs: number; - pairingGeneration: string; - } + | Parameters[0] | undefined; if (client?.connect?.role === "node") { const nodeId = client.connect.device?.id ?? client.connect.client.id; @@ -527,7 +523,10 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti nodeId: nodeSession.nodeId, connectedAtMs: nodeSession.connectedAtMs, disconnectedAtMs: Date.now(), - pairingGeneration: nodeSession.pairingGeneration, + expectedPairingGeneration: { + nodeId: nodeSession.nodeId, + key: nodeSession.pairingGeneration, + }, }; } // Retire I/O now, but retain revocation until admitted lifecycle work drains. @@ -569,15 +568,7 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti currentDisconnectedNodeId === disconnectedNodeHistory.nodeId ) { try { - await recordPairedNodeDisconnection({ - nodeId: disconnectedNodeHistory.nodeId, - connectedAtMs: disconnectedNodeHistory.connectedAtMs, - disconnectedAtMs: disconnectedNodeHistory.disconnectedAtMs, - expectedPairingGeneration: { - nodeId: disconnectedNodeHistory.nodeId, - key: disconnectedNodeHistory.pairingGeneration, - }, - }); + await recordPairedNodeDisconnection(disconnectedNodeHistory); } catch (error) { logGateway.warn( `failed to record node disconnect for ${disconnectedNodeHistory.nodeId}: ${formatForLog(error)}`, @@ -628,6 +619,10 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti releasePreauthBudget(); client = next; clients.add(next); + if (next.presenceKey && next.authenticatedUserId && next.connect.role !== "node") { + next.personPresence = { onlineSince: Date.now() }; + refreshClientPresence(clients, next); + } pingTimer = setInterval(() => { // A half-open TCP connection can remain OPEN indefinitely. Terminate // after one missed pong so the normal close handler releases node state. diff --git a/src/gateway/server/ws-connection/connect-session.ts b/src/gateway/server/ws-connection/connect-session.ts index 48a8dca5b277..04e35b5a438d 100644 --- a/src/gateway/server/ws-connection/connect-session.ts +++ b/src/gateway/server/ws-connection/connect-session.ts @@ -50,10 +50,10 @@ import { setClientPluginNodeCapability, type PluginNodeCapabilitySurface, } from "../../plugin-node-capability.js"; -import { MAX_PAYLOAD_BYTES } from "../../server-constants.js"; +import { MAX_PAYLOAD_BYTES, WEBSOCKET_OPEN_READY_STATE } from "../../server-constants.js"; import { formatForLog, logWs } from "../../ws-log.js"; import { truncateCloseReason } from "../close-reason.js"; -import { incrementPresenceVersion } from "../health-state.js"; +import { broadcastPresenceSnapshot } from "../presence-events.js"; import type { GatewayWsClient } from "../ws-types.js"; import { resolveEffectiveConnectionScopes } from "./connect-admission.js"; import { sendGatewayHello } from "./connect-hello.js"; @@ -214,7 +214,7 @@ export async function attachAuthenticatedGatewayConnect( : ensureProfileForEmail(authenticatedUserId); const profileId = "profileId" in profile ? profile.profileId : profile.id; const display = getUserProfileDisplay(profileId); - // User edits become visible after reconnect; detached provider-avatar adoption refreshes below. + // The live profile callback refreshes edits and detached provider-avatar adoption. authenticatedUserProfile = { profileId: display.id, displayName: display.displayName, @@ -434,6 +434,14 @@ export async function attachAuthenticatedGatewayConnect( }; attachGatewayLocalUserIngress(nextClient, localUserIngress); const attachAuthenticatedProfile = (profileId: string, updatedAt: number) => { + if ( + isClosed() || + context.handler.getClient() !== nextClient || + nextClient.invalidated || + socket.readyState !== WEBSOCKET_OPEN_READY_STATE + ) { + return; + } const display = getUserProfileDisplay(profileId); const profile = { profileId: display.id, @@ -599,7 +607,9 @@ export async function attachAuthenticatedGatewayConnect( ...(authenticatedPresenceUser ? { user: authenticatedPresenceUser } : {}), reason: "connect", }); - incrementPresenceVersion(); + // Publish the completed row before hello snapshots it; existing readers do + // not receive this connection's hello and must not wait for later activity. + broadcastPresenceSnapshot(buildRequestContext()); } if (admittedNodePairing) { const pairingGeneration = admittedNodePairing.generation?.key; diff --git a/src/gateway/server/ws-connection/message-handler.control-ui-build-admission.test.ts b/src/gateway/server/ws-connection/message-handler.control-ui-build-admission.test.ts index 8694fde66a06..4afe38640bcd 100644 --- a/src/gateway/server/ws-connection/message-handler.control-ui-build-admission.test.ts +++ b/src/gateway/server/ws-connection/message-handler.control-ui-build-admission.test.ts @@ -40,7 +40,10 @@ vi.mock("../../../config/config.js", () => ({ loadConfig: () => gatewayConfig, })); vi.mock("../../../config/io.js", () => ({ getRuntimeConfig: () => gatewayConfig })); -vi.mock("../../../infra/system-presence.js", () => ({ upsertPresence: upsertPresenceMock })); +vi.mock("../../../infra/system-presence.js", () => ({ + upsertPresence: upsertPresenceMock, + listSystemPresence: vi.fn(() => []), +})); vi.mock("../../../state/user-profiles.js", () => ({ adoptTailscaleProfileAvatar: vi.fn(), ensureProfileForEmail: vi.fn(async () => ({ @@ -77,7 +80,6 @@ vi.mock("../health-state.js", () => ({ })), getHealthCache: vi.fn(() => null), getHealthVersion: vi.fn(() => 1), - incrementPresenceVersion: incrementPresenceVersionMock, })); vi.mock("../../../version.js", async (importOriginal) => { const actual = await importOriginal(); @@ -211,7 +213,12 @@ describe("Control UI build admission over WebSocket", () => { gatewayMethods: [], events: [], extraHandlers: {}, - buildRequestContext: () => ({ broadcast: vi.fn() }) as unknown as GatewayRequestContext, + buildRequestContext: () => + ({ + broadcast: vi.fn(), + incrementPresenceVersion: incrementPresenceVersionMock, + getHealthVersion: () => 1, + }) as unknown as GatewayRequestContext, nodeLifecycleDispatch: new GatewayNodeLifecycleDispatchTracker(), refreshHealthSnapshot: vi.fn(), send, 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 88b458b3dcda..7d8ca20c3b41 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 @@ -127,6 +127,7 @@ vi.mock("../../../config/io.js", () => ({ })); vi.mock("../../../infra/system-presence.js", () => ({ upsertPresence: upsertPresenceMock, + listSystemPresence: vi.fn(() => []), })); vi.mock("../../server-methods.js", () => ({ @@ -137,7 +138,6 @@ vi.mock("../health-state.js", () => ({ buildGatewaySnapshot: buildGatewaySnapshotMock, getHealthCache: getHealthCacheMock, getHealthVersion: getHealthVersionMock, - incrementPresenceVersion: incrementPresenceVersionMock, })); import { attachGatewayWsMessageHandler } from "./message-handler.js"; @@ -288,6 +288,7 @@ function attachGatewayHarness(options: { }); let onMessage: ((data: string) => void) | undefined; const socket = { + readyState: 1, _receiver: {}, send: socketSend, on: vi.fn((event: string, handler: (data: string) => void) => { @@ -360,7 +361,13 @@ function attachGatewayHarness(options: { gatewayMethods: [], events: [], extraHandlers: {}, - buildRequestContext: () => ({ refreshConnectedUserProfile }) as never, + buildRequestContext: () => + ({ + refreshConnectedUserProfile, + broadcast: vi.fn(), + incrementPresenceVersion: incrementPresenceVersionMock, + getHealthVersion: getHealthVersionMock, + }) as never, nodeLifecycleDispatch: new GatewayNodeLifecycleDispatchTracker(), refreshHealthSnapshot: options.refreshHealthSnapshot ?? vi.fn(async () => createHealthSummary()), @@ -974,88 +981,102 @@ describe("attachGatewayWsMessageHandler post-connect health refresh", () => { }); }); - it("completes GitHub-authenticated login before deferred identity sync", async () => { - await withOpenClawTestState({ label: "gateway-github-profile-deferred" }, async () => { - const canonical = ensureProfileForEmail("canonical@example.test"); - let finishSync: (() => void) | undefined; - const sync = vi.fn( - async () => - await new Promise<{ profileId: string; updatedAt: number }>((resolve) => { - finishSync = () => resolve({ profileId: canonical.id, updatedAt: canonical.updatedAt }); - }), - ); - createAuthenticatedGitHubIdentitySyncMock.mockReturnValueOnce(sync); - resolveConnectAuthStateMock.mockResolvedValueOnce({ - authResult: { - ok: true, - method: "tailscale", - user: "ada@github", - tailscaleIdentity: { login: "ada@github", name: "Ada Lovelace" }, - }, - authOk: true, - authMethod: "tailscale", - sharedAuthOk: true, - }); - const harness = attachGatewayHarness({ - connId: "conn-github-identity-detached", - connectNonce: "nonce-github-identity-detached", - }); - - harness.sendConnect("connect-github-identity-detached", { - minProtocol: PROTOCOL_VERSION, - maxProtocol: PROTOCOL_VERSION, - client: { - id: "test", - version: "dev", - platform: "test", - mode: "test", - }, - role: "operator", - caps: [], - }); - - await waitForFast(() => { - expect(harness.socketSend).toHaveBeenCalled(); - expect(harness.client).toMatchObject({ - authenticatedUserId: "ada@github", - authenticatedGitHubIdentitySync: expect.any(Function), - }); - expect(harness.client).not.toHaveProperty("authenticatedUserProfile"); - expect(localUserIngressFor(harness.client)).toMatchObject({ - facts: { invoker: { state: "unknown" } }, - }); - expect(createAuthenticatedGitHubIdentitySyncMock).toHaveBeenCalledWith( - expect.objectContaining({ - authResult: expect.objectContaining({ method: "tailscale", user: "ada@github" }), - }), + it.each([false, true])( + "completes deferred identity sync only while its socket is live (closed=%s)", + async (closedBeforeSync) => { + await withOpenClawTestState({ label: "gateway-github-profile-deferred" }, async () => { + const canonical = ensureProfileForEmail("canonical@example.test"); + let finishSync: (() => void) | undefined; + const sync = vi.fn( + async () => + await new Promise<{ profileId: string; updatedAt: number }>((resolve) => { + finishSync = () => + resolve({ profileId: canonical.id, updatedAt: canonical.updatedAt }); + }), ); - expect(sync).toHaveBeenCalledOnce(); - }); - const initialPresence = upsertPresenceMock.mock.calls.find( - ([key]) => key === "conn-github-identity-detached", - )?.[1]; - expect(initialPresence).not.toHaveProperty("user"); - expect(harness.socketSend.mock.invocationCallOrder[0]).toBeLessThan( - sync.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, - ); - expect(finishSync).toBeTypeOf("function"); - finishSync?.(); - - await waitForFast(() => { - expect(harness.client).toMatchObject({ - authenticatedUserProfile: { profileId: canonical.id }, - }); - expect(localUserIngressFor(harness.client)).toMatchObject({ - facts: { - invoker: { state: "present", kind: "person", rawPrincipalRef: canonical.id }, + createAuthenticatedGitHubIdentitySyncMock.mockReturnValueOnce(sync); + resolveConnectAuthStateMock.mockResolvedValueOnce({ + authResult: { + ok: true, + method: "tailscale", + user: "ada@github", + tailscaleIdentity: { login: "ada@github", name: "Ada Lovelace" }, }, + authOk: true, + authMethod: "tailscale", + sharedAuthOk: true, }); - expect(harness.refreshConnectedUserProfile).toHaveBeenCalledWith( - expect.objectContaining({ id: canonical.id }), + let closed = false; + const harness = attachGatewayHarness({ + connId: "conn-github-identity-detached", + connectNonce: "nonce-github-identity-detached", + isClosed: () => closed, + }); + + harness.sendConnect("connect-github-identity-detached", { + minProtocol: PROTOCOL_VERSION, + maxProtocol: PROTOCOL_VERSION, + client: { + id: "test", + version: "dev", + platform: "test", + mode: "test", + }, + role: "operator", + caps: [], + }); + + await waitForFast(() => { + expect(harness.socketSend).toHaveBeenCalled(); + expect(harness.client).toMatchObject({ + authenticatedUserId: "ada@github", + authenticatedGitHubIdentitySync: expect.any(Function), + }); + expect(harness.client).not.toHaveProperty("authenticatedUserProfile"); + expect(localUserIngressFor(harness.client)).toMatchObject({ + facts: { invoker: { state: "unknown" } }, + }); + expect(createAuthenticatedGitHubIdentitySyncMock).toHaveBeenCalledWith( + expect.objectContaining({ + authResult: expect.objectContaining({ method: "tailscale", user: "ada@github" }), + }), + ); + expect(sync).toHaveBeenCalledOnce(); + }); + const initialPresence = upsertPresenceMock.mock.calls.find( + ([key]) => key === "conn-github-identity-detached", + )?.[1]; + expect(initialPresence).not.toHaveProperty("user"); + expect(harness.socketSend.mock.invocationCallOrder[0]).toBeLessThan( + sync.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, ); + expect(finishSync).toBeTypeOf("function"); + closed = closedBeforeSync; + finishSync?.(); + + if (closedBeforeSync) { + await vi.dynamicImportSettled(); + expect(harness.client).not.toHaveProperty("authenticatedUserProfile"); + expect(harness.refreshConnectedUserProfile).not.toHaveBeenCalled(); + return; + } + + await waitForFast(() => { + expect(harness.client).toMatchObject({ + authenticatedUserProfile: { profileId: canonical.id }, + }); + expect(localUserIngressFor(harness.client)).toMatchObject({ + facts: { + invoker: { state: "present", kind: "person", rawPrincipalRef: canonical.id }, + }, + }); + expect(harness.refreshConnectedUserProfile).toHaveBeenCalledWith( + expect.objectContaining({ id: canonical.id }), + ); + }); }); - }); - }); + }, + ); it("resolves a GitHub-backed role before registering the connection or sending hello", async () => { await withOpenClawTestState({ label: "gateway-github-role-before-hello" }, async () => { diff --git a/src/gateway/server/ws-connection/message-handler.suspension-admission.test.ts b/src/gateway/server/ws-connection/message-handler.suspension-admission.test.ts index 9a6641d24ee7..4a14c66e02b7 100644 --- a/src/gateway/server/ws-connection/message-handler.suspension-admission.test.ts +++ b/src/gateway/server/ws-connection/message-handler.suspension-admission.test.ts @@ -12,8 +12,7 @@ import { import type { GatewayRequestContext } from "../../server-methods/types.js"; import { GatewayNodeLifecycleDispatchTracker } from "./node-lifecycle-dispatch.js"; -const { incrementPresenceVersionMock, loadConfigMock, upsertPresenceMock } = vi.hoisted(() => ({ - incrementPresenceVersionMock: vi.fn(() => 2), +const { loadConfigMock, upsertPresenceMock } = vi.hoisted(() => ({ loadConfigMock: vi.fn(() => ({ gateway: { auth: { mode: "none" } } })), upsertPresenceMock: vi.fn(), })); @@ -27,6 +26,7 @@ vi.mock("../../../config/io.js", () => ({ })); vi.mock("../../../infra/system-presence.js", () => ({ upsertPresence: upsertPresenceMock, + listSystemPresence: vi.fn(() => []), })); vi.mock("../health-state.js", () => ({ buildGatewaySnapshot: vi.fn(() => ({ @@ -43,7 +43,6 @@ vi.mock("../health-state.js", () => ({ })), getHealthCache: vi.fn(() => null), getHealthVersion: vi.fn(() => 1), - incrementPresenceVersion: incrementPresenceVersionMock, })); import { attachGatewayWsMessageHandler } from "./message-handler.js"; @@ -111,6 +110,7 @@ function attachHarness(params: { deferSocketSend?: boolean; startupPending?: boo gatewayMethods: [], events: [], extraHandlers: {}, + // Backend admission cases never publish presence; hello is mocked above. buildRequestContext: () => ({}) as GatewayRequestContext, nodeLifecycleDispatch: new GatewayNodeLifecycleDispatchTracker(), refreshHealthSnapshot: vi.fn(async () => ({}) as never), @@ -266,7 +266,6 @@ describe("WebSocket connect suspension admission", () => { expect(harness.client).toBeNull(); expect(harness.setClient).not.toHaveBeenCalled(); expect(upsertPresenceMock).not.toHaveBeenCalled(); - expect(incrementPresenceVersionMock).not.toHaveBeenCalled(); await vi.waitFor(() => { expect(harness.close).toHaveBeenCalledWith(1013, "gateway suspension in progress"); }); diff --git a/src/gateway/server/ws-types.ts b/src/gateway/server/ws-types.ts index f1098172a110..ac2586c3eb4f 100644 --- a/src/gateway/server/ws-types.ts +++ b/src/gateway/server/ws-types.ts @@ -34,6 +34,8 @@ export type GatewayWsClient = PluginNodeCapabilityClient & { usesSharedGatewayAuth: boolean; sharedGatewaySessionGeneration?: string; presenceKey?: string; + /** Shared by overlapping identified person sockets; never owned by the presence TTL cache. */ + personPresence?: { onlineSince: number; lastActivityAt?: number }; authenticatedUserId?: string; /** Verified Tailscale provider identity; generic proxy identities must not infer this. */ authenticatedUserIsTailscaleProvider?: boolean; diff --git a/src/gateway/session-viewer-presence.test.ts b/src/gateway/session-viewer-presence.test.ts index 68704c82843a..f128cd1957eb 100644 --- a/src/gateway/session-viewer-presence.test.ts +++ b/src/gateway/session-viewer-presence.test.ts @@ -1,55 +1,101 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { listSystemPresence } from "../infra/system-presence.js"; +import { GatewayClientRegistry } from "./server/client-registry.js"; +import type { GatewayWsClient } from "./server/ws-types.js"; import { createSessionViewerPresenceDeclarations } from "./session-viewer-presence.js"; +function createDeclarations() { + const client: GatewayWsClient = { + connId: "conn-a", + presenceKey: "viewer-timing", + usesSharedGatewayAuth: false, + socket: { readyState: 1 } as GatewayWsClient["socket"], + connect: { + minProtocol: 1, + maxProtocol: 1, + role: "operator", + client: { id: "openclaw-control-ui", version: "test", platform: "test", mode: "webchat" }, + }, + authenticatedUserId: "viewer@timing.test", + personPresence: { onlineSince: Date.now() - 1_000 }, + }; + const clients = new GatewayClientRegistry([client]); + const broadcast = vi.fn(); + const incrementPresenceVersion = vi.fn(() => 2); + const declarations = createSessionViewerPresenceDeclarations({ + clients, + broadcast, + incrementPresenceVersion, + getHealthVersion: () => 1, + }); + const row = () => listSystemPresence().find((entry) => entry.user?.id === "viewer@timing.test"); + return { declarations, client, clients, broadcast, incrementPresenceVersion, row }; +} + describe("session viewer presence declarations", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2041-01-01T00:00:00Z")); + }); + afterEach(() => { + vi.setSystemTime(Date.now() + 300_001); + listSystemPresence(); + vi.useRealTimers(); + }); + it("replaces rather than accumulates connection session keys", () => { - const onReplace = vi.fn(); - const declarations = createSessionViewerPresenceDeclarations({ onReplace }); + const { declarations, broadcast, row } = createDeclarations(); expect(declarations.replace("conn-a", [" beta ", "alpha", "beta"])).toEqual(["alpha", "beta"]); + expect(row()?.watchedSessions).toEqual(["alpha", "beta"]); + vi.setSystemTime(Date.now() + 1_000); expect(declarations.replace("conn-a", ["gamma"])).toEqual(["gamma"]); - expect(onReplace.mock.calls).toEqual([ - ["conn-a", ["alpha", "beta"]], - ["conn-a", ["gamma"]], - ]); + expect(row()).toMatchObject({ watchedSessions: ["gamma"], lastActivityAt: Date.now() }); + expect(broadcast).toHaveBeenCalledTimes(2); }); it("publishes an empty declaration and forgets state on disconnect", () => { - const onReplace = vi.fn(); - const declarations = createSessionViewerPresenceDeclarations({ onReplace }); + const { declarations, broadcast, row } = createDeclarations(); declarations.replace("conn-a", ["alpha"]); + const activity = row()?.lastActivityAt; + vi.setSystemTime(Date.now() + 1_000); declarations.replace("conn-a", []); + expect(row()?.watchedSessions).toBeUndefined(); + expect(row()?.lastActivityAt).toBe(activity); declarations.replace("conn-a", ["beta"]); + const nextActivity = row()?.lastActivityAt; + vi.setSystemTime(Date.now() + 1_000); declarations.unsubscribe("conn-a"); + expect(row()?.lastActivityAt).toBe(nextActivity); declarations.replace("conn-a", ["beta"]); - - expect(onReplace.mock.calls).toEqual([ - ["conn-a", ["alpha"]], - ["conn-a", []], - ["conn-a", ["beta"]], - ["conn-a", ["beta"]], - ]); + expect(row()?.lastActivityAt).toBe(Date.now()); + expect(broadcast).toHaveBeenCalledTimes(4); }); it("does not republish an unchanged set", () => { - const onReplace = vi.fn(); - const declarations = createSessionViewerPresenceDeclarations({ onReplace }); + const { declarations, broadcast, incrementPresenceVersion, row } = createDeclarations(); declarations.replace("conn-a", ["beta", "alpha"]); + const activity = row()?.lastActivityAt; + vi.setSystemTime(Date.now() + 1_000); declarations.replace("conn-a", ["alpha", "beta"]); - expect(onReplace).toHaveBeenCalledOnce(); + expect(row()?.lastActivityAt).toBe(activity); + expect(broadcast).toHaveBeenCalledOnce(); + expect(incrementPresenceVersion).toHaveBeenCalledOnce(); }); - it("rejects declarations from inactive connections", () => { - const onReplace = vi.fn(); - const declarations = createSessionViewerPresenceDeclarations({ - onReplace, - isConnectionActive: () => false, - }); - - expect(declarations.replace("conn-closed", ["alpha"])).toEqual([]); - expect(onReplace).not.toHaveBeenCalled(); + it("rejects declarations from inactive connections and after stop", () => { + const { declarations, client, clients, broadcast } = createDeclarations(); + client.invalidated = true; + expect(declarations.replace("conn-a", ["alpha"])).toEqual([]); + client.invalidated = false; + clients.delete(client); + expect(declarations.replace("conn-a", ["alpha"])).toEqual([]); + clients.add(client); + declarations.stop(); + expect(declarations.replace("conn-a", ["alpha"])).toEqual([]); + expect(broadcast).not.toHaveBeenCalled(); }); }); diff --git a/src/gateway/session-viewer-presence.ts b/src/gateway/session-viewer-presence.ts index 924271419b63..62f9e16b7bbe 100644 --- a/src/gateway/session-viewer-presence.ts +++ b/src/gateway/session-viewer-presence.ts @@ -1,9 +1,13 @@ // Per-connection viewer presence declarations. Message subscriptions are transport state, // while this replace-set records only the sessions a client is actually rendering. +import { upsertPresence } from "../infra/system-presence.js"; +import { WEBSOCKET_OPEN_READY_STATE } from "./server-constants.js"; +import { recordClientPresenceActivity } from "./server/client-presence.js"; +import type { GatewayClientRegistry } from "./server/client-registry.js"; +import { broadcastPresenceSnapshot } from "./server/presence-events.js"; -type SessionViewerPresenceDeclarationsDeps = { - onReplace: (connId: string, sessionKeys: readonly string[]) => void; - isConnectionActive?: (connId: string) => boolean; +type SessionViewerPresenceDeclarationsDeps = Parameters[0] & { + clients: GatewayClientRegistry; }; type SessionViewerPresenceDeclarations = { @@ -36,7 +40,8 @@ export function createSessionViewerPresenceDeclarations( return []; } const normalizedConnId = connId.trim(); - if (!normalizedConnId || deps.isConnectionActive?.(normalizedConnId) === false) { + const client = deps.clients.getByConnectionId(normalizedConnId); + if (!client || client.invalidated || client.socket.readyState !== WEBSOCKET_OPEN_READY_STATE) { return []; } const next = normalizedSessionKeys(sessionKeys); @@ -49,7 +54,15 @@ export function createSessionViewerPresenceDeclarations( } else { declarations.set(normalizedConnId, next); } - deps.onReplace(normalizedConnId, next); + if (client.presenceKey) { + upsertPresence(client.presenceKey, { + watchedSessions: next.length > 0 ? [...next] : undefined, + }); + if (next.length > 0) { + recordClientPresenceActivity(deps.clients, client); + } + broadcastPresenceSnapshot(deps); + } return next; }; diff --git a/src/infra/system-presence.ts b/src/infra/system-presence.ts index 99ee7a94cbc6..a52653134b96 100644 --- a/src/infra/system-presence.ts +++ b/src/infra/system-presence.ts @@ -34,7 +34,11 @@ export type SystemPresence = { avatarUrl?: string; }; watchedSessions?: string[]; + /** Server-owned timing for the person's current continuous live interval. */ + onlineSince?: number; + lastActivityAt?: number; text: string; + /** Heartbeat freshness, independent of person activity and online duration. */ ts: number; }; diff --git a/src/plugins/bundled-capability-runtime.ts b/src/plugins/bundled-capability-runtime.ts index 4f36fa681253..2abfbb4aceb5 100644 --- a/src/plugins/bundled-capability-runtime.ts +++ b/src/plugins/bundled-capability-runtime.ts @@ -6,7 +6,6 @@ import { loadOpenClawPluginsWithInternalOverrides } from "./loader-runtime-load. import type { PluginLoadOptions } from "./loader.js"; import { loadPluginManifestRegistryCore } from "./manifest-registry.js"; import type { PluginRuntime } from "./runtime/types.js"; -import type { PluginSdkResolutionPreference } from "./sdk-alias.js"; const log = createSubsystemLogger("plugins"); @@ -26,13 +25,24 @@ function createCapabilityRegistrationRuntime( }; } -export function loadBundledCapabilityRuntimeRegistry(params: { - pluginIds: readonly string[]; - env?: PluginLoadOptions["env"]; - config?: PluginLoadOptions["config"]; - pluginSdkResolution?: PluginSdkResolutionPreference; - discovery?: PluginDiscoveryResult; -}) { +export function loadBundledCapabilityRuntimeRegistry( + params: Pick< + PluginLoadOptions, + | "env" + | "config" + | "workspaceDir" + | "installRecords" + | "manifestRegistry" + | "activationSourceConfig" + | "autoEnabledReasons" + | "preferBuiltPluginArtifacts" + | "pluginSdkResolution" + > & { + pluginIds: readonly string[]; + discovery?: PluginDiscoveryResult; + }, +) { + const { pluginIds: requestedPluginIds, ...loadOptions } = params; const env = params.env ?? process.env; // Only the speech owner may opt into legacy global-disable compatibility before capture. const config = @@ -42,14 +52,25 @@ export function loadBundledCapabilityRuntimeRegistry(params: { config: params.config, pluginIds: params.pluginIds, }) ?? {}); - const discovery = params.discovery ?? discoverOpenClawPlugins({ env }); + const discovery = params.manifestRegistry + ? undefined + : (params.discovery ?? + discoverOpenClawPlugins({ + env, + workspaceDir: params.workspaceDir, + installRecords: params.installRecords, + })); const pluginIds = new Set(params.pluginIds); - const manifestRegistry = loadPluginManifestRegistryCore({ - config, - env, - candidates: discovery.candidates, - diagnostics: discovery.diagnostics, - }); + const manifestRegistry = + params.manifestRegistry ?? + loadPluginManifestRegistryCore({ + config, + env, + workspaceDir: params.workspaceDir, + installRecords: params.installRecords, + candidates: discovery?.candidates, + diagnostics: discovery?.diagnostics, + }); const scopedManifestRegistry = { plugins: manifestRegistry.plugins.filter( (plugin) => plugin.origin === "bundled" && pluginIds.has(plugin.id), @@ -58,10 +79,10 @@ export function loadBundledCapabilityRuntimeRegistry(params: { }; return loadOpenClawPluginsWithInternalOverrides( { + ...loadOptions, config, env, - onlyPluginIds: [...params.pluginIds], - pluginSdkResolution: params.pluginSdkResolution, + onlyPluginIds: [...requestedPluginIds], cache: false, activate: false, // Channel setup entries cannot register providers; keep their runtime entry in discovery mode. diff --git a/src/plugins/capability-provider-runtime.generation.test.ts b/src/plugins/capability-provider-runtime.generation.test.ts new file mode 100644 index 000000000000..1a92a0d47c04 --- /dev/null +++ b/src/plugins/capability-provider-runtime.generation.test.ts @@ -0,0 +1,306 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; +import { createPluginMetadataSnapshot } from "../config/plugin-auto-enable.test-helpers.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { loadGatewayPlugins } from "../gateway/server-plugins.js"; +import { withEnv } from "../test-utils/env.js"; +import { loadBundledCapabilityRuntimeRegistry } from "./bundled-capability-runtime.js"; +import { withBundledPluginEnablementCompat } from "./bundled-compat.js"; +import { + resolvePluginCapabilityProvider, + resolvePluginCapabilityProviders, +} from "./capability-provider-runtime.js"; +import { setCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-snapshot.js"; +import * as discovery from "./discovery.js"; +import * as installRecords from "./installed-plugin-index-record-reader.js"; +import { loadOpenClawPlugins } from "./loader.js"; +import { + cleanupPluginLoaderFixturesForTest, + EMPTY_PLUGIN_SCHEMA, + makePluginLoaderTempDir, + mkdirSafe, + resetPluginLoaderTestStateForTest, + writePlugin, +} from "./loader.test-fixtures.js"; +import * as manifests from "./manifest-registry.js"; +import { createEmptyPluginRegistry } from "./registry-empty.js"; +import { getActivePluginRegistry, setActivePluginRegistry } from "./runtime.js"; +import { withPluginRuntimeRegistryScope } from "./runtime/gateway-request-scope.js"; +import { + buildPluginRuntimeLoadOptions, + getPluginRuntimeLoadContext, +} from "./runtime/load-context.js"; + +const id = "fixture-speech"; +const log = { info() {}, warn() {}, error() {}, debug() {} }; + +function withSpeechFixture(run: (fixture: ReturnType) => void) { + const fixture = createSpeechFixture(); + return withEnv( + { + OPENCLAW_STATE_DIR: path.join(fixture.root, "state"), + OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(fixture.root, "extensions"), + OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR: "1", + OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined, + }, + () => run(fixture), + ); +} + +function createSpeechFixture() { + const root = fs.realpathSync(makePluginLoaderTempDir()); + const workspaceDir = path.join(root, "workspace"); + mkdirSafe(workspaceDir); + const body = (label: string) => `let registrations = 0; +export default { id: "${id}", register(api) { + api.registerSpeechProvider({ id: "${id}", label: "${label}:" + ++registrations, + isConfigured: () => false, synthesize: async () => { throw new Error("synthesis is not catalog discovery"); } }); +} };`; + const plugin = writePlugin({ + id, + dir: path.join(root, "extensions", id), + filename: "index.ts", + body: body("source"), + }); + const manifestPath = path.join(plugin.dir, "openclaw.plugin.json"); + fs.writeFileSync( + manifestPath, + JSON.stringify({ id, configSchema: EMPTY_PLUGIN_SCHEMA, contracts: { speechProviders: [id] } }), + ); + fs.writeFileSync( + path.join(plugin.dir, "package.json"), + JSON.stringify({ openclaw: { extensions: ["./index.ts"] } }), + ); + const builtDir = path.join(root, "dist", "extensions", id); + mkdirSafe(builtDir); + fs.writeFileSync(path.join(builtDir, "index.js"), body("built")); + const seed = writePlugin({ + id: "fixture-seed", + dir: path.join(root, "extensions", "fixture-seed"), + filename: "index.cjs", + body: 'module.exports = { id: "fixture-seed", register() {} };', + }); + fs.writeFileSync( + path.join(seed.dir, "package.json"), + JSON.stringify({ openclaw: { extensions: ["./index.cjs"] } }), + ); + const config: OpenClawConfig = { + agents: { defaults: { workspace: workspaceDir } }, + plugins: { enabled: false }, + }; + return { root, workspaceDir, config }; +} + +function publishMetadata(fixture: ReturnType) { + const snapshot = createPluginMetadataSnapshot({ + config: fixture.config, + workspaceDir: fixture.workspaceDir, + manifestRegistry: manifests.loadPluginManifestRegistryCore({ config: fixture.config }), + }); + setCurrentPluginMetadataSnapshot(snapshot, { + config: fixture.config, + workspaceDir: fixture.workspaceDir, + }); + return snapshot; +} + +function loadGatewayGeneration( + fixture: ReturnType, + workspaceDir = fixture.workspaceDir, + pluginIds: string[] = [], + pluginMetadataSnapshot?: ReturnType, +) { + return loadGatewayPlugins({ + cfg: fixture.config, + activationSourceConfig: fixture.config, + autoEnabledReasons: {}, + workspaceDir, + pluginIds, + pluginMetadataSnapshot, + baseMethods: [], + log, + }).pluginRegistry; +} + +const speechProviders = (cfg: OpenClawConfig) => + resolvePluginCapabilityProviders({ key: "speechProviders", cfg }); + +afterEach(() => { + vi.restoreAllMocks(); + resetPluginLoaderTestStateForTest(); +}); +afterAll(cleanupPluginLoaderFixturesForTest); + +describe("capability loading from a Gateway generation", () => { + it("uses built speech on the first disabled-plugin catalog read without rediscovery or re-registration", () => { + withSpeechFixture((fixture) => { + publishMetadata(fixture); + const registry = loadGatewayGeneration(fixture); + const discover = vi.spyOn(discovery, "discoverOpenClawPlugins"); + const readManifests = vi.spyOn(manifests, "loadPluginManifestRegistryCore"); + const readInstalls = vi.spyOn(installRecords, "loadInstalledPluginIndexInstallRecordsSync"); + withPluginRuntimeRegistryScope(registry, () => { + const first = speechProviders(fixture.config); + expect(first.map((provider) => provider.label)).toEqual(["built:1"]); + expect(speechProviders(fixture.config)).toEqual(first); + expect( + resolvePluginCapabilityProvider({ + key: "speechProviders", + providerId: id, + cfg: fixture.config, + }), + ).toBe(first[0]); + }); + expect(discover).not.toHaveBeenCalled(); + expect(readManifests).not.toHaveBeenCalled(); + expect(readInstalls).not.toHaveBeenCalled(); + expect(getActivePluginRegistry()).toBe(registry); + expect(registry.speechProviders).toEqual([]); + }); + }); + + it("extends a populated Gateway registry without loading missing speech from source", () => { + withSpeechFixture((fixture) => { + fixture.config.plugins = { enabled: true, entries: { "fixture-seed": { enabled: true } } }; + const snapshot = publishMetadata(fixture); + const startupSnapshot = createPluginMetadataSnapshot({ + config: fixture.config, + workspaceDir: fixture.workspaceDir, + manifestRegistry: { + plugins: snapshot.plugins.filter((plugin) => plugin.id === "fixture-seed"), + diagnostics: [], + }, + }); + const registry = loadGatewayPlugins({ + cfg: fixture.config, + activationSourceConfig: fixture.config, + autoEnabledReasons: {}, + workspaceDir: fixture.workspaceDir, + baseMethods: [], + log, + pluginLookUpTable: { + ...startupSnapshot, + pluginIds: ["fixture-seed"], + startup: { pluginIds: ["fixture-seed"], channelPluginIds: [] }, + workerProviderIds: [], + metrics: { ...startupSnapshot.metrics, startupPlanMs: 0, startupPluginCount: 1 }, + }, + }).pluginRegistry; + expect(getPluginRuntimeLoadContext(registry)?.metadataSnapshot).toBe(snapshot); + expect(registry.plugins).toContainEqual( + expect.objectContaining({ id: "fixture-seed", status: "loaded" }), + ); + withPluginRuntimeRegistryScope(registry, () => { + expect(speechProviders(fixture.config).map((provider) => provider.label)).toEqual([ + "built:1", + ]); + }); + }); + }); + + it("keeps the request's load context when an unrelated active registry already contains speech", () => { + withSpeechFixture((fixture) => { + publishMetadata(fixture); + const registry = loadGatewayGeneration(fixture); + const other = loadOpenClawPlugins({ + config: { ...fixture.config, plugins: { entries: { [id]: { enabled: true } } } }, + onlyPluginIds: [id], + }); + expect(other.speechProviders[0]?.provider.label).toBe("source:1"); + withPluginRuntimeRegistryScope(registry, () => { + expect(speechProviders(fixture.config).map((provider) => provider.label)).toEqual([ + "built:1", + ]); + }); + expect(getActivePluginRegistry()).toBe(other); + }); + }); + + it("carries the same metadata and artifact facts through bundled capability capture", () => { + withSpeechFixture((fixture) => { + publishMetadata(fixture); + const registry = loadGatewayGeneration(fixture); + const context = getPluginRuntimeLoadContext(registry); + expect(context).toBeDefined(); + const discover = vi.spyOn(discovery, "discoverOpenClawPlugins"); + const readManifests = vi.spyOn(manifests, "loadPluginManifestRegistryCore"); + const readInstalls = vi.spyOn(installRecords, "loadInstalledPluginIndexInstallRecordsSync"); + const captured = loadBundledCapabilityRuntimeRegistry({ + ...buildPluginRuntimeLoadOptions(context!, { + config: withBundledPluginEnablementCompat({ config: fixture.config, pluginIds: [id] }), + }), + pluginIds: [id], + }); + expect(captured.speechProviders.map((entry) => entry.provider.label)).toEqual(["built:1"]); + expect(discover).not.toHaveBeenCalled(); + expect(readManifests).not.toHaveBeenCalled(); + expect(readInstalls).not.toHaveBeenCalled(); + expect(getActivePluginRegistry()).toBe(registry); + }); + }); + + it("keeps standalone source loading even when a complete metadata snapshot exists", () => { + withSpeechFixture((fixture) => { + publishMetadata(fixture); + setActivePluginRegistry(createEmptyPluginRegistry()); + expect(speechProviders(fixture.config).map((provider) => provider.label)).toEqual([ + "source:1", + ]); + }); + }); + + it("does not borrow artifact preference from a Gateway with a different workspace", () => { + withSpeechFixture((fixture) => { + publishMetadata(fixture); + const registry = loadGatewayGeneration(fixture, path.join(fixture.root, "other-workspace")); + withPluginRuntimeRegistryScope(registry, () => { + expect(speechProviders(fixture.config).map((provider) => provider.label)).toEqual([ + "source:1", + ]); + }); + }); + }); + + it("does not borrow a replaced generation through a retained request registry", () => { + withSpeechFixture((fixture) => { + publishMetadata(fixture); + const previous = loadGatewayGeneration(fixture); + const nextSnapshot = createPluginMetadataSnapshot({ + config: fixture.config, + workspaceDir: fixture.workspaceDir, + manifestRegistry: manifests.loadPluginManifestRegistryCore({ config: fixture.config }), + }); + // Reload prepares the replacement before publishing its metadata generation. + const current = loadGatewayGeneration(fixture, fixture.workspaceDir, [], nextSnapshot); + setCurrentPluginMetadataSnapshot(nextSnapshot, { + config: fixture.config, + workspaceDir: fixture.workspaceDir, + }); + withPluginRuntimeRegistryScope(previous, () => { + expect(speechProviders(fixture.config).map((provider) => provider.label)).toEqual([ + "source:1", + ]); + }); + withPluginRuntimeRegistryScope(current, () => { + expect(speechProviders(fixture.config).map((provider) => provider.label)).toEqual([ + "built:1", + ]); + }); + }); + }); + + it.each([{ deny: [id] }, { entries: { [id]: { enabled: false } } }])( + "preserves explicit speech owner denial: %j", + (policy) => { + withSpeechFixture((fixture) => { + fixture.config.plugins = { enabled: false, ...policy }; + publishMetadata(fixture); + const registry = loadGatewayGeneration(fixture); + withPluginRuntimeRegistryScope(registry, () => { + expect(speechProviders(fixture.config)).toEqual([]); + }); + }); + }, + ); +}); diff --git a/src/plugins/capability-provider-runtime.test.ts b/src/plugins/capability-provider-runtime.test.ts index 778a602518c9..53cff6974e0e 100644 --- a/src/plugins/capability-provider-runtime.test.ts +++ b/src/plugins/capability-provider-runtime.test.ts @@ -1374,9 +1374,9 @@ describe("resolvePluginCapabilityProviders", () => { expectActiveRegistryLookup(["google"]); expect(mocks.loadBundledCapabilityRuntimeRegistry).toHaveBeenCalledWith({ pluginIds: ["google"], - env: process.env, + onlyPluginIds: ["google"], + activate: false, config: { tts: { provider: "google" } }, - pluginSdkResolution: undefined, }); }); @@ -1406,9 +1406,9 @@ describe("resolvePluginCapabilityProviders", () => { expectResolvedCapabilityProviderIds(providers, ["openai", "google"]); expect(mocks.loadBundledCapabilityRuntimeRegistry).toHaveBeenCalledWith({ pluginIds: ["google"], - env: process.env, + onlyPluginIds: ["google"], + activate: false, config: { tts: { provider: "google" } }, - pluginSdkResolution: undefined, }); }); diff --git a/src/plugins/capability-provider-runtime.ts b/src/plugins/capability-provider-runtime.ts index b056b5085c28..7601a47bac75 100644 --- a/src/plugins/capability-provider-runtime.ts +++ b/src/plugins/capability-provider-runtime.ts @@ -9,6 +9,7 @@ import { } from "./active-runtime-registry.js"; import { loadBundledCapabilityRuntimeRegistry } from "./bundled-capability-runtime.js"; import { withBundledPluginEnablementCompat } from "./bundled-compat.js"; +import { getCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-snapshot.js"; import { resolveRuntimePluginRegistry, type PluginLoadOptions } from "./loader.js"; import { hasManifestContractValue, @@ -19,6 +20,12 @@ import { import type { PluginMetadataSnapshot } from "./plugin-metadata-snapshot.types.js"; import { normalizeCapabilityProviderId } from "./provider-registry-shared.js"; import type { PluginRegistry } from "./registry-types.js"; +import { getPluginRuntimeGatewayRequestScope } from "./runtime/gateway-request-scope.js"; +import { + buildPluginRuntimeLoadOptions, + getPluginRuntimeLoadContext, + type PluginRuntimeLoadContext, +} from "./runtime/load-context.js"; type CapabilityProviderRegistryKey = | "embeddingProviders" @@ -105,17 +112,40 @@ function resolveCapabilityPluginIds(params: { function createCapabilityProviderLoadOptions(params: { cfg?: OpenClawConfig; resolution: CapabilityPluginResolution; + loadContext?: PluginRuntimeLoadContext; }): PluginLoadOptions { const pluginIds = params.resolution.bundledCompatPluginIds; const config = withBundledPluginEnablementCompat({ config: params.cfg, pluginIds, }); - return { + const overrides: PluginLoadOptions = { ...(config === undefined ? {} : { config }), onlyPluginIds: params.resolution.runtimePluginIds, activate: false, }; + return params.loadContext + ? buildPluginRuntimeLoadOptions(params.loadContext, overrides) + : overrides; +} + +function resolveCapabilityLoadContext( + registry: PluginRegistry | undefined, + cfg: OpenClawConfig | undefined, +): PluginRuntimeLoadContext | undefined { + const context = getPluginRuntimeLoadContext(registry); + if (!context?.metadataSnapshot || context.env !== process.env) { + return undefined; + } + // Validate the caller's original policy before speech compatibility derives an enabled config. + // A retained request must not borrow facts from a replaced or differently scoped generation. + return getCurrentPluginMetadataSnapshot({ + config: cfg, + workspaceDir: context.workspaceDir, + ...(cfg === undefined ? { requireDefaultDiscoveryContext: true } : {}), + }) === context.metadataSnapshot + ? context + : undefined; } function findProviderById( @@ -412,13 +442,18 @@ function loadCapabilityProviderEntries( (registry?.[params.key] ?? []).filter((entry) => allowedPluginIds.has(entry.pluginId), ) as PluginRegistry[K]; - const registry = - getLoadedRuntimePluginRegistry({ - env: params.loadOptions.env, - loadOptions: params.loadOptions, - workspaceDir: params.loadOptions.workspaceDir, - requiredPluginIds: params.loadOptions.onlyPluginIds, - }) ?? resolveRuntimePluginRegistry(params.loadOptions); + const scopedRegistry = getPluginRuntimeGatewayRequestScope()?.pluginRegistry; + const loadedRegistry = scopedRegistry + ? registryContainsRuntimePluginIds(scopedRegistry, params.loadOptions.onlyPluginIds) + ? scopedRegistry + : undefined + : getLoadedRuntimePluginRegistry({ + env: params.loadOptions.env, + loadOptions: params.loadOptions, + workspaceDir: params.loadOptions.workspaceDir, + requiredPluginIds: params.loadOptions.onlyPluginIds, + }); + const registry = loadedRegistry ?? resolveRuntimePluginRegistry(params.loadOptions); const entries = filterAllowedEntries(registry); const missingRequested = params.requested && params.requested.size > 0 ? new Set(params.requested) : undefined; @@ -433,10 +468,8 @@ function loadCapabilityProviderEntries( } const captured = filterAllowedEntries( loadBundledCapabilityRuntimeRegistry({ + ...params.loadOptions, pluginIds: params.bundledCompatPluginIds, - env: process.env, - ...(params.loadOptions.config ? { config: params.loadOptions.config } : {}), - pluginSdkResolution: params.loadOptions.pluginSdkResolution, }), ); return entries.length > 0 ? mergeCapabilityProviderEntries(entries, captured) : captured; @@ -451,7 +484,8 @@ export function resolvePluginCapabilityProvider + // SAFETY: Only the setter above writes this optional registry-owned symbol slot. + (registry as RuntimeContextRegistry | undefined)?.[pluginRuntimeLoadContext]; + /** Runtime load option values that can be passed directly to plugin loading. */ type PluginRuntimeResolvedLoadValues = Pick< PluginLoadOptions, diff --git a/ui/src/components/app-sidebar-render.ts b/ui/src/components/app-sidebar-render.ts index 4177f4dfb27b..1d0d1b0d78ed 100644 --- a/ui/src/components/app-sidebar-render.ts +++ b/ui/src/components/app-sidebar-render.ts @@ -1,11 +1,12 @@ import { html, nothing } from "lit"; +import { repeat } from "lit/directives/repeat.js"; import type { GatewayControlUiPluginTab } from "../api/gateway.ts"; import { serializeSidebarEntry, type NavigationRouteId, type SidebarZoneEntry, } from "../app-navigation.ts"; -import { activityPersonLocation, isRouteId, isSessionRouteId } from "../app-route-paths.ts"; +import { isRouteId, isSessionRouteId } from "../app-route-paths.ts"; import { resolveControlUiAuthToken } from "../app/control-ui-auth.ts"; import { isNativeWebChromeHost } from "../app/native-web-chrome.ts"; import { readPresenceEntries, resolveCurrentSelfUser } from "../app/user-profile.ts"; @@ -38,6 +39,7 @@ import type { SidebarRecentSession } from "./app-sidebar-session-types.ts"; import type { SidebarWorkboardBoard } from "./app-sidebar-workboard.ts"; import { icons } from "./icons.ts"; import { redactLoginFailureError } from "./login-gate.ts"; +import { personActivityLink, personActivityRouting } from "./person-activity-link.ts"; import { renderSessionAttentionIcon, renderSessionRunSpinner, @@ -315,29 +317,47 @@ export function renderAppSidebarOnline(host: AppSidebarRenderHost) { ${collapsed ? nothing : html``} `; diff --git a/ui/src/components/app-sidebar.ts b/ui/src/components/app-sidebar.ts index aa61f8ac8103..d29b2c102744 100644 --- a/ui/src/components/app-sidebar.ts +++ b/ui/src/components/app-sidebar.ts @@ -67,6 +67,7 @@ import { import { renderPanelRefreshStatus } from "./panel-refresh-status.ts"; import { SessionOrganizerController } from "./session-organizer-controller.ts"; import { SidebarMenusController } from "./sidebar-menus-controller.ts"; +import { SidebarPeopleController } from "./sidebar-people-controller.ts"; // The shared loader retries transient chunk failures online; a deploy-pruned // chunk still stays off until reload when that retry fails, by design. const lobsterPetImport = createIdleImport(() => import("./lobster-pet.runtime.ts")); @@ -77,6 +78,7 @@ class AppSidebar extends AppSidebarSessionNavigationElement implements SessionLi override readonly sessionOrganizer = new SessionOrganizerController(this); override readonly sidebarMenus = new SidebarMenusController(this); + private readonly people = new SidebarPeopleController(this); sessionGroupDefaults(name: string) { if (this.context?.sessions.groupsStatus() !== "ready") { @@ -204,6 +206,11 @@ class AppSidebar extends AppSidebarSessionNavigationElement implements SessionLi ); } + override dismissTransientMenus(): boolean { + const hadPersonCard = this.people.dismiss(); + return super.dismissTransientMenus() || hadPersonCard; + } + override disconnectedCallback() { window.removeEventListener("openclaw:native-gateways-changed", this.nativeGatewaysChanged); window.removeEventListener( diff --git a/ui/src/components/person-activity-card.ts b/ui/src/components/person-activity-card.ts new file mode 100644 index 000000000000..34d2328a498d --- /dev/null +++ b/ui/src/components/person-activity-card.ts @@ -0,0 +1,276 @@ +import { html, nothing } from "lit"; +import { repeat } from "lit/directives/repeat.js"; +import type { GatewaySessionRow } from "../api/types.ts"; +import { i18n, t } from "../i18n/index.ts"; +import { shouldHandleNavigationClick } from "../lib/navigation-click.ts"; +import type { PresenceViewer } from "../lib/presence-users.ts"; +import { resolveSessionDisplayName } from "../lib/session-display.ts"; +import { + resolveSessionPreferredFace, + sessionNavigationTarget, +} from "../lib/sessions/route-navigation.ts"; +import { + canonicalUiSessionKeyForPersistence, + normalizeAgentId, + parseAgentSessionKey, +} from "../lib/sessions/session-key.ts"; +import { icons } from "./icons.ts"; +import { personActivityLink, type PersonActivityRouting } from "./person-activity-link.ts"; +import type { SessionDataController } from "./session-data-controller.ts"; +import "./elapsed-time.ts"; +import "./viewer-facepile.ts"; + +type ScopedSession = { row: GatewaySessionRow; agentId: string }; +type PersonSessionData = Readonly< + Pick< + SessionDataController, + | "sessionsAgentId" + | "sessionsResult" + | "sessionResultsByAgent" + | "childSessionRowsByParent" + | "loadedChildSessionKeys" + > +>; +type PersonCardInput = { + user: PresenceViewer; + sessionData: PersonSessionData; + watchAgentId: string; + mainKey: string; + globalScope: boolean; + routing: PersonActivityRouting; + openSession: (row: GatewaySessionRow, agentId: string) => void; +}; + +/** Loaded, caller-visible roster facts, paired with their owning list scope. */ +function loadedPresenceSessions(data: PersonSessionData): ScopedSession[] { + const lists = [ + { agentId: data.sessionsAgentId, result: data.sessionsResult }, + ...Object.entries(data.sessionResultsByAgent).map(([agentId, result]) => ({ + agentId, + result, + })), + ]; + const roots = lists.flatMap(({ agentId, result }) => + agentId + ? (result?.sessions ?? []).map((row) => ({ + row, + agentId: parseAgentSessionKey(row.key)?.agentId ?? row.agentId ?? agentId, + })) + : [], + ); + const children = Object.entries(data.childSessionRowsByParent).flatMap(([parent, rows]) => { + const agentId = parseAgentSessionKey(parent)?.agentId ?? data.sessionsAgentId; + return agentId && data.loadedChildSessionKeys.has(parent) + ? rows.map((row) => ({ + row, + agentId: parseAgentSessionKey(row.key)?.agentId ?? row.agentId ?? agentId, + })) + : []; + }); + return [...roots, ...children]; +} + +function sessionIdentity(key: string, agentId: string, input: PersonCardInput): string { + const scope = parseAgentSessionKey(key)?.agentId ?? normalizeAgentId(agentId); + const canonical = canonicalUiSessionKeyForPersistence( + { + agentsList: { + defaultId: scope, + mainKey: input.mainKey, + scope: input.globalScope ? "global" : "agent", + }, + }, + parseAgentSessionKey(key) || key.toLowerCase() === "global" ? key : `agent:${scope}:${key}`, + ); + return `${scope}\u0000${canonical}`; +} + +function observedTimestamp(values: (number | undefined)[], order: "first" | "last") { + const known = values.filter((value): value is number => value !== undefined); + return known.length ? (order === "first" ? Math.min(...known) : Math.max(...known)) : undefined; +} + +function elapsed(timestamp: number) { + const date = new Date(timestamp); + return html``; +} + +function connections(user: PresenceViewer): string[] { + // Tabs with the same reported facts are one description, never a device count. + return [ + ...new Set( + (user.entries ?? []) + .map((entry) => { + const app = + entry.mode === "webchat" + ? t("presence.card.controlUi") + : entry.mode === "cli" + ? t("presence.card.cli") + : entry.mode === "ui" + ? t("presence.card.app") + : undefined; + return [ + ...new Set( + [entry.deviceFamily, entry.platform, app] + .map((value) => value?.trim()) + .filter(Boolean), + ), + ].join(" · "); + }) + .filter(Boolean), + ), + ].toSorted(); +} + +function renderSessions( + sessions: readonly ScopedSession[], + input: PersonCardInput, + recent: boolean, +) { + const title = t(recent ? "presence.card.recentSessions" : "presence.card.viewingNow"); + return html`
+

${title}

+ ${sessions.length + ? html`
+ ${repeat( + sessions.slice(0, 3), + ({ row, agentId }) => sessionIdentity(row.key, agentId, input), + ({ row, agentId }) => { + const target = sessionNavigationTarget({ + face: resolveSessionPreferredFace(row), + sessionKey: row.key, + fallbackAgentId: agentId, + basePath: input.routing.basePath, + row, + mainKey: input.mainKey, + }); + return html` { + if (!shouldHandleNavigationClick(event)) { + return; + } + event.preventDefault(); + input.openSession(row, agentId); + }} + > + ${resolveSessionDisplayName(row.key, row)} ${recent && + row.updatedAt != null + ? html`${t("presence.card.sessionUpdated")} ${elapsed(row.updatedAt)} + ${t("presence.card.ago")}` + : nothing} + `; + }, + )} +
` + : html`

+ ${t(recent ? "presence.card.noRecentSessions" : "presence.card.noVisibleSessions")} +

`} +
`; +} + +export function renderPersonActivityCard(input: PersonCardInput) { + const { user } = input; + const entries = user.entries ?? []; + const onlineSince = observedTimestamp( + entries.map((entry) => entry.onlineSince), + "first", + ); + const lastActivityAt = observedTimestamp( + entries.map((entry) => entry.lastActivityAt), + "last", + ); + const where = connections(user); + const zones = [ + ...new Set(entries.flatMap((entry) => (entry.timeZone?.trim() ? [entry.timeZone.trim()] : []))), + ].toSorted(); + const watched = new Set( + user.watchedSessions.map((key) => sessionIdentity(key, input.watchAgentId, input)), + ); + const unique = new Map(); + // Presence keys are only hints. Intersect the authorized roster before producing any text or href. + for (const session of loadedPresenceSessions(input.sessionData)) { + const key = sessionIdentity(session.row.key, session.agentId, input); + if (!unique.has(key)) { + unique.set(key, session); + } + } + const sessions = [...unique.values()].toSorted( + (a, b) => + (b.row.updatedAt ?? 0) - (a.row.updatedAt ?? 0) || + sessionIdentity(a.row.key, a.agentId, input).localeCompare( + sessionIdentity(b.row.key, b.agentId, input), + ), + ); + const viewing = sessions.filter(({ row, agentId }) => + watched.has(sessionIdentity(row.key, agentId, input)), + ); + const recent = sessions.filter( + ({ row, agentId }) => + !watched.has(sessionIdentity(row.key, agentId, input)) && + [row.owner?.actor, row.createdActor].some( + (actor) => actor?.type === "human" && actor.id === user.id, + ), + ); + const activity = personActivityLink(user.id, input.routing)!; + return html`
+
+ +
+

${user.name ?? user.email ?? t("presence.card.person")}

+ ${t("presence.rosterTitle")} +
+
+
+
+
${t("presence.card.onlineFor")}
+
+ ${onlineSince === undefined ? t("presence.card.notObserved") : elapsed(onlineSince)} +
+
+ ${where.length || zones.length + ? html`
+
${t("presence.card.where")}
+
+ ${where.map((description) => html`${description}`)}${zones.map( + (zone) => html`${t("presence.card.reportedTimeZone", { zone })}`, + )} +
+
` + : nothing} +
+
${t("presence.card.lastActivity")}
+
+ ${lastActivityAt === undefined + ? t("presence.card.notObserved") + : html`${elapsed(lastActivityAt)} ${t("presence.card.ago")}`} +
+
+
+ ${renderSessions(viewing, input, false)}${renderSessions(recent, input, true)} + +
`; +} diff --git a/ui/src/components/portaled-hovercard.ts b/ui/src/components/portaled-hovercard.ts index a708d70a8bd8..95ff790ed2f0 100644 --- a/ui/src/components/portaled-hovercard.ts +++ b/ui/src/components/portaled-hovercard.ts @@ -1,3 +1,5 @@ +import { promoteToPopoverTopLayer } from "./menu-surface.ts"; + const CARD_GAP = 10; const VIEWPORT_PADDING = 12; @@ -9,6 +11,7 @@ export class PortaledHovercardController { pointerOverCard = false; focusInside = false; cardFocusInside = false; + explicitHold = false; private closeTimer: number | null = null; private exitCleanup: (() => void) | null = null; @@ -25,7 +28,31 @@ export class PortaledHovercardController { ) {} get held(): boolean { - return this.pointerInside || this.pointerOverCard || this.focusInside || this.cardFocusInside; + return ( + this.explicitHold || + this.pointerInside || + this.pointerOverCard || + this.focusInside || + this.cardFocusInside + ); + } + + schedulePointerExit(event: PointerEvent, target: HTMLElement, bridgeMs = 220): void { + this.pointerInside = false; + const side = this.card?.dataset.side; + const rect = target.getBoundingClientRect(); + const towardCard = + (event.relatedTarget instanceof Node && this.card?.contains(event.relatedTarget)) || + (side === "right" && event.clientX >= rect.right) || + (side === "left" && event.clientX <= rect.left) || + (side === "bottom" && event.clientY >= rect.bottom) || + (side === "top" && event.clientY <= rect.top); + this.scheduleClose(towardCard ? bridgeMs : this.closeDelayMs); + } + + focusables(): HTMLElement[] { + // Decorative avatar twins opt out; cards share the same keyboard traversal contract. + return [...(this.card?.querySelectorAll('a[href]:not([tabindex="-1"])') ?? [])]; } scheduleOpen(delay: number, open: () => void): void { @@ -144,6 +171,7 @@ export class PortaledHovercardController { this.pointerOverCard = false; this.focusInside = false; this.cardFocusInside = false; + this.explicitHold = false; clearPortaledHovercardTrigger(this.trigger); this.clearCard(exitDurationMs); this.anchor = null; @@ -178,7 +206,11 @@ function mountPortaledHovercard(params: { placement: PortaledHovercardPlacement; observeVisualViewport?: boolean; }): () => void { - document.body.append(params.card); + // A modal drawer makes body siblings inert. Keep its card inside the same + // dialog, then use the existing menu top layer to escape clipping and stacking. + const owner = params.anchor.closest("openclaw-modal-dialog") ?? document.body; + owner.append(params.card); + promoteToPopoverTopLayer(params.card); params.trigger.setAttribute("aria-controls", params.card.id); params.trigger.setAttribute("aria-expanded", "true"); const position = () => positionPortaledHovercard(params.anchor, params.card, params.placement); diff --git a/ui/src/components/session-progress-hovercard.runtime.ts b/ui/src/components/session-progress-hovercard.runtime.ts index eff6ca7cc77e..9cda6fd734f4 100644 --- a/ui/src/components/session-progress-hovercard.runtime.ts +++ b/ui/src/components/session-progress-hovercard.runtime.ts @@ -29,7 +29,6 @@ import { const OPEN_DELAY_MS = 450; const SWEEP_OPEN_DELAY_MS = 80; const SKIP_DELAY_MS = 300; -const ROW_CARD_BRIDGE_MS = 220; const CLOSE_DELAY_MS = 100; const EXIT_DURATION_MS = 100; let nextHovercardId = 0; @@ -221,17 +220,7 @@ export class SessionProgressHovercardProvider extends ReactiveElement { if (event.relatedTarget instanceof Node && target.contains(event.relatedTarget)) { return; } - this.hovercard.pointerInside = false; - const card = this.hovercard.card; - const side = card?.dataset.side; - const rect = target.getBoundingClientRect(); - const movingTowardCard = - (event.relatedTarget instanceof Node && card?.contains(event.relatedTarget)) || - (side === "right" && event.clientX >= rect.right) || - (side === "left" && event.clientX <= rect.left) || - (side === "bottom" && event.clientY >= rect.bottom) || - (side === "top" && event.clientY <= rect.top); - this.hovercard.scheduleClose(movingTowardCard ? ROW_CARD_BRIDGE_MS : CLOSE_DELAY_MS); + this.hovercard.schedulePointerExit(event, target); }; private readonly handleFocusIn = (event: FocusEvent) => { @@ -567,10 +556,7 @@ export class SessionProgressHovercardProvider extends ReactiveElement { }; private cardFocusables(): HTMLElement[] { - // Decorative link twins (avatars beside their labelled link) opt out with tabindex="-1". - return [ - ...(this.hovercard.card?.querySelectorAll('a[href]:not([tabindex="-1"])') ?? []), - ]; + return this.hovercard.focusables(); } private personActivity(): PersonActivityRouting | undefined { diff --git a/ui/src/components/sidebar-people-controller.ts b/ui/src/components/sidebar-people-controller.ts new file mode 100644 index 000000000000..47d32e51236e --- /dev/null +++ b/ui/src/components/sidebar-people-controller.ts @@ -0,0 +1,122 @@ +import type { ReactiveController } from "lit"; +import { t } from "../i18n/index.ts"; +import { showToast } from "../lib/toast.ts"; +import type { AppSidebarSessionNavigationElement } from "./app-sidebar-session-navigation.ts"; +import type { SidebarPeopleRuntime } from "./sidebar-people.runtime.ts"; + +const EVENTS = ["pointerover", "pointerout", "focusin", "focusout", "click", "keydown"] as const; + +/** One lazy interaction owner per sidebar; the data stays in SessionDataController. */ +export class SidebarPeopleController implements ReactiveController { + private runtime: SidebarPeopleRuntime | null = null; + private loading: Promise | null = null; + private generation = 0; + private pendingTarget: HTMLElement | null = null; + + constructor(private readonly host: AppSidebarSessionNavigationElement) { + host.addController(this); + } + + hostConnected(): void { + for (const event of EVENTS) { + this.host.addEventListener(event, this.handleEvent); + } + } + + hostUpdated(): void { + this.runtime?.sync(); + } + + dismiss(): boolean { + this.generation += 1; + this.clearPending(); + return this.runtime?.dismiss() ?? false; + } + + hostDisconnected(): void { + for (const event of EVENTS) { + this.host.removeEventListener(event, this.handleEvent); + } + this.generation += 1; + this.clearPending(); + this.runtime?.dispose(); + this.runtime = null; + } + + private clearPending(): void { + this.pendingTarget = null; + document.removeEventListener("pointerdown", this.cancelPending, true); + document.removeEventListener("keydown", this.cancelPending, true); + } + + private readonly cancelPending = (event: Event) => { + if ( + event instanceof KeyboardEvent + ? event.key === "Escape" + : event.target instanceof Node && !this.pendingTarget?.contains(event.target) + ) { + this.generation += 1; + this.clearPending(); + } + }; + + private readonly handleEvent = (event: Event): void => { + if (this.runtime) { + this.runtime.handleEvent(event); + return; + } + const target = + event.target instanceof Element + ? event.target.closest(".sidebar-online__row") + : null; + if (!target || !["pointerover", "focusin", "click"].includes(event.type)) { + return; + } + // Input modality belongs to the runtime; row intent only warms its lazy code. + if ( + event.type === "click" && + !(event.target instanceof Element && event.target.closest(".sidebar-online__details")) + ) { + return; + } + const generation = ++this.generation; + this.pendingTarget = target; + document.addEventListener("pointerdown", this.cancelPending, true); + document.addEventListener("keydown", this.cancelPending, true); + const client = this.host.sessionDataContext?.gateway.snapshot.client; + const gateway = this.host.sessionDataContext?.gateway; + const hello = gateway?.snapshot.hello; + const route = this.host.activeRouteId; + const sessionKey = this.host.sessionKey; + const startedAt = performance.now(); + this.loading ??= import("./sidebar-people.runtime.ts"); + void this.loading.then( + (module) => { + if (generation === this.generation) { + this.clearPending(); + } + if ( + generation !== this.generation || + !this.host.isConnected || + !target.isConnected || + gateway !== this.host.sessionDataContext?.gateway || + client !== gateway?.snapshot.client || + hello !== gateway?.snapshot.hello || + route !== this.host.activeRouteId || + sessionKey !== this.host.sessionKey + ) { + return; + } + this.runtime ??= new module.SidebarPeopleRuntime(this.host); + this.runtime.handleEvent(event, startedAt); + }, + () => { + this.loading = null; + if (generation === this.generation && this.host.isConnected) { + this.clearPending(); + showToast({ message: t("presence.card.loadFailed") }); + } + }, + ); + }; +} diff --git a/ui/src/components/sidebar-people.runtime.ts b/ui/src/components/sidebar-people.runtime.ts new file mode 100644 index 000000000000..f52d3a7b669f --- /dev/null +++ b/ui/src/components/sidebar-people.runtime.ts @@ -0,0 +1,399 @@ +import { nothing, render } from "lit"; +import type { GatewayBrowserClient } from "../api/gateway.ts"; +import { selectApplicationSession } from "../app/agent-selection.ts"; +import type { ApplicationGateway } from "../app/gateway.ts"; +import { readPresenceEntries, resolveCurrentSelfUser } from "../app/user-profile.ts"; +import { i18n, t } from "../i18n/index.ts"; +import { projectOnlinePresenceViewers } from "../lib/presence-users.ts"; +import { runSessionNavigationIntent } from "../lib/sessions/navigation-handoff.ts"; +import { + resolveSessionPreferredFace, + sessionNavigationTarget, +} from "../lib/sessions/route-navigation.ts"; +import { + isUiGlobalScopeConfigured, + resolveUiConfiguredMainKey, + resolveUiDefaultAgentId, +} from "../lib/sessions/session-key.ts"; +import type { AppSidebarSessionNavigationElement } from "./app-sidebar-session-navigation.ts"; +import { + hovercardBootstrapIntentActive, + remainingHovercardOpenDelay, +} from "./lazy-hovercard-registration.ts"; +import { renderPersonActivityCard } from "./person-activity-card.ts"; +import { personActivityRouting } from "./person-activity-link.ts"; +import { createPortaledHovercard, PortaledHovercardController } from "./portaled-hovercard.ts"; + +let nextCardId = 0; + +export class SidebarPeopleRuntime { + private active: { + id: string; + row: HTMLElement; + trigger: HTMLElement; + scope: string; + gateway: ApplicationGateway; + client: GatewayBrowserClient | null; + } | null = null; + private readonly portal = new PortaledHovercardController(() => this.close(), 100); + private readonly observer = new MutationObserver(() => this.sync()); + private suppressFocus = false; + private lastOpenAt = -Infinity; + private readonly stopLocale: () => void; + + constructor(private readonly host: AppSidebarSessionNavigationElement) { + this.stopLocale = i18n.subscribe(() => this.sync()); + } + + private scope(): string { + return JSON.stringify([ + this.host.activeRouteId, + this.host.sessionKey, + this.host.sessionDataContext?.gateway.connectionRevision, + ]); + } + + handleEvent(event: Event, bootstrapStartedAt?: number): void { + const row = + event.target instanceof Element + ? event.target.closest(".sidebar-online__row") + : null; + const details = + event.target instanceof Element && event.target.closest(".sidebar-online__details"); + if (event.type === "keydown" && event instanceof KeyboardEvent) { + if (event.key === "Tab" && !event.shiftKey && event.target === this.active?.trigger) { + const first = this.portal.focusables()[0]; + if (first) { + event.preventDefault(); + first.focus(); + } + } + return; + } + if (!row) { + return; + } + // Import completion must not replay a hover or focus that already moved away. + if ( + bootstrapStartedAt !== undefined && + event.type !== "click" && + !hovercardBootstrapIntentActive(row, event.type === "focusin" ? "focus" : "pointer", true) + ) { + return; + } + if (event.type === "click") { + if (!details) { + this.close(); + return; + } + if (this.active?.row === row && this.portal.explicitHold) { + this.close(); + return; + } + this.activate(row, 0); + this.portal.explicitHold = true; + this.show(); + } else if (event.type === "pointerover" && event instanceof PointerEvent) { + if (event.pointerType === "touch" || !globalThis.matchMedia?.("(hover: hover)").matches) { + return; + } + if (this.portal.explicitHold && this.active?.row !== row) { + return; + } + const delay = this.portal.card || performance.now() - this.lastOpenAt < 300 ? 80 : 450; + this.activate( + row, + remainingHovercardOpenDelay(bootstrapStartedAt ?? performance.now(), delay), + ); + this.portal.pointerInside = true; + this.portal.clearClose(); + } else if ( + event.type === "pointerout" && + event instanceof PointerEvent && + this.active?.row === row + ) { + if (event.relatedTarget instanceof Node && row.contains(event.relatedTarget)) { + return; + } + this.portal.schedulePointerExit(event, row); + } else if (event.type === "focusin" && !this.suppressFocus) { + this.activate(row, 0); + this.portal.focusInside = true; + this.portal.clearClose(); + this.show(); + } else if ( + event.type === "focusout" && + event instanceof FocusEvent && + this.active?.row === row + ) { + if (event.relatedTarget instanceof Node && row.contains(event.relatedTarget)) { + return; + } + this.portal.focusInside = false; + this.portal.scheduleClose(); + } + } + + private activate(row: HTMLElement, delay: number): void { + const id = row.querySelector("[data-online-user-id]")?.dataset.onlineUserId; + const trigger = row.querySelector(".sidebar-online__details"); + if (!id || !trigger || !this.host.connected) { + return; + } + if (this.active?.id === id && this.active.row === row) { + return; + } + this.close(); + const gateway = this.host.sessionDataContext?.gateway; + if (!gateway || gateway.snapshot.phase !== "connected") { + return; + } + this.active = { + id, + row, + trigger, + scope: this.scope(), + gateway, + client: gateway.snapshot.client, + }; + this.portal.markTrigger(trigger); + this.observer.observe(this.host, { childList: true, subtree: true }); + document.addEventListener("pointerdown", this.outsidePointer, true); + document.addEventListener("focusin", this.outsideFocus, true); + document.addEventListener("keydown", this.outsideKey, true); + this.portal.scheduleOpen(delay, () => { + if (this.portal.held) { + this.show(); + } + }); + } + + private isCurrent(): boolean { + const active = this.active; + const gateway = this.host.sessionDataContext?.gateway; + return Boolean( + active && + this.host.isConnected && + this.host.connected && + gateway?.snapshot.phase === "connected" && + active.gateway === gateway && + active.client === gateway.snapshot.client && + active.scope === this.scope() && + this.host.contains(active.row) && + !this.host.collapsedSessionSections.has("online"), + ); + } + + sync(): void { + if (!this.isCurrent()) { + this.close(); + } else if (this.portal.card) { + this.show(); + } + } + + private show(): void { + const active = this.active; + const context = this.host.sessionDataContext; + if (!active || !context || !this.isCurrent()) { + this.close(); + return; + } + const data = this.host.sessionData; + const self = resolveCurrentSelfUser({ + snapshotUser: context.gateway.snapshot.selfUser, + presenceEntries: readPresenceEntries(data.presencePayload), + presenceInstanceId: data.presenceInstanceId, + }); + const user = projectOnlinePresenceViewers( + data.presencePayload, + self?.id, + data.presenceInstanceId, + ).find((person) => person.id === active.id); + if (!user) { + this.close(); + return; + } + const defaults = { + agentsList: context.agents.state.agentsList, + hello: context.gateway.snapshot.hello, + }; + const existing = this.portal.card; + const card = + existing ?? + createPortaledHovercard( + `openclaw-person-activity-${++nextCardId}`, + "session-progress-hovercard person-activity-hovercard", + ); + const focused = card.contains(document.activeElement) ? document.activeElement : null; + card.setAttribute( + "aria-label", + t("presence.card.ariaLabel", { name: user.name ?? user.email ?? t("presence.card.person") }), + ); + render( + renderPersonActivityCard({ + user, + sessionData: data, + watchAgentId: resolveUiDefaultAgentId(defaults), + mainKey: resolveUiConfiguredMainKey(defaults), + globalScope: isUiGlobalScopeConfigured(defaults), + routing: personActivityRouting( + { + basePath: this.host.basePath, + navigate: (route, options) => this.host.onNavigate?.(route, options), + }, + () => this.close(), + ), + openSession: (row, agentId) => { + const face = resolveSessionPreferredFace(row); + const target = sessionNavigationTarget({ + face, + sessionKey: row.key, + row, + fallbackAgentId: agentId, + basePath: this.host.basePath, + mainKey: resolveUiConfiguredMainKey(defaults), + }); + this.close(); + runSessionNavigationIntent(this.host, { + face, + sessionKey: row.key, + commit: () => { + if ( + this.host.sessionDataContext?.gateway !== active.gateway || + context.gateway.snapshot.client !== active.client || + context.gateway.snapshot.phase !== "connected" || + active.scope !== this.scope() + ) { + return false; + } + this.host.prepareSessionNavigation(row.key, target.options.pathname); + this.host.onNavigate?.(face, target.options); + selectApplicationSession({ + selection: context.agentSelection, + gateway: context.gateway, + sessionKey: row.key, + agentId, + }); + return true; + }, + }); + }, + }), + card, + ); + if (existing) { + if (focused && !card.contains(document.activeElement)) { + const replacement = + focused instanceof HTMLAnchorElement + ? this.portal + .focusables() + .find((link) => link instanceof HTMLAnchorElement && link.href === focused.href) + : undefined; + if (replacement) { + replacement.focus({ preventScroll: true }); + } else { + this.returnFocus(); + } + } + this.portal.position(); + return; + } + this.lastOpenAt = performance.now(); + card.addEventListener("pointerenter", () => { + this.portal.pointerOverCard = true; + this.portal.clearClose(); + }); + card.addEventListener("pointerleave", () => { + this.portal.pointerOverCard = false; + this.portal.scheduleClose(); + }); + card.addEventListener("focusin", () => { + this.portal.cardFocusInside = true; + this.portal.clearClose(); + }); + card.addEventListener("focusout", (event) => { + if (event.relatedTarget instanceof Node && card.contains(event.relatedTarget)) { + return; + } + this.portal.cardFocusInside = false; + this.portal.scheduleClose(); + }); + card.addEventListener("keydown", (event) => { + const links = this.portal.focusables(); + if ( + event.key === "Tab" && + document.activeElement === (event.shiftKey ? links[0] : links.at(-1)) + ) { + event.preventDefault(); + this.returnFocus(); + this.close(); + } + }); + this.portal.mount(active.row, card, "horizontal", true, () => render(nothing, card)); + } + + private returnFocus(): void { + this.suppressFocus = true; + this.active?.trigger.focus({ preventScroll: true }); + this.suppressFocus = false; + this.portal.focusInside = document.activeElement === this.active?.trigger; + } + + private readonly outsidePointer = (event: Event) => { + if ( + event.target instanceof Node && + !this.active?.row.contains(event.target) && + !this.portal.card?.contains(event.target) + ) { + this.close(); + } + }; + + private readonly outsideFocus = (event: Event) => { + if ( + event.target instanceof Node && + !this.active?.row.contains(event.target) && + !this.portal.card?.contains(event.target) + ) { + this.close(); + } + }; + + private readonly outsideKey = (event: KeyboardEvent) => { + if (event.key === "Escape") { + // The card owns this Escape, not the modal navigation drawer beneath it. + event.preventDefault(); + event.stopPropagation(); + if (this.portal.card?.contains(document.activeElement)) { + this.returnFocus(); + } + this.close(); + } + }; + + private close(): void { + if (this.portal.card) { + this.lastOpenAt = performance.now(); + } + this.observer.disconnect(); + document.removeEventListener("pointerdown", this.outsidePointer, true); + document.removeEventListener("focusin", this.outsideFocus, true); + document.removeEventListener("keydown", this.outsideKey, true); + this.portal.reset(); + this.active?.trigger.setAttribute("aria-haspopup", "dialog"); + this.active?.trigger.setAttribute("aria-expanded", "false"); + this.active = null; + } + + dismiss(): boolean { + const hadCard = this.active !== null; + this.close(); + return hadCard; + } + + dispose(): void { + this.close(); + this.stopLocale(); + } +} diff --git a/ui/src/e2e/chat-transcript-disclosure-anchor.e2e.test.ts b/ui/src/e2e/chat-transcript-disclosure-anchor.e2e.test.ts index d65cb928a213..33355e039ff8 100644 --- a/ui/src/e2e/chat-transcript-disclosure-anchor.e2e.test.ts +++ b/ui/src/e2e/chat-transcript-disclosure-anchor.e2e.test.ts @@ -187,6 +187,9 @@ suite.define(() => { // a wheel event or a changed offset. await page.mouse.click(track!.x + track!.width - 3, track!.y + 20); await page.locator(".chat-scroll-to-bottom").waitFor({ state: "visible" }); + // Chromium can commit its last canceled animation offset after the + // pointer action returns. Capture the reader before releasing text. + await waitForChatScrollIdle(page); } const interruptedOffset = await thread.evaluate((element) => element.scrollTop); if (interruption === "wheel") { diff --git a/ui/src/e2e/people-activity-card.e2e.test.ts b/ui/src/e2e/people-activity-card.e2e.test.ts new file mode 100644 index 000000000000..c8bdcc41da5e --- /dev/null +++ b/ui/src/e2e/people-activity-card.e2e.test.ts @@ -0,0 +1,228 @@ +import { mkdir } from "node:fs/promises"; +import path from "node:path"; +import type { Locator, Page } from "playwright"; +import { expect, it } from "vitest"; +import { + captureUiProofEnabled, + chatSessionListResponse, + controlUiSessionUrl, + createChatFlowE2eSuite, + installMockGateway, +} from "./chat-flow.test-support.ts"; + +const suite = createChatFlowE2eSuite(); +const selected = "agent:main:card-selected"; +const watched = "agent:main:card-viewing"; + +function scenario() { + const now = Date.now(); + return { + sessionKey: selected, + presenceUsers: [ + { + id: "alice", + name: "Alice", + onlineSince: now - 2_700_000, + lastActivityAt: now - 60_000, + deviceFamily: "Mac", + platform: "macOS", + timeZone: "Europe/Paris", + watchedSessions: [watched, "agent:main:main", "agent:private:hidden"], + }, + ], + methodResponses: { + "sessions.list": chatSessionListResponse([ + { key: "agent:main:main", kind: "direct", label: "", updatedAt: now - 90_000 }, + { key: selected, kind: "direct", label: "Selected session", updatedAt: now }, + { + key: watched, + kind: "direct", + label: "Release checklist", + updatedAt: now - 60_000, + boardFace: "dashboard", + }, + { + key: "agent:main:card-recent", + kind: "direct", + label: "Design notes", + updatedAt: now - 120_000, + createdActor: { type: "human", id: "alice" }, + }, + ]), + }, + }; +} + +async function expectInlineLastActivity(card: Locator) { + const positions = await card + .locator(".person-activity-card__facts dd") + .last() + .evaluate((value) => { + const time = value.querySelector("time"); + const text = document.createTreeWalker(value, NodeFilter.SHOW_TEXT); + let suffix = text.nextNode(); + while (suffix && suffix.textContent?.trim() !== "ago") { + suffix = text.nextNode(); + } + if (!time || !suffix) { + throw new Error("Expected last activity duration and suffix"); + } + const range = document.createRange(); + range.selectNodeContents(suffix); + return { time: time.getBoundingClientRect().top, suffix: range.getBoundingClientRect().top }; + }); + expect(Math.abs(positions.time - positions.suffix)).toBeLessThan(2); +} + +async function capturePeopleCard(page: Page, filename: string) { + if (!captureUiProofEnabled) { + return; + } + const directory = path.resolve(".artifacts/control-ui-e2e/people-activity-cards"); + await mkdir(directory, { recursive: true }); + await page.screenshot({ + path: path.join(directory, filename), + fullPage: true, + animations: "disabled", + }); +} + +suite.define(() => { + it("bridges hover, preserves focus on updates, and keeps navigation separate from details", async () => { + await suite.withPage( + { + hasTouch: false, + colorScheme: "light", + locale: "en-US", + serviceWorkers: "block", + viewport: { width: 1280, height: 900 }, + }, + async ({ page }) => { + const gateway = await installMockGateway(page, scenario()); + await page.goto(controlUiSessionUrl(suite.server.baseUrl, selected)); + const row = page + .locator(".sidebar-online__row") + .filter({ has: page.locator('[data-online-user-id="alice"]') }); + const name = row.locator("a.sidebar-online__person"); + const details = row.getByRole("button", { name: "Details for Alice" }); + const card = page.getByRole("dialog", { name: "Activity for Alice" }); + await name.waitFor({ state: "visible" }); + expect(await card.count()).toBe(0); + expect(await name.getAttribute("href")).toContain("/activity?person=alice"); + await name.hover(); + await card.waitFor({ state: "visible" }); + expect(await card.textContent()).toContain("Reported time zone: Europe/Paris"); + await expect + .poll(() => card.locator("a").allTextContents()) + .toEqual( + expect.arrayContaining([ + expect.stringContaining("Release checklist"), + expect.stringContaining("Main Session"), + expect.stringContaining("Design notes"), + expect.stringContaining("View activity"), + ]), + ); + expect(await card.innerHTML()).not.toContain("agent:private:hidden"); + await expectInlineLastActivity(card); + await capturePeopleCard(page, "desktop-light-open.png"); + const bounds = await row.boundingBox(); + const cardBounds = await card.boundingBox(); + if (!bounds || !cardBounds) { + throw new Error("Expected person row and card bounds"); + } + await page.mouse.move(bounds.x + bounds.width + 4, bounds.y + bounds.height / 2); + await page.mouse.move(cardBounds.x + 8, cardBounds.y + 20); + expect(await card.count()).toBe(1); + await details.focus(); + await page.keyboard.press("Tab"); + const session = card.getByRole("link", { name: "Release checklist" }); + await expect + .poll(() => session.evaluate((element) => document.activeElement === element)) + .toBe(true); + expect(await session.getAttribute("href")).toContain("/dashboard/"); + const current = scenario().presenceUsers[0]!; + await gateway.emitGatewayEvent("presence", { + presence: [ + { + ...current, + user: { id: "alice", name: "Alice" }, + lastInputSeconds: 600, + ts: Date.now(), + lastActivityAt: Date.now(), + }, + { user: { id: "bob", name: "Bob" }, ts: Date.now(), lastInputSeconds: 0 }, + ], + }); + await expect + .poll(() => + page.locator(".sidebar-online__person").first().getAttribute("data-online-user-id"), + ) + .toBe("bob"); + expect(await session.evaluate((element) => document.activeElement === element)).toBe(true); + await page.keyboard.press("Escape"); + await expect.poll(() => card.count()).toBe(0); + expect(await details.evaluate((element) => document.activeElement === element)).toBe(true); + await details.click(); + await card.waitFor({ state: "visible" }); + await page.mouse.move(1100, 850); + expect(await card.count()).toBe(1); + await page.mouse.click(1100, 850); + await expect.poll(() => card.count()).toBe(0); + await details.click(); + await card.waitFor({ state: "visible" }); + await card.getByRole("link", { name: "View activity", exact: true }).click(); + await expect.poll(() => page.url()).toContain("/activity?person=alice"); + await expect.poll(() => card.count()).toBe(0); + }, + ); + }); + + it("opens touch details inside a narrow viewport and follows the session's saved face", async () => { + await suite.withPage( + { + hasTouch: true, + isMobile: true, + colorScheme: "dark", + reducedMotion: "reduce", + locale: "en-US", + serviceWorkers: "block", + viewport: { width: 390, height: 650 }, + }, + async ({ page }) => { + await installMockGateway(page, scenario()); + await page.goto(controlUiSessionUrl(suite.server.baseUrl, selected)); + await page + .locator(".topbar-nav-toggle:visible, .chat-pane__nav-toggle:visible") + .first() + .click(); + const details = page.getByRole("button", { name: "Details for Alice" }); + await details.tap(); + const card = page.getByRole("dialog", { name: "Activity for Alice" }); + await card.waitFor({ state: "visible" }); + await page.keyboard.press("Tab"); + await page.keyboard.press("Escape"); + await expect.poll(() => card.count()).toBe(0); + expect(await details.isVisible()).toBe(true); + expect(await details.evaluate((element) => document.activeElement === element)).toBe(true); + await details.tap(); + await card.waitFor({ state: "visible" }); + expect(await card.evaluate((element) => getComputedStyle(element).pointerEvents)).toBe( + "auto", + ); + const bounds = await card.boundingBox(); + expect(bounds).not.toBeNull(); + expect(bounds!.x).toBeGreaterThanOrEqual(0); + expect(bounds!.y).toBeGreaterThanOrEqual(0); + expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(390); + expect(bounds!.y + bounds!.height).toBeLessThanOrEqual(650); + const session = card.getByRole("link", { name: "Release checklist" }); + await session.click({ trial: true }); + await expectInlineLastActivity(card); + await capturePeopleCard(page, "touch-dark-open.png"); + await session.tap(); + await expect.poll(() => page.url()).toContain("/dashboard/"); + await expect.poll(() => card.count()).toBe(0); + }, + ); + }); +}); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 267f36303ba0..5e83e801e2ba 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -3589,6 +3589,27 @@ export const en: TranslationMap = { }, presence: { rosterTitle: "Online", + card: { + details: "Details for {name}", + loadFailed: "Could not open details. Try again, or open this person’s Activity page.", + ariaLabel: "Activity for {name}", + person: "Person", + onlineFor: "Online for", + where: "Where", + reportedTimeZone: "Reported time zone: {zone}", + lastActivity: "Last activity", + notObserved: "Not observed yet", + viewingNow: "Viewing now", + recentSessions: "Recent sessions", + noVisibleSessions: "No visible sessions being viewed.", + noRecentSessions: "No recent visible sessions.", + sessionUpdated: "Session updated", + ago: "ago", + viewActivity: "View activity", + controlUi: "Control UI", + cli: "Command line", + app: "App", + }, }, activityFeed: { sessionsMode: "Sessions", diff --git a/ui/src/pages/chat/components/chat-transcript-controller.test.ts b/ui/src/pages/chat/components/chat-transcript-controller.test.ts index 97daf642e553..14cac78e41ff 100644 --- a/ui/src/pages/chat/components/chat-transcript-controller.test.ts +++ b/ui/src/pages/chat/components/chat-transcript-controller.test.ts @@ -414,6 +414,56 @@ describe("chat transcript controller", () => { }, ); + it("keeps a smooth latest command through an idle observer delivery before reaching its target", async () => { + const rows: TestContentRow[] = Array.from({ length: 40 }, (_, index) => ({ + kind: "content", + key: `row:${index}`, + content: html`
row ${index}
`, + })); + const { container, transcript } = await mountTestTranscript("idle-latest", rows); + Object.defineProperties(container, { + clientHeight: { configurable: true, value: 600 }, + scrollHeight: { configurable: true, value: 4800 }, + }); + const scrollTo = vi.fn(); + container.scrollTo = scrollTo; + vi.useFakeTimers(); + try { + container.scrollTop = 1000; + container.dispatchEvent(new Event("scroll")); + transcript.scrollToEnd({ behavior: "smooth" }); + expect(scrollTo).toHaveBeenLastCalledWith({ top: 4200, behavior: "smooth" }); + container.scrollTop = 1500; + container.dispatchEvent(new Event("scroll")); + Object.defineProperty(container, "scrollHeight", { configurable: true, value: 4900 }); + vi.advanceTimersByTime(16); + expect(scrollTo).toHaveBeenLastCalledWith({ top: 4300, behavior: "smooth" }); + scrollTo.mockClear(); + + // A retargeted native animation can pause between offset events. Core's + // idle debounce still fires, but the requested end has not been reached. + vi.advanceTimersByTime(150); + expect(transcript.isProgrammaticScroll).toBe(true); + expect(scrollTo).not.toHaveBeenCalled(); + + // The 8px UI-follow boundary does not complete the native end command. + container.scrollTop = 4296; + container.dispatchEvent(new Event("scroll")); + vi.advanceTimersByTime(150); + expect(transcript.isProgrammaticScroll).toBe(false); + expect(scrollTo).not.toHaveBeenCalled(); + + container.scrollTop = 4300; + container.dispatchEvent(new Event("scroll")); + vi.advanceTimersByTime(150); + expect(scrollTo).toHaveBeenCalledExactlyOnceWith({ top: 4300, behavior: "instant" }); + expect(transcript.isProgrammaticScroll).toBe(false); + } finally { + transcript.hostDisconnected(); + vi.useRealTimers(); + } + }); + it("remeasures every visible pane transcript while preserving hidden transcript rows", async () => { const host = Object.assign(document.body.appendChild(document.createElement("div")), { addController: vi.fn(), diff --git a/ui/src/pages/chat/components/chat-transcript-controller.ts b/ui/src/pages/chat/components/chat-transcript-controller.ts index b3c2c04fc8fe..be5b475fb9af 100644 --- a/ui/src/pages/chat/components/chat-transcript-controller.ts +++ b/ui/src/pages/chat/components/chat-transcript-controller.ts @@ -293,17 +293,19 @@ class ChatSessionVirtualizerHost implements ReactiveControllerHost, ChatTranscri if (element !== this.scrollElement) { return; } - const previousOffset = instance.scrollOffset; callback(offset, scrolling); - // Core must observe the user's offset before replaying skipped sizes; - // otherwise its old destination can compensate the viewport back down. - if ( - this.endScrollBehavior === "smooth" && - (!scrolling || offset < (previousOffset ?? offset)) - ) { - this.cancelScroll(); - } else if (!scrolling) { - this.endScrollBehavior = null; + // Idle can arrive between smooth retargets. Completion needs the + // restore path's 1px precision, not the 8px UI-follow boundary. + // The input listeners above own reader takeover. + const settledAtEnd = + !scrolling && + Math.abs((maxTranscriptScrollOffset(element) ?? 0) - (element?.scrollTop ?? 0)) <= 1; + if (settledAtEnd && this.pendingScrollOffset === null) { + if (this.endScrollBehavior === "smooth") { + this.cancelScroll(); + } else { + this.endScrollBehavior = null; + } } }); return () => { diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 1f7806aef8b2..135915695f68 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -892,10 +892,12 @@ openclaw-session-progress-hovercard-provider { --session-hovercard-shift-y: 0; position: fixed; + inset: auto; z-index: 1990; width: min(296px, calc(100vw - 24px)); max-height: min(520px, calc(100vh - 24px)); padding: 0; + margin: 0; overflow: auto; border: 1px solid var(--border); border-radius: var(--radius-lg); @@ -958,8 +960,166 @@ openclaw-session-progress-hovercard-provider { } } +/* Explicit details remain interactive on touch devices as well as hover pointers. */ +.person-activity-hovercard[data-open="true"] { + pointer-events: auto; +} + +.person-activity-card { + font-size: calc(12px * var(--control-ui-text-scale)); + line-height: 1.5; +} + +.person-activity-card__header { + display: flex; + align-items: center; + gap: 10px; + padding: 16px 16px 12px; +} + +.person-activity-card__header > div { + min-width: 0; +} + +.person-activity-card h2, +.person-activity-card h3, +.person-activity-card p { + margin: 0; +} + +.person-activity-card h2 { + overflow-wrap: anywhere; + font-size: calc(14px * var(--control-ui-text-scale)); + font-weight: 600; +} + +.person-activity-card__status { + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--muted); +} + +.person-activity-card__status > span { + width: 6px; + height: 6px; + border-radius: var(--radius-full); + background: var(--ok); +} + +.person-activity-card__facts { + display: grid; + gap: 12px; + padding: 0 16px 16px; + margin: 0; +} + +.person-activity-card__facts > div { + display: grid; + grid-template-columns: 80px minmax(0, 1fr); + gap: 8px; +} + +.person-activity-card__facts dt, +.person-activity-card__muted, +.person-activity-card small { + color: var(--muted); +} + +.person-activity-card__facts dd { + display: grid; + gap: 3px; + margin: 0; + overflow-wrap: anywhere; +} + +.person-activity-card small { + display: block; + font-size: calc(11px * var(--control-ui-text-scale)); +} + +.person-activity-card__section, +.person-activity-card footer { + padding: 12px 16px; + border-top: 1px solid var(--border); +} + +.person-activity-card h3 { + margin-bottom: 6px; + color: var(--muted); + font-size: inherit; + font-weight: 500; +} + +.person-activity-card__sessions { + display: grid; + gap: 2px; +} + +.person-activity-card__session { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 6px; + margin: 0 -6px; + border-radius: var(--radius-sm); + color: var(--text); + text-decoration: none; +} + +.person-activity-card__session-copy { + min-width: 0; + overflow-wrap: anywhere; +} + +.person-activity-card__session-copy > span { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.person-activity-card__session-icon { + flex: none; + margin-top: 2px; + color: var(--muted); +} + +.person-activity-card svg { + display: block; + width: 14px; + height: 14px; +} + +.person-activity-card footer a { + display: flex; + align-items: center; + justify-content: space-between; + color: var(--text); + text-decoration: none; +} + +.person-activity-card a:hover { + background: var(--bg-hover); +} + +.person-activity-card a:focus-visible { + border-radius: var(--radius-sm); + outline: none; + box-shadow: var(--focus-ring); +} + +@media (pointer: coarse) { + .person-activity-card a { + min-height: 44px; + align-items: center; + } +} + .github-link-hovercard { position: fixed; + inset: auto; + margin: 0; z-index: 1990; width: min(440px, calc(100vw - 24px)); min-height: 112px; diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index 1c0a2a7774a5..b86ec328d0dd 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -2181,7 +2181,15 @@ openclaw-settings-save-indicator:empty { overflow: hidden; } +.sidebar-online__row { + display: flex; + align-items: center; + min-width: 0; + border-radius: var(--radius-md); +} + .sidebar-online__person { + flex: 1; display: flex; align-items: center; gap: 8px; @@ -2222,25 +2230,61 @@ openclaw-settings-save-indicator:empty { white-space: nowrap; } -.sidebar-online__person-action { +.sidebar-online__details { display: inline-flex; - margin-left: auto; + align-items: center; + justify-content: center; + flex: none; + width: 28px; + height: 28px; + padding: 0; + border: 0; + border-radius: var(--radius-sm); + background: transparent; color: var(--muted); opacity: 0; transition: opacity var(--duration-fast) ease; } -.sidebar-online__person:hover .sidebar-online__person-action, -.sidebar-online__person:focus-visible .sidebar-online__person-action { +.sidebar-online__row:hover .sidebar-online__details, +.sidebar-online__row:focus-within .sidebar-online__details, +.sidebar-online__details[aria-expanded="true"] { opacity: 1; } -.sidebar-online__person-action svg { +.sidebar-online__details:hover { + background: var(--bg-hover); + color: var(--text); +} + +.sidebar-online__details:focus-visible { + outline: none; + box-shadow: var(--focus-ring); +} + +.sidebar-online__details svg { + display: block; width: 13px; height: 13px; - fill: none; - stroke: currentColor; - stroke-width: 1.6px; +} + +@media (hover: none), (pointer: coarse) { + .sidebar-online__details { + width: 44px; + height: 44px; + opacity: 1; + } + + .sidebar-online__person { + min-height: 44px; + } +} + +@media (prefers-reduced-motion: reduce) { + .sidebar-online__person, + .sidebar-online__details { + transition: none; + } } .sidebar-zone-entry { diff --git a/ui/src/test-helpers/app-sidebar-cases/presence.ts b/ui/src/test-helpers/app-sidebar-cases/presence.ts index cc13cb2eece5..89b2a4ff7e04 100644 --- a/ui/src/test-helpers/app-sidebar-cases/presence.ts +++ b/ui/src/test-helpers/app-sidebar-cases/presence.ts @@ -1,6 +1,12 @@ +import type { LitElement } from "lit"; import { describe, expect, it, vi } from "vitest"; import type { GatewayBrowserClient } from "../../api/gateway.ts"; -import { createGatewayHarness, createSessions, mountSidebar } from "../app-sidebar.ts"; +import { + createGatewayHarness, + createSessions, + createSessionsHarness, + mountSidebar, +} from "../app-sidebar.ts"; import "../../components/app-sidebar.ts"; await import("../../components/viewer-facepile.ts"); @@ -87,13 +93,255 @@ describe("AppSidebar viewer presence", () => { await sidebar.updateComplete; expect(sidebar.querySelectorAll(".sidebar-online__person")).toHaveLength(3); - sidebar.querySelector('[data-online-user-id="alice"]')?.click(); + const aliceLink = sidebar.querySelector('[data-online-user-id="alice"]')!; + for (const options of [ + { ctrlKey: true }, + { metaKey: true }, + { shiftKey: true }, + { button: 1 }, + ]) { + const click = new MouseEvent("click", { ...options, bubbles: true, cancelable: true }); + let intercepted = false; + aliceLink.addEventListener( + "click", + (event) => { + intercepted = event.defaultPrevented; + event.preventDefault(); // Keep jsdom from attempting a new browsing context. + }, + { once: true }, + ); + aliceLink.dispatchEvent(click); + expect(intercepted).toBe(false); + } + expect(onNavigate).not.toHaveBeenCalled(); + aliceLink.click(); expect(onNavigate).toHaveBeenCalledWith("activity", { + href: "/activity?person=alice", pathname: "/activity", search: "?person=alice", }); }); + it("projects only visible sessions and reported facts without guessing timing or devices", async () => { + const gateway = createGatewayHarness({ instanceId: "self" } as GatewayBrowserClient); + const sessions = createSessionsHarness("research", [ + "watched", + "global", + "agent:research:ambiguous", + "agent:research:robot", + ...[1, 2, 3, 4].map((n) => `agent:research:recent-${n}`), + ]); + const result = sessions.sessions.state.result!; + result.sessions.forEach((row, index) => { + row.label = row.key === "global" ? "Research global" : `Visible ${index}`; + row.updatedAt = Date.now() - index * 60_000; + if (index === 2) { + row.participants = [{ type: "human", id: "alice", label: "Alice" }]; + } + if (index === 3) { + row.createdActor = { type: "agent", id: "alice" }; + } + if (index >= 4) { + row.owner = { actor: { type: "human", id: "alice" } }; + } + }); + sessions.publishList({ result }); + const { sidebar } = await mountSidebar(gateway.gateway, sessions.sessions); + sidebar.connected = true; + gateway.publishEvent("presence", { + presence: [1, 2, 3].map((tab) => ({ + ts: Date.now() - 500_000, + lastInputSeconds: 3, + instanceId: `private-tab-${tab}`, + ip: "192.0.2.12", + host: "internal-host", + deviceFamily: tab === 3 ? "iPhone" : "Mac", + platform: tab === 3 ? "iOS" : "macOS", + mode: "webchat", + timeZone: "Europe/Paris", + user: { id: "alice", name: "Alice" }, + watchedSessions: [ + "AGENT:research:watched", + "agent:research:watched", + "agent:private:secret-title", + "global", + ], + })), + }); + await sidebar.updateComplete; + sidebar.querySelector(".sidebar-online__details")!.click(); + await vi.waitFor(() => + expect(document.querySelector(".person-activity-hovercard")).not.toBeNull(), + ); + const card = document.querySelector(".person-activity-hovercard")!; + expect(card.querySelectorAll("dt")).toHaveLength(3); + const facts = card.querySelectorAll("dd"); + expect(facts[0]?.textContent?.trim()).toBe("Not observed yet"); + expect([...facts[1]!.querySelectorAll("span")].map((node) => node.textContent)).toEqual([ + "Mac · macOS · Control UI", + "iPhone · iOS · Control UI", + ]); + expect(facts[1]?.querySelector("small")?.textContent).toBe("Reported time zone: Europe/Paris"); + expect(facts[2]?.textContent?.trim()).toBe("Not observed yet"); + const sections = card.querySelectorAll("section"); + expect(sections[0]?.querySelectorAll("a")).toHaveLength(1); + expect(sections[0]?.textContent).toContain("Visible 0"); + expect(sections[0]?.querySelector("a")?.getAttribute("href")).toBe("/chat/research/watched"); + expect(sections[1]?.querySelectorAll("a")).toHaveLength(3); + expect(sections[1]?.textContent).toContain("Session updated"); + for (const hidden of [ + "secret-title", + "private-tab", + "internal-host", + "192.0.2.12", + "Research global", + "Visible 2", + "Visible 3", + "Visible 7", + ]) { + expect(card.outerHTML).not.toContain(hidden); + } + expect(card.querySelectorAll("[data-viewer-id]")).toHaveLength(0); + }); + + it("keeps the active identity and focused session link across presence reordering", async () => { + const gateway = createGatewayHarness({ instanceId: "self" } as GatewayBrowserClient); + const sessions = createSessionsHarness("main", ["agent:main:work"]); + const result = sessions.sessions.state.result!; + sessions.publishList({ + result: { + ...result, + sessions: result.sessions.map((row) => ({ + ...row, + createdActor: { type: "human" as const, id: "alice" }, + })), + }, + }); + const { sidebar } = await mountSidebar(gateway.gateway, sessions.sessions); + sidebar.connected = true; + const now = Date.now(); + const alice = { + ts: now, + user: { id: "alice", name: "Alice" }, + watchedSessions: ["agent:main:work"], + onlineSince: now - 60_000, + lastActivityAt: now - 10_000, + lastInputSeconds: 0, + }; + const bob = { ts: now, user: { id: "bob", name: "Bob" }, lastInputSeconds: 0 }; + gateway.publishEvent("presence", { presence: [alice, bob] }); + await sidebar.updateComplete; + const aliceLink = sidebar.querySelector('[data-online-user-id="alice"]')!; + const button = aliceLink.parentElement!.querySelector("button")!; + button.click(); + await vi.waitFor(() => + expect(document.querySelector(".person-activity-hovercard")).not.toBeNull(), + ); + const card = document.querySelector(".person-activity-hovercard")!; + const sessionLink = card.querySelector(".person-activity-card__session")!; + sessionLink.focus(); + gateway.publishEvent("presence", { + presence: [{ ...alice, lastInputSeconds: 600, lastActivityAt: now }, bob], + }); + await sidebar.updateComplete; + expect(sidebar.querySelector('[data-online-user-id="alice"]')).toBe(aliceLink); + expect( + sidebar.querySelector(".sidebar-online__person")?.getAttribute("data-online-user-id"), + ).toBe("bob"); + expect(card.querySelector("h2")?.textContent).toBe("Alice"); + expect(document.activeElement).toBe(sessionLink); + expect(card.querySelector("time")?.getAttribute("datetime")).toBe( + new Date(alice.onlineSince).toISOString(), + ); + gateway.publishEvent("presence", { presence: [{ ...alice, watchedSessions: [] }, bob] }); + await sidebar.updateComplete; + expect(document.activeElement?.getAttribute("href")).toBe(sessionLink.getAttribute("href")); + expect(document.activeElement?.closest("section")?.querySelector("h3")?.textContent).toBe( + "Recent sessions", + ); + sessions.publishList({ result: { ...result, sessions: [], count: 0 } }); + await sidebar.updateComplete; + expect(document.activeElement).toBe(button); + button.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + expect(document.querySelector(".person-activity-hovercard")).toBeNull(); + expect(document.activeElement).toBe(button); + expect(button.getAttribute("aria-expanded")).toBe("false"); + }); + + it.each(["disconnect", "switch", "route", "collapse", "shell", "remove", "teardown"] as const)( + "dismisses and releases card timers on %s", + async (reason) => { + const gateway = createGatewayHarness({ instanceId: "self" } as GatewayBrowserClient); + const { sidebar, provider } = await mountSidebar( + gateway.gateway, + createSessions("main", ["agent:main:work"]), + ); + sidebar.connected = true; + const person = { + ts: Date.now(), + user: { id: "alice", name: "Alice" }, + onlineSince: Date.now() - 90_000, + }; + gateway.publishEvent("presence", { + presence: [person], + }); + await sidebar.updateComplete; + vi.useFakeTimers(); + sidebar.querySelector(".sidebar-online__details")!.click(); + await vi.waitFor(() => + expect(document.querySelector("openclaw-elapsed-time")?.textContent).toBeTruthy(), + ); + const elapsed = document.querySelector("openclaw-elapsed-time")!; + await elapsed.updateComplete; + const elapsedBeforeDismissal = elapsed.textContent; + if (reason === "disconnect") { + gateway.publish({ phase: "reconnecting" }); + } + if (reason === "switch") { + gateway.publish({ client: { instanceId: "replacement" } as GatewayBrowserClient }); + } + if (reason === "route") { + sidebar.activeRouteId = "activity"; + } + if (reason === "shell") { + sidebar.dismissTransientMenus(); + } + if (reason === "collapse") { + sidebar + .querySelector('.sidebar-online button[aria-label="Online"]')! + .click(); + } + if (reason === "remove") { + gateway.publishEvent("presence", { presence: [{ ...person, reason: "disconnect" }] }); + } + if (reason === "teardown") { + provider.remove(); + } + await sidebar.updateComplete; + await vi.waitFor(() => + expect(document.querySelector(".person-activity-hovercard")).toBeNull(), + ); + await vi.advanceTimersByTimeAsync(1_000); + await elapsed.updateComplete; + expect(elapsed.textContent).toBe(elapsedBeforeDismissal); + if (reason === "remove") { + expect(sidebar.querySelector(".sidebar-online")).toBeNull(); + const returned = { ...person, ts: Date.now(), onlineSince: Date.now() }; + gateway.publishEvent("presence", { + presence: [{ ...person, reason: "disconnect" }, returned], + }); + await sidebar.updateComplete; + sidebar.querySelector(".sidebar-online__details")!.click(); + await vi.waitFor(() => + expect( + document.querySelector(".person-activity-hovercard time")?.getAttribute("datetime"), + ).toBe(new Date(returned.onlineSince).toISOString()), + ); + expect(document.querySelector(".person-activity-hovercard h2")?.textContent).toBe("Alice"); + } + }, + ); + it("restores the collapsed online section", async () => { localStorage.setItem( "openclaw:sidebar:sessions:collapsed-sections", diff --git a/ui/src/test-helpers/app-sidebar.ts b/ui/src/test-helpers/app-sidebar.ts index 8f1b52390c49..49b9acbe6041 100644 --- a/ui/src/test-helpers/app-sidebar.ts +++ b/ui/src/test-helpers/app-sidebar.ts @@ -79,6 +79,7 @@ export type SidebarLifecycleState = HTMLElement & { routeId: string, options?: { pathname?: string; search?: string; hash?: string }, ) => void; + dismissTransientMenus: () => boolean; readonly sessionData: SessionDataController; readonly sessionOrganizer: SessionOrganizerController; listSessionGroupFolders(path?: string): Promise<{ diff --git a/ui/src/test-helpers/control-ui-e2e.ts b/ui/src/test-helpers/control-ui-e2e.ts index 37dfcbe0ab41..dea837a5bca3 100644 --- a/ui/src/test-helpers/control-ui-e2e.ts +++ b/ui/src/test-helpers/control-ui-e2e.ts @@ -334,6 +334,9 @@ export type ControlUiMockGatewayScenario = { host?: string; instanceId?: string; lastInputSeconds?: number; + onlineSince?: number; + lastActivityAt?: number; + timeZone?: string; mode?: string; platform?: string; ts?: number; @@ -1380,6 +1383,9 @@ function installControlUiMockGateway( ...(user.platform ? { platform: user.platform } : {}), ...(user.deviceFamily ? { deviceFamily: user.deviceFamily } : {}), ...(user.lastInputSeconds === undefined ? {} : { lastInputSeconds: user.lastInputSeconds }), + ...(user.onlineSince === undefined ? {} : { onlineSince: user.onlineSince }), + ...(user.lastActivityAt === undefined ? {} : { lastActivityAt: user.lastActivityAt }), + ...(user.timeZone ? { timeZone: user.timeZone } : {}), user: { id: user.id, name: user.name ?? null,