Compare commits

...
4 Commits
Author SHA1 Message Date
ooovenenosoandKevin c12b8ab6c9 fix: add OpenCode setup provider aliases (#4700)
Co-authored-by: Kevin <120500656+oooindefatigable@users.noreply.github.com>
2026-06-22 17:33:02 +02:00
Ashvin e812a29233 fix(markdown): preserve URLs inside inline code spans (#4681)
Inline backtick spans were converted to <code> only at the end of
mdToHtml, after the bare-URL autolink and <a>/allowed-HTML passes. A URL
inside inline code is preceded by a space, so the autolink wrapped it in
an <a> tag and swapped it for an ___ALLOWED_HTML_ placeholder, corrupting
commands like `irm http://127.0.0.1:3000/x`.

Extract inline code into placeholders before the link passes, mirroring
the existing fenced-code-block handling, and restore them last so
placeholders carried inside restored <a> blocks resolve. Escape the code
at extraction time since it now bypasses the global escape pass.
2026-06-22 17:23:55 +02:00
nopoz ca4973c41f fix(security): prevent exponential ReDoS in email→calendar extract regex (#4708)
The fallback regex in email_pollers.py that recovers a
[{"action": ...}, ...] JSON array from raw model output used lazy
[^[\]]*? runs inside a (?:,\s*\{...\}\s*)* repetition, which backtracks
exponentially (CodeQL py/redos) on inputs like [{"action"},{ + }},{{ * N.
It runs on the LLM reply to an email→calendar prompt embedding the
untrusted email body, so a crafted email can stall the background poller.

Extract the pattern to a module-level _CAL_ACTION_ARRAY_RE and rewrite the
object-content class from the lazy [^[\]]*? to a greedy brace-delimited
[^{}], which removes the quantifier ambiguity. The match is linear (a 500KB
adversarial input now resolves in <1ms) and equivalent on well-formed
arrays; it is also strictly more robust for values containing '[' or ']'
(the old class bailed on those and extracted nothing).

Resolves CodeQL py/redos #198.
2026-06-22 17:18:34 +02:00
Tom 91b4171b3f feat(a11y): add a Text size control and an OpenDyslexic font option (#4210)
* feat(a11y): add a Text size control and an OpenDyslexic font option

Text size: a Theme > Font & Layout control (Default / Larger) that scales the whole UI via CSS zoom, so the many hard-coded px sizes scale too (density only moves the root font-size). Stored globally so it persists across theme switches; applied early in the boot script to avoid a flash. OpenDyslexic: a dyslexia-friendly self-hosted font (SIL OFL 1.1), bundled as woff2 alongside Fira Code/Inter and wired into the Font select. Reuses the existing density/font pattern end to end; no new colours, spacing, or component styles.

* fix(a11y): keep modals on-screen at Larger text size

Inline vh heights on .modal-content overrode the ui-scale-125 max-height
compensation, so Cookbook (and the email/doc/skills/PDF modals) overflowed
the viewport at 125% — pushing the header and close button off-screen.
Let the compensation own those heights.

* fix(a11y): keep PDF export modal at its original 86vh on Default size
2026-06-22 13:53:46 +02:00
18 changed files with 328 additions and 15 deletions
+1
View File
@@ -86,6 +86,7 @@ Bundled in `static/fonts/`:
| [Fira Code](https://github.com/tonsky/FiraCode) | SIL Open Font License 1.1 | Nikita Prokopov & contributors |
| [Inter](https://github.com/rsms/inter) | SIL Open Font License 1.1 | Rasmus Andersson |
| [GohuFont](https://font.gohu.org/) (`fonts/custom/GohuFont.ttf`) | WTFPL | Hugo Chargois |
| [OpenDyslexic](https://opendyslexic.org/) (`fonts/OpenDyslexic-{Regular,Bold}.woff2`) | SIL Open Font License 1.1 ([`licenses/OpenDyslexic-OFL.txt`](licenses/OpenDyslexic-OFL.txt)) | Abbie Gonzalez |
## Python dependencies
+94
View File
@@ -0,0 +1,94 @@
Copyright (c) 2019-07-29, Abbie Gonzalez (https://abbiecod.es|support@abbiecod.es),
with Reserved Font Name OpenDyslexic.
Copyright (c) 12/2012 - 2019
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+12 -1
View File
@@ -44,6 +44,17 @@ from routes.email_helpers import (
logger = logging.getLogger(__name__)
# Recovers a `[{"action": ...}, ...]` JSON array from raw LLM output when the
# fenced-block strip leaves nothing usable. Runs on model output influenced by
# untrusted email bodies, so it must not backtrack: the object content class is
# `[^{}]` (brace-delimited, greedy) rather than the old `[^[\]]*?` lazy runs,
# which exploded exponentially on inputs like `[{"action"},{` + `}},{{` * N
# (CodeQL py/redos #198).
_CAL_ACTION_ARRAY_RE = re.compile(
r'\[\s*\{[^{}]*"action"[^{}]*\}\s*(?:,\s*\{[^{}]*\}\s*)*\]',
re.DOTALL,
)
def _owner_for_email_account(account_id: str | None) -> str:
if not account_id:
@@ -558,7 +569,7 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
cal_extract = _strip_think(_raw_original)
cal_extract = re.sub(r"^```(?:json)?\s*|\s*```$", "", cal_extract, flags=re.MULTILINE).strip()
if not cal_extract and _raw_original:
matches = list(re.finditer(r'\[\s*\{[^[\]]*?"action"[^[\]]*?\}\s*(?:,\s*\{[^[\]]*?\}\s*)*\]', _raw_original, re.DOTALL))
matches = list(_CAL_ACTION_ARRAY_RE.finditer(_raw_original))
if matches:
cal_extract = matches[-1].group()
logger.info(f"[cal-extract] uid={uid.decode() if isinstance(uid, bytes) else uid} folder={_folder} subj={subject[:50]!r} raw_len={len(cal_extract)} orig_len={len(_raw_original)} raw={cal_extract[:800]!r}")
Binary file not shown.
Binary file not shown.
+16 -2
View File
@@ -76,7 +76,7 @@
}
// Apply font early
if (t && t.font) {
var fm = {mono:"'Fira Code', monospace",sans:"system-ui, -apple-system, 'Segoe UI', sans-serif",serif:"Georgia, 'Times New Roman', serif"};
var fm = {mono:"'Fira Code', monospace",sans:"system-ui, -apple-system, 'Segoe UI', sans-serif",serif:"Georgia, 'Times New Roman', serif",opendyslexic:"'OpenDyslexic', sans-serif"};
if (fm[t.font]) { s.setProperty('--font-family', fm[t.font]); }
else { s.setProperty('--font-family', "'" + t.font.replace(/'/g,'') + "', sans-serif"); }
}
@@ -84,6 +84,12 @@
if (t && t.density && t.density !== 'comfortable') {
document.documentElement.classList.add('density-' + t.density);
}
// Apply UI text-size scale early (global accessibility pref, independent
// of the active theme) so there's no flash on load.
try {
var _us = localStorage.getItem('odysseus-ui-scale');
if (_us && _us !== '100') document.documentElement.classList.add('ui-scale-' + _us);
} catch(e){}
// Apply background pattern on body once available
if (t && t.bgPattern && t.bgPattern !== 'none') {
document.addEventListener('DOMContentLoaded', function() {
@@ -581,6 +587,7 @@
<option value="mono">Monospace</option>
<option value="sans">Sans-serif</option>
<option value="serif">Serif</option>
<option value="opendyslexic">OpenDyslexic (dyslexia-friendly)</option>
</select>
</div>
<div class="theme-fd-group">
@@ -591,6 +598,13 @@
<option value="spacious">Spacious</option>
</select>
</div>
<div class="theme-fd-group">
<label class="theme-fd-label">Text size</label>
<select id="theme-text-size-select" class="theme-fd-select" aria-label="Text size">
<option value="100">Default</option>
<option value="125">Larger</option>
</select>
</div>
<div class="theme-fd-group" id="theme-frosted-group">
<label class="theme-fd-label" for="theme-frosted-toggle">Frosted</label>
<label class="admin-switch" style="margin-top:4px;">
@@ -1318,7 +1332,7 @@
<!-- Cookbook Modal -->
<div id="cookbook-modal" class="modal hidden">
<div class="modal-content" role="dialog" aria-label="Cookbook" style="width: min(780px, 92vw); height: 94vh; max-height: 94vh; background: var(--bg);">
<div class="modal-content" role="dialog" aria-label="Cookbook" style="width: min(780px, 92vw); background: var(--bg);">
<div class="modal-header">
<h4 style="margin:0;margin-right:auto"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px"><path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/></svg>Cookbook</h4>
<button class="close-btn" id="close-cookbook-modal" aria-label="Close cookbook">✖</button>
+1 -1
View File
@@ -666,7 +666,7 @@ import * as Modals from './modalManager.js';
overlay.className = 'modal pdf-export-overlay';
overlay.style.cssText = 'pointer-events:auto;background:rgba(0,0,0,0.5);backdrop-filter:blur(4px);';
overlay.innerHTML = `
<div class="modal-content" style="width:min(780px,94vw);max-height:86vh;">
<div class="modal-content" style="width:min(780px,94vw);">
<div class="modal-header">
<h4>Export filled PDF</h4>
<button id="pdf-export-close" class="modal-close" title="Close">×</button>
+1 -1
View File
@@ -1595,7 +1595,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
modal.className = 'modal';
modal.id = 'doclib-modal';
modal.innerHTML = `
<div class="modal-content doclib-modal-content" style="width:min(640px, 92vw);max-height:85vh;background:var(--bg);">
<div class="modal-content doclib-modal-content" style="width:min(640px, 92vw);background:var(--bg);">
<div class="modal-header">
<!-- Header title + icon mirror the currently-active sub-tab (Chats /
Documents / Research / Archive) so the user sees ONE icon at
+3 -3
View File
@@ -858,7 +858,7 @@ export function openEmailLibrary(opts = {}) {
modal.className = 'modal';
modal.id = 'email-lib-modal';
modal.innerHTML = `
<div class="modal-content doclib-modal-content" style="width:min(720px, 92vw);max-height:85vh;background:var(--bg);">
<div class="modal-content doclib-modal-content" style="width:min(720px, 92vw);background:var(--bg);">
<div class="modal-header">
<h4>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;">
@@ -4866,7 +4866,7 @@ async function _openEmailAsTab(em, folder) {
modal.className = 'modal email-reader-tab-modal';
modal.id = modalId;
modal.innerHTML = `
<div class="modal-content doclib-modal-content email-reader-tab-content" style="background:var(--bg);width:min(720px, 92vw);max-height:85vh;display:flex;flex-direction:column;">
<div class="modal-content doclib-modal-content email-reader-tab-content" style="background:var(--bg);width:min(720px, 92vw);display:flex;flex-direction:column;">
<div class="modal-header">
<h4 style="display:flex;align-items:center;gap:6px;min-width:0;flex:1;">
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-left:8px;">${_esc(em.subject || '(no subject)')}</span>
@@ -5101,7 +5101,7 @@ async function _openEmailWindow(em, folder) {
modal.id = winId;
modal.style.cssText = 'pointer-events:none;background:transparent;';
modal.innerHTML = `
<div class="modal-content email-window-content" style="width:min(640px, 92vw);max-height:80vh;display:flex;flex-direction:column;background:var(--bg);">
<div class="modal-content email-window-content" style="width:min(640px, 92vw);display:flex;flex-direction:column;background:var(--bg);">
<div class="modal-header">
<h4 style="display:flex;align-items:center;gap:6px;min-width:0;flex:1;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>
+22 -6
View File
@@ -483,6 +483,7 @@ export function processWithThinking(text) {
export function mdToHtml(src, opts) {
const allowedHtmlBlocks = [];
const codeBlocks = [];
const inlineCodeBlocks = [];
const mermaidBlocks = [];
let s = (src ?? '');
@@ -521,6 +522,19 @@ export function mdToHtml(src, opts) {
return placeholder;
});
// Extract inline code spans before the link/autolink/HTML passes, mirroring
// the fenced-block handling above. A URL inside `inline code` (e.g.
// `irm http://127.0.0.1:3000/x`) is preceded by a space, so the bare-URL
// autolink matches it, wraps it in an <a> tag, and swaps that for an
// ___ALLOWED_HTML_ placeholder — corrupting the command. The old inline-code
// pass ran after those passes, too late to protect it.
s = s.replace(/`([^`]+?)`/g, (match, code) => {
if (code.startsWith('___CODE_BLOCK_') || code.startsWith('___MERMAID_BLOCK_')) return match;
const placeholder = `___INLINE_CODE_${inlineCodeBlocks.length}___`;
inlineCodeBlocks.push(`<code>${escapeHtml(code)}</code>`);
return placeholder;
});
// Repair common ways the agent mangles the entity-anchor convention
// (`[Name](#kind-<id>)`). Models reliably get the single-link case
// right but slip into other formats when listing many in a table.
@@ -678,12 +692,6 @@ export function mdToHtml(src, opts) {
return html;
});
// Inline code (but not placeholders)
s = s.replace(/`([^`]+?)`/g, (match, code) => {
if (code.startsWith('___CODE_BLOCK_') || code.startsWith('___ALLOWED_HTML_')) return match;
return `<code>${code}</code>`;
});
// Horizontal rules (must come before bold/italic to avoid * conflicts)
s = s.replace(/^(?:---|\*\*\*|___)\s*$/gm, '<hr>');
@@ -756,6 +764,14 @@ export function mdToHtml(src, opts) {
s = s.replace(`___CODE_BLOCK_${index}___`, block);
});
// Restore inline code spans last, so placeholders carried inside restored
// <a>/allowed-HTML blocks are resolved too. The function replacer keeps the
// escaped code literal — e.g. a shell snippet like `echo $1` is not treated
// as a regex back-reference.
inlineCodeBlocks.forEach((block, index) => {
s = s.replace(`___INLINE_CODE_${index}___`, () => block);
});
return _useSvgEmoji() ? svgifyEmoji(s, opts) : s;
}
+1 -1
View File
@@ -1802,7 +1802,7 @@ async function _showSkillSource(name) {
wrap.className = 'modal';
wrap.style.display = 'block';
wrap.innerHTML = `
<div class="modal-content" style="max-width:760px;max-height:85vh;display:flex;flex-direction:column">
<div class="modal-content" style="max-width:760px;display:flex;flex-direction:column">
<div class="modal-header">
<h4>SKILL.md — <code>${esc(name)}</code></h4>
<span style="flex:1"></span>
+4
View File
@@ -101,6 +101,8 @@ function _setupProviderFromInput(input) {
xai: 'xai',
grok: 'xai',
nvidia: 'nvidia',
opencodezen: 'opencode-zen',
opencodego: 'opencode-go',
};
return SETUP_PROVIDER_URLS[aliases[raw] || raw] || null;
}
@@ -129,6 +131,8 @@ function _extractSetupProviderCredential(input) {
['google', 'gemini'], ['gemini', 'gemini'],
['x ai', 'xai'], ['xai', 'xai'], ['grok', 'xai'],
['nvidia', 'nvidia'],
['opencode zen', 'opencode-zen'], ['opencode-zen', 'opencode-zen'],
['opencode go', 'opencode-go'], ['opencode-go', 'opencode-go'],
];
for (const [alias, key] of providerAliases) {
const re = new RegExp('(^|\\s|[,;:])(' + alias.replace(/\s+/g, '\\s+') + ')(?=$|\\s|[,;:])', 'i');
+1
View File
@@ -24,6 +24,7 @@ export const KEYS = {
SECTION_ORDER: 'sidebar-section-order',
ADMIN_LAST_TAB: 'admin-last-tab',
DENSITY: 'odysseus-density',
UI_SCALE: 'odysseus-ui-scale',
WORKSPACE: 'odysseus-workspace'
};
+27
View File
@@ -39,6 +39,7 @@ const FONT_MAP = {
mono: "'Fira Code', monospace",
sans: "system-ui, -apple-system, 'Segoe UI', sans-serif",
serif: "Georgia, 'Times New Roman', serif",
opendyslexic: "'OpenDyslexic', sans-serif",
};
const DEFAULT_FONT = 'mono';
const DEFAULT_DENSITY = 'comfortable';
@@ -387,6 +388,20 @@ export function applyFontDensity(font, density) {
if (d !== 'comfortable') document.documentElement.classList.add('density-' + d);
}
// UI text-size scale (accessibility). Global and independent of the active
// theme, so the chosen size persists across theme switches. Stored as a plain
// percentage string ('100' | '110' | '125' | '150').
const UI_SCALE_KEY = 'odysseus-ui-scale';
const DEFAULT_UI_SCALE = '100';
export function applyUiScale(scale) {
const s = scale || DEFAULT_UI_SCALE;
// Only one non-default scale ('125'). Remove any legacy classes too so an
// older stored value can't leave a stale zoom applied.
document.documentElement.classList.remove('ui-scale-110', 'ui-scale-125', 'ui-scale-140');
if (s === '125') document.documentElement.classList.add('ui-scale-125');
}
const _BG_CLASSES = ['bg-pattern-dots',
'bg-pattern-synapse', 'bg-pattern-rain', 'bg-pattern-constellations',
'bg-pattern-perlin-flow',
@@ -1133,6 +1148,18 @@ export function initThemeUI() {
const s = getSaved(); if (s) _saveFull(s.name, s.colors);
});
}
const textSizeSelect = document.getElementById('theme-text-size-select');
if (textSizeSelect) {
const nts = textSizeSelect.cloneNode(true); textSizeSelect.parentNode.replaceChild(nts, textSizeSelect);
let initScale = DEFAULT_UI_SCALE;
try { initScale = localStorage.getItem(UI_SCALE_KEY) || DEFAULT_UI_SCALE; } catch (e) {}
nts.value = initScale;
applyUiScale(initScale);
nts.addEventListener('change', () => {
applyUiScale(nts.value);
try { localStorage.setItem(UI_SCALE_KEY, nts.value); } catch (e) {}
});
}
if (patternSelect) {
const np = patternSelect.cloneNode(true); patternSelect.parentNode.replaceChild(np, patternSelect);
np.value = _initPattern;
+37
View File
@@ -114,6 +114,10 @@ body {
@font-face { font-family: 'Fira Code'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Fira Code'; font-weight: 600; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-SemiBold.woff2') format('woff2'); }
/* Self-hosted OpenDyslexic — dyslexia-friendly accessibility font option (SIL OFL 1.1) */
@font-face { font-family: 'OpenDyslexic'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/OpenDyslexic-Regular.woff2') format('woff2'); }
@font-face { font-family: 'OpenDyslexic'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/OpenDyslexic-Bold.woff2') format('woff2'); }
/* Code block baseline */
pre, code, .hljs {
font-size: 0.95em;
@@ -158,6 +162,39 @@ html {
:root.density-spacious .list-item { padding: 8px 12px; }
:root.density-spacious .sidebar .section { padding: 0; }
/* ── UI text-size scale (accessibility) ──
Density only changes the root font-size, which can't move the many
hard-coded px sizes. `zoom` scales the whole UI uniformly (px text
included) while keeping layout intact, unlike `transform: scale`. */
:root.ui-scale-125 { zoom: 1.25; }
/* `zoom` makes the 100dvh shell render taller than the real viewport, which
pushes the bottom-pinned sidebar account/settings row below the fold (and
body's overflow:hidden then clips it). Shrink the shell by the same factor
so it fits the viewport exactly. */
:root.ui-scale-125 body { height: calc(100dvh / 1.25); }
/* Modals/panels under the 1.25x scale: zoom renders a centred, viewport-sized
panel ~1.25x taller, pushing its draggable header + close button off-screen
(a catch-22 — you can't reach the control to turn the size back down). Divide
each max-height by the same factor to keep the original on-screen footprint.
Desktop only — the mobile `!important` full-sheet rules win on small screens
and stay top-anchored, so their headers are already visible. */
:root.ui-scale-125 .modal-content { max-height: calc(85dvh / 1.25); }
:root.ui-scale-125 .cal-modal-content { max-height: calc(88dvh / 1.25); }
:root.ui-scale-125 .settings-modal-content { max-height: calc(85dvh / 1.25); }
:root.ui-scale-125 #theme-popup { max-height: min(calc(85dvh / 1.25), 480px); }
/* Cookbook is the one modal that set its height inline (94vh), which beat the
.modal-content compensation above and overflowed the viewport at 1.25x
(header + close button pushed off-screen). Own its height here so the same
zoom compensation applies. */
#cookbook-modal .modal-content { height: 94vh; max-height: 94vh; }
:root.ui-scale-125 #cookbook-modal .modal-content { height: calc(94dvh / 1.25); max-height: calc(94dvh / 1.25); }
/* PDF export modal also set its height inline (86vh) at v1.0; that inline cap
beat the .modal-content compensation above and shifted ~1vh at Default when
removed. Own its height here so Default is byte-for-byte 86vh and the same
1.25x compensation applies. */
.pdf-export-overlay .modal-content { max-height: 86vh; }
:root.ui-scale-125 .pdf-export-overlay .modal-content { max-height: calc(86dvh / 1.25); }
/* ── Background Patterns ── */
:root { --bg-effect-intensity: 1; }
+30
View File
@@ -170,6 +170,36 @@ def test_extract_thinking_blocks_handles_thought_tag(node_available):
assert result["content"] == "Final answer."
def test_url_inside_inline_code_is_not_autolinked(node_available):
# A URL inside a backtick span is preceded by a space, so the bare-URL
# autolink used to wrap it in an <a> tag (then swap it for an
# ___ALLOWED_HTML_ placeholder), corrupting the command shown to the user.
html = _run_markdown_case("Run `$j = irm http://127.0.0.1:3000/x` to fetch.")
assert "<code>$j = irm http://127.0.0.1:3000/x</code>" in html
assert "___ALLOWED_HTML_" not in html
assert "<a " not in html
assert 'href="http://127.0.0.1:3000/x"' not in html
def test_url_outside_inline_code_is_still_autolinked(node_available):
# Inline code must not disable autolinking for bare URLs elsewhere in the
# same line.
html = _run_markdown_case("Use `irm` then visit https://example.com/page now.")
assert "<code>irm</code>" in html
assert 'href="https://example.com/page"' in html
def test_inline_code_content_is_html_escaped(node_available):
# Inline code is now extracted before the global escape pass, so it must be
# escaped at extraction time (matching the fenced-code-block handling).
html = _run_markdown_case("Render `<b>$1 & 'q'</b>` literally.")
assert "<code>&lt;b&gt;$1 &amp; &#39;q&#39;&lt;/b&gt;</code>" in html
assert "<b>" not in html
def test_dotted_python_import_paths_are_not_autolinked(node_available):
html = _run_markdown_case(
"from imblearn.combine import SMOTETomek\n"
+49
View File
@@ -0,0 +1,49 @@
r"""Regression test for ReDoS in the calendar-extract fallback regex.
CodeQL `py/redos` (#198) flagged the inline array-matcher in
`email_pollers.py` that recovers a `[{"action": ...}, ...]` JSON array from
raw LLM output (influenced by attacker-supplied email bodies). The original
pattern used `[^[\]]*?` lazy runs inside a `(...)*` repetition, which
backtracks *exponentially* on inputs like `[{"action"},{` + `}},{{` * N.
The regex is now a module-level constant so it can be pinned here. These tests
assert it (a) still extracts well-formed action arrays and (b) returns
promptly on the adversarial input that hung the old pattern.
"""
import time
from routes.email_pollers import _CAL_ACTION_ARRAY_RE
def _matches(s):
return [m.group() for m in _CAL_ACTION_ARRAY_RE.finditer(s)]
def test_extracts_action_array_from_prose():
s = 'Here you go:\n[{"action":"add","title":"Standup","start":"2026-07-01T09:00"}]\nThanks!'
assert _matches(s) == ['[{"action":"add","title":"Standup","start":"2026-07-01T09:00"}]']
def test_extracts_multi_object_array():
s = 'prose [{"action":"add","title":"A"},{"action":"cancel","uid":"x"}] tail'
assert _matches(s) == ['[{"action":"add","title":"A"},{"action":"cancel","uid":"x"}]']
def test_no_array_returns_no_match():
assert _matches("no array here at all") == []
def test_bracket_in_string_value_still_extracts():
# The old `[^[\]]` class bailed on a '[' inside a value and matched nothing;
# the linear `[^{}]` form correctly recovers the array.
s = '[{"action":"add","title":"Meeting [urgent]","start":"x"}]'
assert _matches(s) == [s]
def test_adversarial_input_is_fast():
evil = '[{"action"},{' + '}},{{' * 100_000 # exploded the old exponential pattern
start = time.perf_counter()
_CAL_ACTION_ARRAY_RE.search(evil)
dt = time.perf_counter() - start
assert dt < 1.0, f"_CAL_ACTION_ARRAY_RE took {dt:.2f}s on adversarial input"
@@ -0,0 +1,29 @@
import re
import subprocess
from pathlib import Path
def test_opencode_setup_provider_aliases_resolve():
source = Path("static/js/slashCommands.js").read_text()
match = re.search(
r"const SETUP_PROVIDER_URLS = \{[\s\S]*?\nfunction _normalizeSetupBaseUrl",
source,
)
assert match, "setup provider helper block not found"
helper_source = match.group(0).removesuffix("\nfunction _normalizeSetupBaseUrl")
script = helper_source + r"""
function assert(condition, message) {
if (!condition) throw new Error(message);
}
const zenFromCommand = _setupProviderFromInput('opencode zen');
assert(zenFromCommand && zenFromCommand.url === 'https://opencode.ai/zen/v1', 'opencode zen command alias failed');
const goFromCommand = _setupProviderFromInput('opencode-go');
assert(goFromCommand && goFromCommand.url === 'https://opencode.ai/zen/go/v1', 'opencode-go command alias failed');
const zenCredential = _extractSetupProviderCredential('opencode-zen sk-test');
assert(zenCredential && zenCredential.provider.name === 'OpenCode Zen', 'opencode-zen credential provider failed');
assert(zenCredential.credential === 'sk-test', 'opencode-zen credential extraction failed');
const goCredential = _extractSetupProviderCredential('opencode go sk-test');
assert(goCredential && goCredential.provider.name === 'OpenCode Go', 'opencode go credential provider failed');
assert(goCredential.credential === 'sk-test', 'opencode go credential extraction failed');
"""
subprocess.run(["node", "-e", script], check=True)