fix(ui): keep chat usable during session loading

This commit is contained in:
Peter Steinberger
2026-05-31 16:08:45 +01:00
parent 972d2b66d1
commit 89cdf164ca
6 changed files with 196 additions and 1 deletions

View File

@@ -8,6 +8,7 @@ import { createServer, type ViteDevServer } from "vite";
import { PROTOCOL_VERSION } from "../../../packages/gateway-protocol/src/version.js";
import { CONTROL_UI_BOOTSTRAP_CONFIG_PATH } from "../../../src/gateway/control-ui-contract.js";
import {
controlUiBrowserOnlySharedModuleAliases,
resolveSourcePackageAliasesForVite,
resolveTsconfigPathAliasesForVite,
} from "../../vite.config.ts";
@@ -42,6 +43,7 @@ export type ControlUiMockGatewayScenario = {
assistantAgentId?: string;
assistantName?: string;
defaultAgentId?: string;
deferredMethods?: string[];
historyMessages?: unknown[];
methodResponses?: Record<string, unknown>;
models?: Array<{ id: string; name: string; provider: string }>;
@@ -111,6 +113,7 @@ export async function startControlUiE2eServer(): Promise<ControlUiE2eServer> {
],
},
publicDir: path.join(uiRoot, "public"),
plugins: [controlUiBrowserOnlySharedModuleAliases()],
resolve: {
alias: [
{ find: "json5", replacement: json5EsmPath },
@@ -170,6 +173,7 @@ function normalizeScenario(
assistantAgentId: scenario.assistantAgentId?.trim() || defaultAgentId,
assistantName: scenario.assistantName?.trim() || "OpenClaw",
defaultAgentId,
deferredMethods: scenario.deferredMethods ?? [],
historyMessages: scenario.historyMessages ?? [],
methodResponses: scenario.methodResponses ?? {},
models: scenario.models ?? [{ id: "gpt-5.5", name: "gpt-5.5", provider: "openai" }],
@@ -246,7 +250,7 @@ function installControlUiMockGateway(input: {
const scenario: BrowserScenario = input.scenario;
const protocolVersion = input.protocolVersion;
const deferredMethods: string[] = [];
const deferredMethods: string[] = [...scenario.deferredMethods];
const deferredResponses: DeferredResponse[] = [];
const requests: BrowserRequest[] = [];
const sockets: unknown[] = [];

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { redactToolDetail } from "./browser-redact.ts";
describe("browser tool detail redaction", () => {
it("redacts tool detail credential families without Node config imports", () => {
const redacted = redactToolDetail(
[
"Authorization: Basic dXNlcjpzdXBlcnNlY3JldHBhc3N3b3Jk",
"curl 'https://example.test?refresh_token=ya29.longOAuthRefreshTokenValue&ok=1'",
"client_secret=clientSecretValueThatShouldNotRender",
"AIzaSyDUMMYGoogleApiKeyValue1234567890",
"-----BEGIN PRIVATE KEY-----\nabc123\n-----END PRIVATE KEY-----",
'cookie: "sessionid=verySensitiveCookieValue"',
].join("\n"),
);
expect(redacted).toContain("Authorization: Basic dXNlcj...b3Jk");
expect(redacted).toContain("refresh_token=ya29.l...alue");
expect(redacted).toContain("client_secret=client...nder");
expect(redacted).toContain("AIzaSy...7890");
expect(redacted).toContain(
"-----BEGIN PRIVATE KEY-----\n...redacted...\n-----END PRIVATE KEY-----",
);
expect(redacted).toContain('cookie: "sessio...alue"');
expect(redacted).not.toContain("supersecretpassword");
expect(redacted).not.toContain("longOAuthRefreshTokenValue");
expect(redacted).not.toContain("clientSecretValueThatShouldNotRender");
expect(redacted).not.toContain("DUMMYGoogleApiKeyValue1234567890");
expect(redacted).not.toContain("abc123");
expect(redacted).not.toContain("verySensitiveCookieValue");
});
});

View File

@@ -0,0 +1,76 @@
const PAYMENT_CREDENTIAL_KEYS =
"card[-_]?number|card[-_]?cvc|card[-_]?cvv|cvc|cvv|security[-_]?code|securityCode|payment[-_]?credential|paymentCredential|shared[-_]?payment[-_]?token|sharedPaymentToken";
const SECRET_DETAIL_PATTERNS: RegExp[] = [
/\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|CARD[_-]?NUMBER|CARD[_-]?CVC|CARD[_-]?CVV|CVC|CVV|SECURITY[_-]?CODE|PAYMENT[_-]?CREDENTIAL|SHARED[_-]?PAYMENT[_-]?TOKEN)\b\s*[=:]\s*(["']?)([^\s"'\\&<>]+)\1/gi,
/\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|CARD[_-]?NUMBER|CARD[_-]?CVC|CARD[_-]?CVV|CVC|CVV|SECURITY[_-]?CODE|PAYMENT[_-]?CREDENTIAL|SHARED[_-]?PAYMENT[_-]?TOKEN)\b\s*[=:]\s*\\+(["'])([^\s"'\\&<>]+)\\+\1/gi,
new RegExp(
`[?&](?:access[-_]?token|auth[-_]?token|hook[-_]?token|refresh[-_]?token|api[-_]?key|client[-_]?secret|token|key|secret|password|pass|passwd|auth|signature|${PAYMENT_CREDENTIAL_KEYS})=([^&\\s"'<>]+)`,
"gi",
),
/"(?:apiKey|token|secret|password|passwd|accessToken|refreshToken|cardNumber|card_number|cardCvc|card_cvc|cardCvv|card_cvv|cvc|cvv|securityCode|security_code|paymentCredential|payment_credential|sharedPaymentToken|shared_payment_token)"\s*:\s*"([^"]+)"/gi,
/(^|[\s,{])["']?(?:api[-_]key|access[-_]token|refresh[-_]token|authToken|auth[-_]token|clientSecret|client[-_]secret|appSecret|app[-_]secret)["']?\s*[:=]\s*(["'])([^"'\r\n]+)\2/gi,
/(^|[\s,{])["']?(?:authorization|proxy-authorization|cookie|set-cookie|x-api-key|x-auth-token)["']?\s*[:=]\s*(["'])([^"'\r\n]+)\2/gi,
new RegExp(
`--(?:api[-_]?key|hook[-_]?token|token|secret|password|passwd|${PAYMENT_CREDENTIAL_KEYS})\\s+(["']?)([^\\s"']+)\\1`,
"gi",
),
/Authorization\s*[:=]\s*Bearer\s+([A-Za-z0-9._\-+=]+)/gi,
/Authorization\s*[:=]\s*Basic\s+([A-Za-z0-9+/=]+)/gi,
/(?:X-OpenClaw-Token|x-pomerium-jwt-assertion|X-Api-Key|X-Auth-Token)\s*[:=]\s*([^\s"',;]+)/gi,
/\bBearer\s+([A-Za-z0-9._\-+=]{18,})\b/gi,
new RegExp(
`(^|[\\s,;])(?:access_token|refresh_token|auth[-_]?token|api[-_]?key|client[-_]?secret|app[-_]?secret|token|secret|password|passwd|${PAYMENT_CREDENTIAL_KEYS})=([^\\s&#]+)`,
"gi",
),
/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]+?-----END [A-Z ]*PRIVATE KEY-----/g,
/\b(sk-[A-Za-z0-9_-]{8,})\b/g,
/\b(ghp_[A-Za-z0-9]{20,})\b/g,
/\b(github_pat_[A-Za-z0-9_]{20,})\b/g,
/\b(xox[baprs]-[A-Za-z0-9-]{10,})\b/g,
/\b(xapp-[A-Za-z0-9-]{10,})\b/g,
/\b(gsk_[A-Za-z0-9_-]{10,})\b/g,
/\b(AIza[0-9A-Za-z\-_]{20,})\b/g,
/\b(ya29\.[0-9A-Za-z_\-./+=]{10,})\b/g,
/\b(1\/\/0[0-9A-Za-z_\-./+=]{10,})\b/g,
/\b(eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,})\b/g,
/\b(pplx-[A-Za-z0-9_-]{10,})\b/g,
/\b(npm_[A-Za-z0-9]{10,})\b/g,
/\b(AKID[A-Za-z0-9]{10,})\b/g,
/\b(LTAI[A-Za-z0-9]{10,})\b/g,
/\b(hf_[A-Za-z0-9]{10,})\b/g,
/\b(r8_[A-Za-z0-9]{10,})\b/g,
/\bbot(\d{6,}:[A-Za-z0-9_-]{20,})\b/g,
/\b(\d{6,}:[A-Za-z0-9_-]{20,})\b/g,
];
function redactToken(value: string): string {
if (value.length <= 10) {
return "***";
}
return `${value.slice(0, 6)}...${value.slice(-4)}`;
}
function redactPemBlock(block: string): string {
const lines = block.split(/\r?\n/).filter(Boolean);
if (lines.length < 2) {
return "***";
}
return `${lines[0]}\n...redacted...\n${lines[lines.length - 1]}`;
}
export function redactToolDetail(detail: string): string {
let redacted = detail;
for (const pattern of SECRET_DETAIL_PATTERNS) {
redacted = redacted.replace(pattern, (...args: string[]) => {
const match = args[0] ?? "";
if (match.includes("PRIVATE KEY-----")) {
return redactPemBlock(match);
}
const groups = args.slice(1, -2);
const token = groups.findLast((group) => typeof group === "string" && group.length > 0);
return token ? match.replace(token, redactToken(token)) : "***";
});
}
return redacted;
}

View File

@@ -2,6 +2,7 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import {
controlUiBrowserOnlySharedModuleAliases,
resolveSourcePackageAliasesForVite,
resolveTsconfigPathAliasesForVite,
} from "../../vite.config.ts";
@@ -50,4 +51,19 @@ describe("Control UI Vite config", () => {
expect(netPolicyIpIndex).toBeLessThan(netPolicyPackageIndex);
expect(netPolicyWildcardIndex).toBeLessThan(broadOpenClawWildcardIndex);
});
it("uses a browser-safe redactor for shared tool display imports", async () => {
const plugin = controlUiBrowserOnlySharedModuleAliases();
const resolveId = plugin.resolveId;
expect(typeof resolveId).toBe("function");
const resolved = await resolveId.call(
{} as never,
"../logging/redact.js",
path.join(repoRoot, "src/agents/tool-display-common.ts"),
{ attributes: {}, custom: {}, isEntry: false, ssr: false },
);
expect(resolved).toBe(path.join(repoRoot, "ui/src/ui/browser-redact.ts"));
});
});

View File

@@ -129,6 +129,49 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
}
});
it("keeps chat usable while sessions are still loading", async () => {
const context = await browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, {
deferredMethods: ["sessions.list"],
historyMessages: [
{
content: [{ text: "History renders before sessions finish.", type: "text" }],
role: "assistant",
timestamp: Date.now(),
},
],
});
try {
await page.goto(`${server.baseUrl}chat`);
await page.getByText("History renders before sessions finish.").waitFor({ timeout: 10_000 });
await page
.locator(".agent-chat__composer-combobox textarea")
.waitFor({ state: "visible", timeout: 10_000 });
const sessionsList = await gateway.waitForRequest("sessions.list");
expect(requireRecord(sessionsList.params)).toMatchObject({
includeGlobal: true,
includeUnknown: true,
limit: 50,
});
await gateway.resolveDeferred("sessions.list");
await page.getByRole("button", { name: "Chat session" }).waitFor({
state: "visible",
timeout: 10_000,
});
} finally {
await context.close();
}
});
it("keeps a delayed chat.send ACK visible as pending until the ACK resolves", async () => {
const context = await browser.newContext({
locale: "en-US",

View File

@@ -178,6 +178,29 @@ export function resolveTsconfigPathAliasesForVite(): ControlUiViteAlias[] {
});
}
export function controlUiBrowserOnlySharedModuleAliases(): Plugin {
const browserRedactPath = path.join(here, "src/ui/browser-redact.ts");
const sharedRedactImporters = new Set([
path.join(repoRoot, "src/agents/tool-display-common.ts"),
path.join(repoRoot, "src/agents/tool-display-exec.ts"),
path.join(repoRoot, "src/agents/tool-display.ts"),
]);
return {
name: "control-ui-browser-only-shared-module-aliases",
enforce: "pre",
resolveId(source, importer) {
if (
source === "../logging/redact.js" &&
importer &&
sharedRedactImporters.has(path.normalize(importer))
) {
return browserRedactPath;
}
return null;
},
};
}
function controlUiServiceWorkerBuildIdPlugin(buildId: string): Plugin {
return {
name: "control-ui-service-worker-build-id",
@@ -240,6 +263,7 @@ export default defineConfig(() => {
strictPort: true,
},
plugins: [
controlUiBrowserOnlySharedModuleAliases(),
controlUiServiceWorkerBuildIdPlugin(controlUiBuildId),
{
name: "control-ui-dev-stubs",