fix(ui): deduplicate gateway-native session hosts

This commit is contained in:
Vincent Koc
2026-07-30 15:22:31 +08:00
parent ad1dc60259
commit bb68389a66
20 changed files with 453 additions and 38 deletions

View File

@@ -56,6 +56,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- **Control UI native session catalogs:** omit the Gateway's same-install node-host copies from Codex and Claude Code catalogs, hide empty host groups, and remove the empty Coding section when no live work sessions exist.
- **Control UI dynamic deep links:** reuse the initial route loader result when publishing real agent, session, dashboard, Workboard, Memory, and Plugins paths, avoiding redundant route-loader work during startup. Thanks @shakkernerd.
- **Linux gateway service ownership:** refuse user-scope systemd publication and activation when the same gateway unit name is already owned or cannot be verified in the system scope, including `--force`, with actionable recovery guidance instead of creating restart-looping dual managers. Fixes #116129.
- **macOS remote tunnel lifecycle:** prevent cancelled or superseded restart backoffs from recreating SSH tunnels, and join a tunnel create that another caller started while the actor was suspended.

View File

@@ -2568,6 +2568,51 @@ describe("Claude session catalog", () => {
]);
});
it("omits the Gateway's same-install node host from native discovery", async () => {
const home = await createHome();
process.env.HOME = home;
const invoke = vi.fn(async ({ nodeId }: { nodeId: string }) => ({
payloadJSON: JSON.stringify({
sessions: [
{
threadId: `remote-${nodeId}`,
status: "stored",
source: "claude-cli",
archived: false,
},
],
}),
}));
const provider = captureCatalogProvider({
nodes: {
list: vi.fn().mockResolvedValue({
nodes: [
{
nodeId: "gateway-node",
displayName: "Gateway node",
gatewayLocal: true,
connected: true,
commands: [CLAUDE_SESSIONS_LIST_COMMAND],
},
{
nodeId: "remote-node",
displayName: "Remote node",
connected: true,
commands: [CLAUDE_SESSIONS_LIST_COMMAND],
},
],
}),
invoke,
},
} as unknown as PluginRuntime);
const hosts = await provider.list({});
expect(hosts.map((host) => host.hostId)).toEqual(["gateway:local", "node:remote-node"]);
expect(invoke).toHaveBeenCalledTimes(1);
expect(invoke).toHaveBeenCalledWith(expect.objectContaining({ nodeId: "remote-node" }));
});
it("bounds how long a hung paired-node catalog can delay the caller", async () => {
vi.useFakeTimers();
try {

View File

@@ -1497,6 +1497,7 @@ async function listClaudeSessionCatalog(params: {
const eligible = nodes
.filter(
(node) =>
node.gatewayLocal !== true &&
node.commands?.includes(CLAUDE_SESSIONS_LIST_COMMAND) &&
(!requested || requested.has(`node:${node.nodeId}`)),
)

View File

@@ -1162,6 +1162,51 @@ describe("Codex supervision catalog", () => {
});
});
it("omits the Gateway's same-install node host from native discovery", async () => {
const control = createControl({
listPage: vi.fn(async () => ({
sessions: [{ threadId: "local", status: "idle", archived: false }],
})),
});
const invoke = vi.fn<PluginRuntime["nodes"]["invoke"]>(async ({ nodeId }) => ({
payloadJSON: JSON.stringify({
sessions: [{ threadId: `remote-${nodeId}`, status: "idle", archived: false }],
}),
}));
const { runtime } = createRuntime({
nodes: [
{
nodeId: "gateway-node",
displayName: "Gateway node",
gatewayLocal: true,
connected: true,
commands: [CODEX_APP_SERVER_THREADS_LIST_COMMAND],
},
{
nodeId: "remote-node",
displayName: "Remote node",
connected: true,
commands: [CODEX_APP_SERVER_THREADS_LIST_COMMAND],
},
],
invoke,
});
const result = await listCodexSessionCatalog({
bindingStore: createCodexTestBindingStore(),
config,
runtime,
control,
});
expect(result.hosts.map((host) => host.hostId)).toEqual([
CODEX_LOCAL_SESSION_HOST_ID,
"node:remote-node",
]);
expect(invoke).toHaveBeenCalledTimes(1);
expect(invoke).toHaveBeenCalledWith(expect.objectContaining({ nodeId: "remote-node" }));
});
it("isolates federated host failures while preserving selected healthy hosts", async () => {
const control = createControl({
listPage: vi.fn(async () => ({

View File

@@ -627,6 +627,7 @@ async function listCodexSessionCatalog(params: {
nodes = (await (params.listNodes?.() ?? params.runtime.nodes.list())).nodes
.filter(
(node) =>
node.gatewayLocal !== true &&
node.commands?.includes(CODEX_APP_SERVER_THREADS_LIST_COMMAND) &&
(!requestedHostIds || requestedHostIds.has(`node:${node.nodeId}`)),
)

View File

@@ -11,6 +11,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { listDevicePairing, resolveNodePairingState } from "../../infra/device-pairing.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { projectNodePairing } from "../../infra/node-pairing.js";
import { resolveLocalNodeId } from "../../node-host/local-id.js";
import type { NodeListNode } from "../../shared/node-list-types.js";
import { replaceRemoteNodeSkills } from "../../skills/runtime/remote-skills.js";
import { recordRemoteNodeInfo, refreshRemoteNodeBins } from "../../skills/runtime/remote.js";
@@ -61,6 +62,7 @@ function listNodesForClient(params: {
pairedNodes: ReturnType<typeof projectNodePairing>["paired"];
pendingNodes: ReturnType<typeof projectNodePairing>["pending"];
connectedNodes: readonly NodeSession[];
localNodeId: string | null;
}): NodeListNode[] {
const catalog = createKnownNodeCatalog({
pairedDevices: params.pairedDevices,
@@ -68,7 +70,9 @@ function listNodesForClient(params: {
pendingNodes: params.pendingNodes,
connectedNodes: params.connectedNodes,
});
const nodes = listKnownNodes(catalog);
const nodes = listKnownNodes(catalog).map((node) =>
node.nodeId === params.localNodeId ? Object.assign({}, node, { gatewayLocal: true }) : node,
);
if (nodeInvokePolicy.canReadPendingNodePairing(params.client)) {
return nodes;
}
@@ -223,12 +227,19 @@ export const nodeReadHandlers: GatewayRequestHandlers = {
const devicePairing = await listDevicePairing();
const nodePairing = projectNodePairing(devicePairing.paired);
const connectedNodes = listCurrentConnectedNodes(context, devicePairing.paired);
const localNodeId = await resolveLocalNodeId().catch((error: unknown) => {
context.logGateway.warn(
`failed to resolve same-install node-host identity: ${formatErrorMessage(error)}`,
);
return null;
});
const nodes = listNodesForClient({
client,
pairedDevices: devicePairing.paired,
pairedNodes: nodePairing.paired,
pendingNodes: nodePairing.pending,
connectedNodes,
localNodeId,
});
const activeNodeId = context.nodeRegistry.getActiveNode(connectedNodes)?.nodeId;
const nodesWithPresence = activeNodeId

View File

@@ -1054,7 +1054,7 @@ describe("loadGatewayPlugins", () => {
expect(opts.req.method).toBe("node.list");
opts.respond(true, {
nodes: [
{ nodeId: "connected", connected: true },
{ nodeId: "connected", connected: true, gatewayLocal: true },
{ nodeId: "offline", connected: false },
],
});
@@ -1066,7 +1066,7 @@ describe("loadGatewayPlugins", () => {
const result = await runtime.nodes.list({ connected: true });
expect(getLastDispatchedParams()).toStrictEqual({});
expect(result.nodes).toEqual([{ nodeId: "connected", connected: true }]);
expect(result.nodes).toEqual([{ nodeId: "connected", connected: true, gatewayLocal: true }]);
});
test("projects effective node-command policy into the plugin node runtime", async () => {

View File

@@ -17,7 +17,7 @@ import {
import { upsertPresence } from "../../../infra/system-presence.js";
import { loadVoiceWakeRoutingConfig } from "../../../infra/voicewake-routing.js";
import { loadVoiceWakeConfig } from "../../../infra/voicewake.js";
import { loadNodeHostConfig } from "../../../node-host/config.js";
import { resolveLocalNodeId } from "../../../node-host/local-id.js";
import { recordRemoteNodeInfo, refreshRemoteNodeBins } from "../../../skills/runtime/remote.js";
import { ensureProfileForEmail } from "../../../state/user-profiles.js";
import {
@@ -63,16 +63,6 @@ function isReleasedVersion(version: string): boolean {
return RELEASED_VERSION_RE.test(version);
}
/**
* Lazily resolve the local node host's nodeId from canonical shared SQLite state.
* Process-stable: only changes on `openclaw node install`, which requires restart.
*/
let cachedLocalNodeId: Promise<string | null> | undefined;
async function resolveLocalNodeId(): Promise<string | null> {
cachedLocalNodeId ??= loadNodeHostConfig().then((config) => config?.nodeId ?? null);
return await cachedLocalNodeId;
}
function setSocketMaxPayload(socket: WebSocket, maxPayload: number): void {
const receiver = (socket as { _receiver?: { _maxPayload?: number } })["_receiver"];
if (receiver) {

View File

@@ -0,0 +1,68 @@
import fs from "node:fs/promises";
import { afterEach, describe, expect, it } from "vitest";
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
import {
createOpenClawTestState,
type OpenClawTestState,
} from "../test-utils/openclaw-test-state.js";
import { configureNodeHost } from "./config.js";
import { resolveLocalNodeId } from "./local-id.js";
const states: OpenClawTestState[] = [];
afterEach(async () => {
closeOpenClawStateDatabaseForTest();
while (states.length > 0) {
await states.pop()?.cleanup();
}
});
describe("resolveLocalNodeId", () => {
it("reads and process-caches the canonical same-install node id", async () => {
const state = await createOpenClawTestState({
label: "local-node-id",
layout: "state-only",
});
states.push(state);
await configureNodeHost({
candidateNodeId: "gateway-local-node",
fallbackDisplayName: "Gateway node",
gateway: {},
env: state.env,
nowMs: 1,
});
await expect(resolveLocalNodeId(state.env)).resolves.toBe("gateway-local-node");
await configureNodeHost({
candidateNodeId: "replacement-node",
fallbackDisplayName: "Replacement node",
gateway: {},
env: state.env,
nowMs: 2,
});
await expect(resolveLocalNodeId(state.env)).resolves.toBe("gateway-local-node");
});
it("retries after a failed canonical config read", async () => {
const state = await createOpenClawTestState({
label: "local-node-id-retry",
layout: "state-only",
});
states.push(state);
const legacyPath = state.statePath("node.json");
await fs.writeFile(legacyPath, "{}\n", "utf8");
await expect(resolveLocalNodeId(state.env)).rejects.toThrow("openclaw doctor --fix");
await fs.rm(legacyPath);
await configureNodeHost({
candidateNodeId: "recovered-local-node",
fallbackDisplayName: "Recovered node",
gateway: {},
env: state.env,
nowMs: 1,
});
await expect(resolveLocalNodeId(state.env)).resolves.toBe("recovered-local-node");
});
});

27
src/node-host/local-id.ts Normal file
View File

@@ -0,0 +1,27 @@
import { resolveStateDir } from "../config/paths.js";
import { loadNodeHostConfig } from "./config.js";
const localNodeIdByStateDir = new Map<string, Promise<string | null>>();
/**
* Resolve the same-install node host from canonical shared SQLite state.
* Node-host config changes require restart, so this fact stays process-stable.
*/
export async function resolveLocalNodeId(
env: NodeJS.ProcessEnv = process.env,
): Promise<string | null> {
const stateDir = resolveStateDir(env);
let pending = localNodeIdByStateDir.get(stateDir);
if (!pending) {
pending = loadNodeHostConfig(env).then((config) => config?.nodeId ?? null);
localNodeIdByStateDir.set(stateDir, pending);
}
try {
return await pending;
} catch (error) {
if (localNodeIdByStateDir.get(stateDir) === pending) {
localNodeIdByStateDir.delete(stateDir);
}
throw error;
}
}

View File

@@ -80,6 +80,8 @@ type RuntimeNodeListResult = {
connected?: boolean;
caps?: string[];
commands?: string[];
/** True only for the node host installed alongside this Gateway. */
gatewayLocal?: boolean;
/** Advertised commands currently permitted by Gateway node-command policy. */
invocableCommands?: string[];
nodePluginTools?: NodePluginToolDescriptor[];

View File

@@ -10,6 +10,8 @@ export type NodeListNode = {
uiVersion?: string;
clientId?: string;
clientMode?: string;
/** This node host runs from the Gateway's own canonical node-host installation. */
gatewayLocal?: boolean;
remoteIp?: string;
deviceFamily?: string;
modelIdentifier?: string;

View File

@@ -22,6 +22,7 @@ import {
formatSidebarTimestamp,
type CatalogBackingSessionDisplay,
type CatalogSessionMenuRequest,
visibleCatalogHosts,
} from "./app-sidebar-session-catalogs.ts";
import { renderSidebarSessionSectionHeader } from "./app-sidebar-session-section-header.ts";
import { icons } from "./icons.ts";
@@ -119,15 +120,8 @@ export function renderSessionCatalogGroups(params: SessionCatalogGroupsParams) {
const sectionId = `catalog:${catalog.id}`;
const collapsed = params.collapsedSections.has(sectionId);
const hosts = catalog.hosts;
const visibleHosts: SessionCatalogHost[] = [];
for (const host of hosts) {
const sessions = host.sessions.filter(
(session) => !params.creatorId || session.createdActor?.id === params.creatorId,
);
if (sessions.length > 0) {
visibleHosts.push(sessions.length === host.sessions.length ? host : { ...host, sessions });
}
}
// Catalog providers own host identity; the sidebar only removes hosts with no visible rows.
const visibleHosts = visibleCatalogHosts(hosts, params.creatorId);
const rows = visibleHosts.flatMap((host) =>
host.sessions.map((session) => ({ host, session })),
);

View File

@@ -1,7 +1,8 @@
// @vitest-environment node
import { afterEach, describe, expect, it, vi } from "vitest";
import type { SessionCatalogHost } from "../../../packages/gateway-protocol/src/index.ts";
import { i18n } from "../i18n/index.ts";
import { formatSidebarTimestamp } from "./app-sidebar-session-catalogs.ts";
import { formatSidebarTimestamp, visibleCatalogHosts } from "./app-sidebar-session-catalogs.ts";
describe("formatSidebarTimestamp", () => {
afterEach(async () => {
@@ -31,3 +32,60 @@ describe("formatSidebarTimestamp", () => {
expect(formatSidebarTimestamp(Date.now() + 5 * 60_000)).toBe("in 5m");
});
});
describe("visibleCatalogHosts", () => {
const session = (threadId: string, name: string) => ({
threadId,
name,
status: "idle",
archived: false,
canContinue: true,
canArchive: false,
});
it("removes empty hosts", () => {
const hosts: SessionCatalogHost[] = [
{
hostId: "gateway:local",
label: "Gateway",
kind: "gateway",
connected: true,
sessions: [session("shared", "Gateway copy")],
},
{
hostId: "node:empty",
label: "Empty node",
kind: "node",
connected: true,
sessions: [],
},
];
expect(visibleCatalogHosts(hosts)).toEqual([hosts[0]]);
});
it("filters sessions by creator without inferring host identity", () => {
const hosts: SessionCatalogHost[] = [
{
hostId: "node:remote",
label: "Remote node",
kind: "node",
connected: true,
sessions: [
{
...session("mine", "Mine"),
createdActor: { id: "operator:mine", type: "human" },
},
{
...session("theirs", "Theirs"),
createdActor: { id: "operator:theirs", type: "human" },
},
],
},
];
expect(visibleCatalogHosts(hosts, "operator:mine")).toEqual([
{ ...hosts[0]!, sessions: [hosts[0]!.sessions[0]!] },
]);
});
});

View File

@@ -1,5 +1,6 @@
import type {
SessionCatalog,
SessionCatalogHost,
SessionCatalogSession,
} from "../../../packages/gateway-protocol/src/index.ts";
import type { ApplicationNavigationOptions } from "../app/context.ts";
@@ -42,6 +43,22 @@ export function adoptedCatalogSessionKeys(catalogs: readonly SessionCatalog[]):
return keys;
}
export function visibleCatalogHosts(
hosts: readonly SessionCatalogHost[],
creatorId?: string | null,
): SessionCatalogHost[] {
const visible: SessionCatalogHost[] = [];
for (const host of hosts) {
const sessions = host.sessions.filter(
(session) => !creatorId || session.createdActor?.id === creatorId,
);
if (sessions.length > 0) {
visible.push(sessions.length === host.sessions.length ? host : { ...host, sessions });
}
}
return visible;
}
export type CatalogBackingSessionDisplay = {
label: string;
subtitle?: string;

View File

@@ -395,15 +395,7 @@ function renderSessionListBody(params: {
: nothing}`;
}
if (section.id === "work") {
// Keep the Coding header visible beside catalog sections just as it
// was when those sections were nested inside it.
if (
section.totalRowCount === 0 &&
!(
catalogsVisible &&
(params.catalogs.catalogs.length > 0 || params.catalogs.refreshStatus.error !== null)
)
) {
if (section.totalRowCount === 0) {
return nothing;
}
return renderSessionSection({ host, section });

View File

@@ -207,7 +207,15 @@ function hostGroupedNativeCatalogs() {
async function expandCodingSection(page: Page) {
const toggle = page.locator('[data-session-section="work"] .sidebar-session-group-toggle');
await toggle.waitFor({ state: "visible" });
await page.waitForFunction(() =>
Boolean(
document.querySelector('[data-session-section="work"]') ??
document.querySelector('[data-session-section^="catalog:"]'),
),
);
if ((await toggle.count()) === 0) {
return;
}
if ((await toggle.getAttribute("aria-expanded")) === "false") {
await toggle.click();
}
@@ -259,6 +267,9 @@ suite("Claude native session catalog", () => {
expect(await gatewayHost.getByText("Gateway Mac", { exact: true }).count()).toBe(0);
expect(await gatewayHost.locator(".sidebar-recent-session").count()).toBe(1);
expect(await buildHost.locator(".sidebar-recent-session").count()).toBe(1);
expect(await section.getByText(`${catalogLabel} local plan`, { exact: true }).count()).toBe(
1,
);
}
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();

View File

@@ -29,9 +29,21 @@ const uiProofArtifactDir = path.join(
"native-session-discovery",
);
async function expandCodingSection(page: Page) {
async function expandCodingSection(page: Page, required = false) {
const toggle = page.locator('[data-session-section="work"] .sidebar-session-group-toggle');
await toggle.waitFor({ state: "visible" });
if (required) {
await toggle.waitFor({ state: "visible" });
} else {
await page.waitForFunction(() =>
Boolean(
document.querySelector('[data-session-section="work"]') ??
document.querySelector('[data-session-section^="catalog:"]'),
),
);
if ((await toggle.count()) === 0) {
return;
}
}
if ((await toggle.getAttribute("aria-expanded")) === "false") {
await toggle.click();
}
@@ -198,7 +210,7 @@ suite("Codex native session catalog", () => {
try {
await page.goto(`${server.baseUrl}chat`);
await page.evaluate(() => document.documentElement.setAttribute("data-theme-mode", "dark"));
await expandCodingSection(page);
await expandCodingSection(page, true);
const sessionGroups = page.locator(".sidebar-recent-sessions");
const workSection = sessionGroups.locator(':scope > [data-session-section="work"]');
const liveRows = workSection.locator(":scope > .sidebar-recent-sessions__list");

View File

@@ -116,7 +116,6 @@ suite("OpenCode and Pi external session catalogs", () => {
});
await page.goto(`${server.baseUrl}chat`);
await page.locator('[data-session-section="work"] .sidebar-session-group-toggle').click();
await page.getByText("OpenCode release review", { exact: true }).click();
await expect.poll(() => page.getByText("OpenCode transcript loaded").count()).toBe(1);
await page.getByText("Pi architecture notes", { exact: true }).click();

View File

@@ -0,0 +1,139 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { chromium, type Browser } from "playwright";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
canRunPlaywrightChromium,
installMockGateway,
resolvePlaywrightChromiumExecutablePath,
startControlUiE2eServer,
type ControlUiE2eServer,
} from "../test-helpers/control-ui-e2e.ts";
const executablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
const available = canRunPlaywrightChromium(executablePath);
const allowMissing = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
const suite = available || !allowMissing ? describe : describe.skip;
const captureUiProofEnabled = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
const collapsedSessionSectionsStorageKey = "openclaw:sidebar:sessions:collapsed-sections";
const uiProofArtifactDir = path.join(
process.cwd(),
".artifacts",
"control-ui-e2e",
"native-session-discovery",
);
let browser: Browser;
let server: ControlUiE2eServer;
suite("native session sidebar", () => {
beforeAll(async () => {
if (!available) {
throw new Error(`Playwright Chromium is unavailable at ${executablePath}`);
}
server = await startControlUiE2eServer();
browser = await chromium.launch({ executablePath });
});
afterAll(async () => {
await browser?.close();
await server?.close();
});
it("hides empty native hosts and the empty Coding section", async () => {
const page = await browser.newPage({
deviceScaleFactor: 2,
viewport: { height: 1100, width: 1440 },
});
await page.addInitScript(
(key) => localStorage.removeItem(key),
collapsedSessionSectionsStorageKey,
);
await installMockGateway(page, {
featureMethods: ["chat.metadata", "chat.startup", "sessions.catalog.list"],
methodResponses: {
"sessions.catalog.list": {
catalogs: [
{
id: "codex",
label: "Codex",
capabilities: { continueSession: true, archive: true },
hosts: [
{
hostId: "gateway:local",
label: "Gateway",
kind: "gateway",
connected: true,
sessions: [
{
threadId: "thread-shared",
name: "Shared gateway session",
cwd: "/workspace/openclaw",
status: "idle",
archived: false,
canContinue: true,
canArchive: true,
},
],
},
{
hostId: "node:remote",
label: "Remote Workstation",
kind: "node",
connected: true,
nodeId: "remote",
sessions: [
{
threadId: "thread-remote",
name: "Remote-only session",
cwd: "/workspace/remote",
status: "idle",
archived: false,
canContinue: false,
canArchive: false,
},
],
},
{
hostId: "node:empty",
label: "Empty Workstation",
kind: "node",
connected: true,
nodeId: "empty",
sessions: [],
},
],
},
],
},
},
});
try {
await page.goto(`${server.baseUrl}chat`);
await page.evaluate(() => {
document.documentElement.setAttribute("data-theme", "openknot");
document.documentElement.setAttribute("data-theme-mode", "dark");
});
const sessionGroups = page.locator(".sidebar-recent-sessions");
const section = sessionGroups.locator(':scope > [data-session-section="catalog:codex"]');
await section.waitFor({ state: "visible" });
await section.getByText("Shared gateway session", { exact: true }).waitFor();
await section.getByText("Remote-only session", { exact: true }).waitFor();
if (captureUiProofEnabled) {
await mkdir(uiProofArtifactDir, { recursive: true });
await sessionGroups.screenshot({
animations: "disabled",
path: path.join(uiProofArtifactDir, "08-after-deduplicated-session-hosts.png"),
});
}
expect(await sessionGroups.locator(':scope > [data-session-section="work"]').count()).toBe(0);
expect(await section.getByText("Shared gateway session", { exact: true }).count()).toBe(1);
expect(await section.locator('[data-session-catalog-host="node:remote"]').count()).toBe(1);
expect(await section.locator('[data-session-catalog-host="node:empty"]').count()).toBe(0);
} finally {
await page.close();
}
});
});