diff --git a/app/src/pages/Conversations.tsx b/app/src/pages/Conversations.tsx
index 928fc0968..d649681fb 100644
@@ -70,160 +70,165 @@ import {
type ToolTimelineEntry,
} from '../store/chatRuntimeSlice';
import { useAppDispatch, useAppSelector } from '../store/hooks';
[... 74 context line(s) omitted ...]
const AUTOCOMPLETE_POLL_DEBOUNCE_MS = 320;
const AUTOCOMPLETE_MIN_CONTEXT_CHARS = 3;
const debug = debugFactory('conversations');
+const SAFE_IMAGE_DATA_URI_RE = /^data:image\/(?:png|jpe?g|gif|webp|bmp);base64,[a-z0-9+/=\s]+$/i;
+
+function isSafeAttachmentImageSrc(src: string): boolean {
+ return SAFE_IMAGE_DATA_URI_RE.test(src);
+}
interface ConversationsProps {
/**
[... 74 context line(s) omitted ...]
}
return String(err);
}
@@ -2244,163 +2249,165 @@ const Conversations = ({
className="flex items-center rounded-full border border-primary-200 bg-primary-100 px-1.5 text-xs leading-[1.5] shadow-sm transition-colors hover:bg-primary-200 dark:border-primary-400/40 dark:bg-primary-500/25"
title={t('chat.removeReaction').replace(
'{emoji}',
[... 74 context line(s) omitted ...]
<div className="flex flex-col items-end gap-1">
{(() => {
const displayText = parsedContent.text;
- const dataUris = Array.isArray(msg.extraMetadata?.attachmentDataUris)
- ? (msg.extraMetadata.attachmentDataUris as string[])
- : parsedContent.dataUris;
+ const dataUris = (
+ Array.isArray(msg.extraMetadata?.attachmentDataUris)
+ ? (msg.extraMetadata.attachmentDataUris as string[])
+ : parsedContent.dataUris
+ ).filter(isSafeAttachmentImageSrc);
const hasImages = dataUris.length > 0;
// Document attachments carry no image data-URI (only
// images do); surface them as filename chips from the
[... 74 context line(s) omitted ...]
</div>
))}
</div>
diff --git a/app/test/e2e/specs/connector-jira.spec.ts b/app/test/e2e/specs/connector-jira.spec.ts
index 8718d16f3..6e77c7731 100644
@@ -37,164 +37,172 @@ const LOG = '[ConnectorJiraE2E]';
const CONNECTOR_NAME = 'Jira';
const TOOLKIT_SLUG = 'jira';
const AUTH_TOKEN = 'e2e-connector-jira-token';
[... 74 context line(s) omitted ...]
return (
document.querySelector('[data-testid="composio-required-subdomain"]') !== null ||
document.querySelector('input[placeholder*="subdomain"]') !== null ||
- // fallback: any .atlassian.net suffix label
- Array.from(document.querySelectorAll('*')).some(el =>
- (el.textContent ?? '').includes('.atlassian.net')
- )
+ // fallback: any visible Jira tenant URL label
+ Array.from(document.querySelectorAll('*')).some(el => {
+ const text = el.textContent ?? '';
+ const matches = text.match(/https?:\/\/[^\s"'<>]+/g) ?? [];
+ return matches.some(candidate => {
+ try {
+ return new URL(candidate).hostname.endsWith('.atlassian.net');
+ } catch {
+ return false;
+ }
+ });
+ })
);
})
.catch(() => false);
[... 74 context line(s) omitted ...]
it('expired auth shows Reconnect button and does not log user out', async function () {
this.timeout(60_000);
seedComposioConnection(TOOLKIT_SLUG, 'EXPIRED', 'c-jira-expired');
diff --git a/app/test/e2e/specs/mega-flow.spec.ts b/app/test/e2e/specs/mega-flow.spec.ts
index 4f5bc3cbc..8225fa4ea 100644
@@ -644,163 +644,168 @@ describe('Mega flow — login + Gmail OAuth + Composio in one session', () => {
});
const ingressBody = await ingressResp.json().catch(() => ({}));
expect(ingressResp.status).toBe(200);
[... 74 context line(s) omitted ...]
console.log(`${LOG} update.version: asset_prefix = ${prefix}`);
// No outbound HTTP call should have been made — version is purely local.
- const outbound = getRequestLog().find(
- r => r.url.includes('github.com') || r.url.includes('update.openhuman.app')
- );
+ const outbound = getRequestLog().find(r => {
+ try {
+ const url = new URL(r.url, 'http://mock.local');
+ return url.hostname === 'github.com' || url.hostname === 'update.openhuman.app';
+ } catch {
+ return false;
+ }
+ });
expect(outbound).toBeUndefined();
const ping = await callOpenhumanRpc('core.ping', {});
[... 74 context line(s) omitted ...]
it('thread CRUD smoke: create → append message → list messages roundtrip', async () => {
await resetEverything('before thread-crud-smoke scenario');
diff --git a/scripts/mock-api/routes/llm.mjs b/scripts/mock-api/routes/llm.mjs
index ef3402b1a..ed8609646 100644
@@ -1,91 +1,93 @@
import { json, setCors } from "../http.mjs";
import {
behavior,
[... 5 context line(s) omitted ...]
import { buildDynamicCompletion } from "./llm/dynamic.mjs";
import { headerValue, pickProbeText, resolveThreadKey } from "./llm/shared.mjs";
+const MAX_STREAM_DELAY_MS = 30_000;
+
// The scripted `llmForcedResponses` FIFO models the *interactive* agent turn,
// which always advertises tools (the orchestrator's delegate_* tools). Ancillary
// completions that share the endpoint but carry no tools — thread-title/summary
[... 74 context line(s) omitted ...]
});
}
@@ -98,332 +100,332 @@ function sendRuleError(res, rule) {
// data: {"choices":[{"delta":{"content":"hello"},"finish_reason":null}]}
// data: {"choices":[{"delta":{},"finish_reason":"stop"}], "usage":{...}}
// data: [DONE]
[... 74 context line(s) omitted ...]
return new Promise((resolve) => setTimeout(resolve, ms));
}
+function safeDelayMs(raw, fallback = 0) {
+ const parsed = Number(raw);
+ if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
+ return Math.min(parsed, MAX_STREAM_DELAY_MS);
+}
+
// Split a string into N-character windows so we can stream tool-call
// argument JSON the same way real providers do — clients accumulate the
// partial fragments and JSON-parse at the end.
[... 131 context line(s) omitted ...]
}
}
- const defaultDelayMs = Number.isFinite(
- parseFloat(mockBehavior.llmStreamChunkDelayMs),
- )
- ? Math.max(0, parseFloat(mockBehavior.llmStreamChunkDelayMs))
- : 25;
+ const defaultDelayMs = safeDelayMs(mockBehavior.llmStreamChunkDelayMs, 25);
// Fire-and-forget — the dispatcher only cares that the handler
// claimed the request. Errors mid-stream are surfaced through SSE.
[... 21 context line(s) omitted ...]
let trailingUsage = null;
for (let i = 0; i < script.length; i += 1) {
const entry = script[i] ?? {};
- const delay = Number.isFinite(entry.delayMs)
- ? entry.delayMs
- : defaultDelayMs;
+ const delay = safeDelayMs(entry.delayMs, defaultDelayMs);
if (delay > 0) await sleep(delay);
if (entry.error) {
[... 74 context line(s) omitted ...]
finishReason: entry.finish,
usage: trailingUsage ?? undefined,
}),
diff --git a/scripts/mock-api/routes/oauth.mjs b/scripts/mock-api/routes/oauth.mjs
index 869beb7e5..63fc6c7d4 100644
@@ -1,228 +1,248 @@
import { html, json, setCors } from "../http.mjs";
import { behavior, MOCK_JWT } from "../state.mjs";
[... 37 context line(s) omitted ...]
const legacy = url.match(
/^\/mock-(telegram|notion|google|gmail|slack|discord|twitter|github)-oauth\/?(\?.*)?$/i,
);
- const legacyGeneric = !newStyle && !legacy && /^\/mock-oauth\/?(\?.*)?$/.test(url);
+ const legacyGeneric =
+ !newStyle && !legacy && /^\/mock-oauth\/?(\?.*)?$/.test(url);
if (method === "GET" && (newStyle || legacy || legacyGeneric)) {
const provider = newStyle?.[1] || legacy?.[1] || "generic";
[... 22 context line(s) omitted ...]
integrationId,
)}&provider=${encodeURIComponent(params.provider || provider)}`;
- html(res, 200, renderOAuthPage({ provider, target, autoRedirectMs, errorCode }));
+ html(
+ res,
+ 200,
+ renderOAuthPage({ provider, target, autoRedirectMs, errorCode }),
+ );
return true;
}
// Generic callback exchange. Real providers each hit their own
// backend-specific URL; for e2e a single endpoint per provider that
// always returns a session token is enough.
- const callbackMatch = url.match(/^\/auth\/([a-z][a-z0-9_-]*)\/callback\/?(\?.*)?$/i);
+ const callbackMatch = url.match(
+ /^\/auth\/([a-z][a-z0-9_-]*)\/callback\/?(\?.*)?$/i,
+ );
if (method === "GET" && callbackMatch) {
const provider = callbackMatch[1];
const params = parseQuery(url);
[... 72 context line(s) omitted ...]
const key = eq < 0 ? pair : pair.slice(0, eq);
const raw = eq < 0 ? "" : pair.slice(eq + 1);
try {
- out[decodeURIComponent(key)] = decodeURIComponent(raw.replace(/\+/g, " "));
+ out[decodeURIComponent(key)] = decodeURIComponent(
+ raw.replace(/\+/g, " "),
+ );
} catch {
out[key] = raw;
}
[... 8 context line(s) omitted ...]
}
function escapeHtml(s) {
- return String(s).replace(/[&<>"']/g, (c) => ({
- "&": "&",
- "<": "<",
- ">": ">",
- '"': """,
- "'": "'",
- })[c]);
+ return String(s).replace(
+ /[&<>"']/g,
+ (c) =>
+ ({
+ "&": "&",
+ "<": "<",
+ ">": ">",
+ '"': """,
+ "'": "'",
+ })[c],
+ );
}
function renderOAuthPage({ provider, target, autoRedirectMs, errorCode }) {
[... 9 context line(s) omitted ...]
const autoRedirectScript =
autoRedirectMs === null
? ""
- : `<script>setTimeout(function(){window.location.href=${JSON.stringify(
- target,
- )};}, ${Number(autoRedirectMs)});</script>`;
+ : `<script>
+ (function () {
+ var delay = Number(document.body.dataset.autoRedirectMs || 0);
+ window.setTimeout(function () {
+ var link = document.getElementById("continue");
+ if (link instanceof HTMLAnchorElement) window.location.href = link.href;
+ }, Number.isFinite(delay) && delay >= 0 ? delay : 0);
+ })();
+ </script>`;
const metaRefresh =
autoRedirectMs === null
? ""
- : `<meta http-equiv="refresh" content="${(Number(autoRedirectMs) /
- 1000).toFixed(2)};url=${safeTarget}" />`;
+ : `<meta http-equiv="refresh" content="${(
+ Number(autoRedirectMs) / 1000
+ ).toFixed(2)};url=${safeTarget}" />`;
return `<!doctype html>
<html lang="en">
[... 9 context line(s) omitted ...]
code { background: #f3f3f3; padding: 2px 6px; border-radius: 4px; font-size: 13px; }
</style>
</head>
-<body>
+<body${autoRedirectMs === null ? "" : ` data-auto-redirect-ms="${Number(autoRedirectMs)}"`}>
<h1>${heading}</h1>
<p>${blurb}</p>
<p>Target: <code>${safeTarget}</code></p>
<p><a class="button" id="continue" href="${safeTarget}">Continue to OpenHuman</a></p>
${autoRedirectScript}
</body>
</html>`;
}
diff --git a/scripts/mock-api/server.mjs b/scripts/mock-api/server.mjs
index 68d55ea99..aff66d35d 100644
@@ -47,173 +47,178 @@ const ROUTE_HANDLERS = [
handleUser,
handleInvites,
handlePayments,
[... 74 context line(s) omitted ...]
return false;
if (typeof rule.path === "string" && rule.path !== ctx.url) return false;
if (typeof rule.pathRegex === "string") {
- try {
- const regex = new RegExp(rule.pathRegex);
- if (!regex.test(ctx.url)) return false;
- } catch {
- return false;
- }
+ if (!pathPatternMatches(rule.pathRegex, ctx.url)) return false;
}
if (typeof rule.contains === "string" && !ctx.url.includes(rule.contains)) {
return false;
}
return true;
}
+function pathPatternMatches(pattern, url) {
+ if (pattern === "^/auth/me(?:\\?.*)?$") {
+ return url === "/auth/me" || url.startsWith("/auth/me?");
+ }
+ if (/^[a-zA-Z0-9_./?=&:-]+$/.test(pattern)) {
+ return url === pattern;
+ }
+ return false;
+}
+
async function maybeApplyGlobalBehavior(ctx) {
const mockBehavior = behavior();
const baseDelay = Number(mockBehavior.globalDelayMs || 0);
[... 74 context line(s) omitted ...]
openSockets.add(socket);
socket.on("close", () => openSockets.delete(socket));
});
[compacted tool output — this is a PARTIAL view; the full original (65898 bytes) is available by calling tokenjuice_retrieve with token "9ca597ed4cb282758cb90d47a1f0d739" (marker ⟦tj:9ca597ed4cb282758cb90d47a1f0d739⟧)]