feat(ui): show activity cards for online people (#130664)

* feat(ui): show activity cards for online people

Record live person timing independently of heartbeat freshness and expose keyboard- and touch-accessible cards with visible session links. Preserve continuous online intervals across overlapping tabs without adding persistent activity history.

Closes #130649

* build(protocol): refresh generated presence timing fields

* fix(gateway): scope presence snapshots to read-access operators

Apply one recipient projection to hello, system-presence, and events. Keep person timing and time-zone details behind read access, and filter watched references with the canonical session-list policy without leaking hidden counts. Preserve idle-person metadata for readers and prevent non-reader activity-driven frames.

* fix(plugins): preserve Gateway capability load context

Carry the owning registry's metadata generation, workspace, install records, and built-artifact preference through capability discovery. Preserve standalone source loading and existing speech eligibility while avoiding synchronous source transforms on cold Talk catalog requests.

Fixes #130777

* test: align integrated presence and capability coverage

Keep the recipient matrix in its timing-aware owner, close the suite-owned Gateway for shutdown proof, and spy on the canonical install-record reader. Remove the retired private carrier's assertion allowance.

* fix(gateway): broadcast presence when clients connect

Publish completed connection rows through the canonical scoped broadcaster so established readers see first connections and reconnects without waiting for profile edits or activity. Preserve shared online intervals and reset them only after the final socket closes.

* fix(ci): align presence fixtures and sidebar type ownership

Reuse the canonical sidebar host type to remove the type-only lazy-runtime cycle. Assert omitted scopes through hello auth and denied presence reads, and supply presence version callbacks in the shared WebSocket context fixture. Retain startup admission and drain assertions without changing deadlines.

* fix(ui): retain latest scroll commands until their destination

Do not treat the virtualizer's idle debounce or a decreasing offset as reader takeover. Settle at the actual DOM end within one pixel, retaining the separate eight-pixel UI-follow policy and explicit input cancellation. Cover retargeted idle delivery and near-end precision with the real dependency, synchronize pointer baseline capture, and type the existing composer snapshot as its textarea contract.

Fixes #130892
This commit is contained in:
Peter Steinberger
2026-08-27 08:07:22 -07:00
committed by GitHub
parent 5090935817
commit 3aaf13ca84
68 changed files with 3563 additions and 643 deletions
@@ -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
+1 -2
View File
@@ -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
+12
View File
@@ -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.
+29 -20
View File
@@ -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:
@@ -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(
@@ -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)),
});
+34 -3
View File
@@ -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),
+2 -2
View File
@@ -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,
},
@@ -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");
}
@@ -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();
@@ -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,
});
@@ -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];
+2 -2
View File
@@ -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,
});
+12 -14
View File
@@ -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],
]);
});
+13 -7
View File
@@ -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);
@@ -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 = {
+2 -1
View File
@@ -60,7 +60,8 @@ const EVENT_SCOPE_GUARDS: Record<string, string[]> = {
"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],
+7 -6
View File
@@ -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],
+4 -11
View File
@@ -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 = {
@@ -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,
@@ -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();
@@ -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,
@@ -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);
});
});
});
@@ -340,6 +340,8 @@ type GatewayTransportContext = {
nodeUnsubscribeAll: (nodeId: string) => void;
hasConnectedTalkNode: () => Promise<boolean>;
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?: <TPayload>(params?: {
approvalKind?: "exec" | "plugin" | "system-agent";
+3
View File
@@ -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,
+9 -3
View File
@@ -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<typeof import("../plugins/runtime/load-context.js")>();
return {
buildPluginRuntimeLoadOptions,
setPluginRuntimeLoadContext,
createPluginRuntimeLoaderLogger: () => pluginRuntimeLoaderLogger,
};
});
vi.mock("../plugins/plugin-lookup-table.js", () => ({
loadPluginLookUpTable,
+53 -46
View File
@@ -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);
+97 -3
View File
@@ -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<typeof createGatewayRequestContext>[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({
+16 -19
View File
@@ -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<GatewayRequestContextClient>;
clients: Set<GatewayWsClient>;
isConnectionActive: NonNullable<GatewayRequestContext["isConnectionActive"]>;
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({
+8 -18
View File
@@ -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);
@@ -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<ReturnType<typeof connectReq>>): 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<ReturnType<typeof openWs>>[] = [];
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",
@@ -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<void> {
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<ReturnType<typeof connectReq>>): 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<ReturnType<typeof openWs>>[] = [];
const observePresence = (ws: Awaited<ReturnType<typeof openWs>>) => {
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();
}
}
});
});
});
+17 -15
View File
@@ -23,13 +23,14 @@ const FINGERPRINT_TIMEOUT_MS = 3_000;
const CLI_PRESENCE_TIMEOUT_MS = 3_000;
let harness: GatewayServerHarness;
let harnessClose: Promise<void> | 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");
});
});
+235
View File
@@ -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<typeof attachGatewayWsForTest>["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();
});
});
+80
View File
@@ -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<GatewayWsClient>,
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<GatewayWsClient>,
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;
}
@@ -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({
@@ -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),
};
}
+11 -16
View File
@@ -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<typeof recordPairedNodeDisconnection>[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.
@@ -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;
@@ -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<typeof import("../../../version.js")>();
@@ -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,
@@ -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 () => {
@@ -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");
});
+2
View File
@@ -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;
+74 -28
View File
@@ -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();
});
});
+18 -5
View File
@@ -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<typeof broadcastPresenceSnapshot>[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;
};
+4
View File
@@ -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;
};
+38 -17
View File
@@ -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.
@@ -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<typeof createSpeechFixture>) => 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<typeof createSpeechFixture>) {
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<typeof createSpeechFixture>,
workspaceDir = fixture.workspaceDir,
pluginIds: string[] = [],
pluginMetadataSnapshot?: ReturnType<typeof publishMetadata>,
) {
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([]);
});
});
},
);
});
@@ -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,
});
});
+60 -15
View File
@@ -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<K extends CapabilityProviderRegistryKey>(
@@ -412,13 +442,18 @@ function loadCapabilityProviderEntries<K extends CapabilityProviderRegistryKey>(
(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<K extends CapabilityProviderRegistryKey>(
}
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<K extends CapabilityProviderRegi
return undefined;
}
const activeRegistry = getLoadedRuntimePluginRegistry();
const activeRegistry =
getPluginRuntimeGatewayRequestScope()?.pluginRegistry ?? getLoadedRuntimePluginRegistry();
const activeProviders = filterPolicyAllowedCapabilityProviders({
entries: activeRegistry?.[params.key] ?? [],
registry: activeRegistry,
@@ -463,7 +497,11 @@ export function resolvePluginCapabilityProvider<K extends CapabilityProviderRegi
return activeProvider;
}
const pluginMetadataSnapshot = loadCapabilityManifestSnapshot({ cfg: params.cfg });
const loadContext = resolveCapabilityLoadContext(activeRegistry, params.cfg);
const pluginMetadataSnapshot = loadCapabilityManifestSnapshot({
cfg: params.cfg,
pluginMetadataSnapshot: loadContext?.metadataSnapshot,
});
let pluginIds = resolveCapabilityPluginIds({
key: params.key,
cfg: params.cfg,
@@ -487,6 +525,7 @@ export function resolvePluginCapabilityProvider<K extends CapabilityProviderRegi
const loadOptions = createCapabilityProviderLoadOptions({
cfg: params.cfg,
resolution: pluginIds,
loadContext,
});
const loadedProviders = loadCapabilityProviderEntries({
key: params.key,
@@ -505,7 +544,8 @@ export function resolvePluginCapabilityProviders<K extends CapabilityProviderReg
return [];
}
const activeRegistry = getLoadedRuntimePluginRegistry();
const activeRegistry =
getPluginRuntimeGatewayRequestScope()?.pluginRegistry ?? getLoadedRuntimePluginRegistry();
const activeProviders = filterPolicyAllowedCapabilityProviders({
entries: activeRegistry?.[params.key] ?? [],
registry: activeRegistry,
@@ -544,7 +584,11 @@ export function resolvePluginCapabilityProviders<K extends CapabilityProviderReg
requestedProviders && shouldScopeCapabilityLoadToRequestedProviders(params.key)
? requestedProviders
: undefined;
const pluginMetadataSnapshot = loadCapabilityManifestSnapshot({ cfg: params.cfg });
const loadContext = resolveCapabilityLoadContext(activeRegistry, params.cfg);
const pluginMetadataSnapshot = loadCapabilityManifestSnapshot({
cfg: params.cfg,
pluginMetadataSnapshot: loadContext?.metadataSnapshot,
});
const requestedPluginIds = resolveRequestedCapabilityPluginIds({
key: params.key,
cfg: params.cfg,
@@ -566,6 +610,7 @@ export function resolvePluginCapabilityProviders<K extends CapabilityProviderReg
const loadOptions = createCapabilityProviderLoadOptions({
cfg: params.cfg,
resolution: pluginIds,
loadContext,
});
const loadedProviders = loadCapabilityProviderEntries({
key: params.key,
+22
View File
@@ -21,6 +21,7 @@ import {
resolvePluginMetadataSnapshot,
} from "../plugin-metadata-snapshot.js";
import type { PluginMetadataSnapshot } from "../plugin-metadata-snapshot.types.js";
import type { PluginRegistry } from "../registry-types.js";
import type { PluginLogger } from "../types.js";
const log = createSubsystemLogger("plugins");
@@ -140,6 +141,27 @@ export type PluginRuntimeLoadContext = {
preferBuiltPluginArtifacts?: boolean;
};
// Source and built consumers must read the same facts from the owning registry.
const pluginRuntimeLoadContext = Symbol.for("openclaw.pluginRuntimeLoadContext");
type RuntimeContextRegistry = PluginRegistry & {
[pluginRuntimeLoadContext]?: PluginRuntimeLoadContext;
};
export function setPluginRuntimeLoadContext(
registry: PluginRegistry,
context: PluginRuntimeLoadContext,
): void {
// SAFETY: Internal registries are extensible; this module owns the optional symbol slot.
(registry as RuntimeContextRegistry)[pluginRuntimeLoadContext] = context;
}
/** Reads load facts carried by an exact lifecycle-owned registry. */
export const getPluginRuntimeLoadContext = (
registry: PluginRegistry | undefined,
): PluginRuntimeLoadContext | undefined =>
// 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,
+44 -24
View File
@@ -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`<div class="sidebar-online__list">
${users.map((user) => {
const { pathname, search, href } = activityPersonLocation(user.id, host.basePath);
return html`<a
class="sidebar-online__person ${isPresenceViewerIdle(user)
? "sidebar-online__person--away"
: ""}"
data-online-user-id=${user.id}
href=${href}
@click=${(event: MouseEvent) => {
if (!shouldHandleNavigationClick(event)) {
return;
}
event.preventDefault();
host.onNavigate?.("activity", { pathname, search });
}}
>
<openclaw-viewer-avatar .user=${user} variant="footer"></openclaw-viewer-avatar>
<span class="sidebar-online__person-name">${presenceViewerLabel(user)}</span>
<span class="sidebar-online__person-action" aria-hidden="true"
>${icons.chevronRight}</span
>
</a>`;
})}
${repeat(
users,
(user) => user.id,
(user) => {
const activity = personActivityLink(
user.id,
personActivityRouting({
basePath: host.basePath,
navigate: (route, options) => host.onNavigate?.(route, options),
}),
)!;
return html`<div class="sidebar-online__row">
<a
class="sidebar-online__person ${isPresenceViewerIdle(user)
? "sidebar-online__person--away"
: ""}"
data-online-user-id=${user.id}
href=${activity.href}
@click=${activity.open}
>
<openclaw-viewer-avatar
.user=${user}
.markAsViewer=${false}
variant="footer"
aria-hidden="true"
></openclaw-viewer-avatar>
<span class="sidebar-online__person-name"
>${presenceViewerLabel(user)}</span
> </a
><button
class="sidebar-online__details"
type="button"
aria-haspopup="dialog"
aria-expanded="false"
aria-label=${t("presence.card.details", { name: presenceViewerLabel(user) })}
>
<span aria-hidden="true">${icons.chevronRight}</span>
</button>
</div>`;
},
)}
</div>`}
</section>
`;
+7
View File
@@ -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(
+276
View File
@@ -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`<time
datetime=${date.toISOString()}
title=${date.toLocaleString(i18n.getLocale())}
aria-label=${date.toLocaleString(i18n.getLocale())}
><openclaw-elapsed-time .startMs=${timestamp}></openclaw-elapsed-time
></time>`;
}
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`<section class="person-activity-card__section">
<h3>${title}</h3>
${sessions.length
? html`<div class="person-activity-card__sessions">
${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`<a
class="person-activity-card__session"
href=${target.href}
@click=${(event: MouseEvent) => {
if (!shouldHandleNavigationClick(event)) {
return;
}
event.preventDefault();
input.openSession(row, agentId);
}}
><span class="person-activity-card__session-icon" aria-hidden="true"
>${icons.messageSquare}</span
>
<span class="person-activity-card__session-copy"
><span>${resolveSessionDisplayName(row.key, row)}</span> ${recent &&
row.updatedAt != null
? html`<small
>${t("presence.card.sessionUpdated")} ${elapsed(row.updatedAt)}
${t("presence.card.ago")}</small
>`
: nothing}</span
>
</a>`;
},
)}
</div>`
: html`<p class="person-activity-card__muted">
${t(recent ? "presence.card.noRecentSessions" : "presence.card.noVisibleSessions")}
</p>`}
</section>`;
}
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<string, ScopedSession>();
// 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`<div class="person-activity-card">
<header class="person-activity-card__header">
<openclaw-viewer-avatar
.user=${user}
.markAsViewer=${false}
variant="footer"
aria-hidden="true"
></openclaw-viewer-avatar>
<div>
<h2>${user.name ?? user.email ?? t("presence.card.person")}</h2>
<span class="person-activity-card__status"
><span aria-hidden="true"></span>${t("presence.rosterTitle")}</span
>
</div>
</header>
<dl class="person-activity-card__facts">
<div>
<dt>${t("presence.card.onlineFor")}</dt>
<dd>
${onlineSince === undefined ? t("presence.card.notObserved") : elapsed(onlineSince)}
</dd>
</div>
${where.length || zones.length
? html`<div>
<dt>${t("presence.card.where")}</dt>
<dd>
${where.map((description) => html`<span>${description}</span>`)}${zones.map(
(zone) => html`<small>${t("presence.card.reportedTimeZone", { zone })}</small>`,
)}
</dd>
</div>`
: nothing}
<div>
<dt>${t("presence.card.lastActivity")}</dt>
<dd>
${lastActivityAt === undefined
? t("presence.card.notObserved")
: html`<span>${elapsed(lastActivityAt)} ${t("presence.card.ago")}</span>`}
</dd>
</div>
</dl>
${renderSessions(viewing, input, false)}${renderSessions(recent, input, true)}
<footer>
<a href=${activity.href} @click=${activity.open}
>${t("presence.card.viewActivity")}<span aria-hidden="true">${icons.chevronRight}</span></a
>
</footer>
</div>`;
}
+34 -2
View File
@@ -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<HTMLElement>('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);
@@ -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<HTMLElement>('a[href]:not([tabindex="-1"])') ?? []),
];
return this.hovercard.focusables();
}
private personActivity(): PersonActivityRouting | undefined {
@@ -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<typeof import("./sidebar-people.runtime.ts")> | 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<HTMLElement>(".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") });
}
},
);
};
}
+399
View File
@@ -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<HTMLElement>(".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<HTMLElement>("[data-online-user-id]")?.dataset.onlineUserId;
const trigger = row.querySelector<HTMLElement>(".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();
}
}
@@ -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") {
+228
View File
@@ -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);
},
);
});
});
+21
View File
@@ -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 persons 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",
@@ -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`<div>row ${index}</div>`,
}));
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(),
@@ -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 () => {
+160
View File
@@ -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;
+52 -8
View File
@@ -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 {
@@ -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<HTMLAnchorElement>('[data-online-user-id="alice"]')?.click();
const aliceLink = sidebar.querySelector<HTMLAnchorElement>('[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<HTMLButtonElement>(".sidebar-online__details")!.click();
await vi.waitFor(() =>
expect(document.querySelector(".person-activity-hovercard")).not.toBeNull(),
);
const card = document.querySelector<HTMLElement>(".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<HTMLAnchorElement>('[data-online-user-id="alice"]')!;
const button = aliceLink.parentElement!.querySelector<HTMLButtonElement>("button")!;
button.click();
await vi.waitFor(() =>
expect(document.querySelector(".person-activity-hovercard")).not.toBeNull(),
);
const card = document.querySelector<HTMLElement>(".person-activity-hovercard")!;
const sessionLink = card.querySelector<HTMLAnchorElement>(".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<HTMLButtonElement>(".sidebar-online__details")!.click();
await vi.waitFor(() =>
expect(document.querySelector("openclaw-elapsed-time")?.textContent).toBeTruthy(),
);
const elapsed = document.querySelector<LitElement>("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<HTMLButtonElement>('.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<HTMLButtonElement>(".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",
+1
View File
@@ -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<{
+6
View File
@@ -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,