mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
fix(ui): keep chat usable during session loading
This commit is contained in:
@@ -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[] = [];
|
||||
|
||||
32
ui/src/ui/browser-redact.test.ts
Normal file
32
ui/src/ui/browser-redact.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
76
ui/src/ui/browser-redact.ts
Normal file
76
ui/src/ui/browser-redact.ts
Normal 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;
|
||||
}
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user