Merge branch 'relnext/security-notices': security notices, starting with the OpenCode Console breach

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-18 10:03:02 +02:00
commit f744c503c5
12 files changed
+1805 -29

No files matched your search

+2
View File
@@ -26,6 +26,8 @@
**Spreadsheet preview (XLSX)** (`spreadsheet-preview.js` + `spreadsheet-preview-worker.js`; moved from File-path links in the root file): ⚠️ **XLSX previews parse in the BROWSER**, never on the server: `spreadsheet-preview.js` fetches the raw route with `?preview=true` (413 above `MAX_XLSX_BROWSER_PREVIEW_BYTES`, 10 MB) and hands the bytes to `spreadsheet-preview-worker.js`, the only place the pinned `exceljs`/`fflate` vendor bundles load (never on page load). `admitXlsx()` caps the ZIP before ExcelJS runs, counting what ExcelJS will EXPAND as well as what it reads (a merge costs its area, a `<col>` past 16384 is refused, validations are never parsed via `ignoreNodes`, and defined names are never expanded: the worker stubs `_definedNames.model`). ⚠️ The INDEX a row or sheet claims is bounded too, since ExcelJS allocates and walks up to it: a `<row r>` outside 1-1048576 and an `xl/workbook.xml` `<sheet sheetId>` above `LIMITS.maxSheetId` (65535) are refused, and `sendTile` reuses the merges read at load (`mergesById`), never `sheet.model`, which rebuilds the whole sheet. ⚠️ The COLUMN index costs the same way (a row's cells live at `_cells[col - 1]`, so one `XFD` cell makes ExcelJS's `eachRow`/`eachCell`/`hasValues` visit 16,384 slots per row): `worksheetMetadata` builds each sheet's row and cell index from the keys that exist (`Object.keys` of `_rows` and `_cells`, skipping falsy and `Null`-type cells as `eachCell({ includeEmpty: false })` does), reads row heights in that pass and merges from `sheet._merges`, and `sendTile` reads cells from that index (`populatedRowsById`); never call ExcelJS's dense `eachRow`/`eachCell` there. `parseThemePalette` returns the default palette for a theme above 64 KB (64 * 1024 characters), since its patterns are quadratic on unclosed tags. ⚠️ Merges are capped per sheet (`LIMITS.maxMergesPerSheet`, 2,000) AND workbook-wide (`LIMITS.maxMerges`, 10,000), refused as `merge-limit` before ExcelJS loads, since ExcelJS's `_mergeCellsInternal` checks each merge against every earlier one on its sheet (quadratic). Every `<numFmt>` in `xl/styles.xml` is read with `readTagAttributes()` and refused (`number-format`) when its decoded `formatCode` is over 255 characters or has a `[` after its last `]`: ExcelJS's `isDateFmt` rescans to the end of the code per unclosed `[` once per numeric cell, and the code is echoed into the notice bar. ⚠️ `formatCellValue` caps every cell's display text at `LIMITS.maxCellTextChars` (1,000, ellipsis, never splitting a surrogate pair) for every value shape (rich text, hyperlink text, formula source, errors), since structured clone copies each tile cell's whole string to the page and the load timeout no longer covers tiles. `richTextPrefix` also visits at most `maxCellTextChars + 1` runs, not only keeps that much text: an empty `<r/>` adds nothing, so a shared string of a million empty runs was walked whole per cell, per tile (56 s a tile). ⚠️ Every part ExcelJS parses (all but the pure-bytes `xl/media/<name>.<ext>`) is also charged per START TAG against `LIMITS.maxElements` (2,000,000, refused as `element-limit`): ExcelJS builds an object per element in sharedStrings, styles, comments, drawings, VML and tables too, and empty stored blocks pad a deflate stream past the ratio cap, so 8.3M empty `<r/>` runs in a 375 KB file cost 570 MB of heap. ⚠️ Admission keys every entry on the name ExcelJS will SEE (`excelJsEntryName()`: JSZip's `.`/`..`/empty-segment resolution, then one leading `/` stripped), refuses two entries that land on one name, and treats anything matching ExcelJS's UNANCHORED `xl/worksheets/sheet<N>.xml` as a worksheet, so `/xl/...` or `xl/./...` cannot skip a counter, and ExcelJS parses only a STORE-only archive rebuilt from the entries admission inflated (`buildAdmittedArchive()`), never the fetched bytes; cell text goes through `textContent`, formulas are never evaluated. Bumping either package or editing the worker/core changes `SPREADSHEET_ASSET_VERSION`, which `npm run check:public-assets` pins. xls/ods stay download-only.
**Security notices** (`src/web/public/security-notices.json`; placed by `checkWhatsNew()` in settings-ui.js through the pure `CodemanSecurityNotices.select()`/`.plan()` in constants.js; drawn by `showSecurityNotice()`/`showUpdateNotice()` in panels-ui.js, CSS `.security-notice*` beside the toast rules): a designed warning card (shield, "Security notice" eyebrow, title, message, the notice's link, Dismiss, ×) about something outside Codeman its users should act on (the first: OpenCode's Console breach, Oct 2026). ⚠️ The file is HAND-maintained and ships with a release; never put a notice in `whats-new.json`, which `npm run version-packages` rewrites. Adding and retiring one: `docs/releasing.md`. ⚠️ A notice naming a `cli` shows ONLY when `window.__codemanCliAvailable[cli] === true`: absent, unknown and false all mean "not installed", the opposite of `isCliAvailable()` (which offers a launch on a doubt), because a warning about software you do not have is noise. The id lives only in the JSON as data; no code branches on it (`test/frontend-cli-no-id-branching.test.ts` scans constants.js). ⚠️ ONE notification per notice per load, decided only by `plan()`: when the once-per-release update toast shows (an existing install on a new version), every selected notice rides INSIDE it as a warning section beside "See what's new"; otherwise (a fresh install, a same-version reload) each gets its own card. With no notice selected the update toast is the plain `showToast` it always was, pinned byte for byte by the test. ⚠️ A detached session window (`/session/:id`) is rendered WITHOUT `__codemanCliAvailable`, so it cannot select a CLI notice: while any notice is pending (every CLI the file names counted as installed), it shows and records nothing and leaves the update toast and the notice to the dashboard, or it would record the version and the dashboard would show the notice as a separate card; with none pending it shows the plain toast as before. ⚠️ A notice is recorded as seen (`codeman-security-notices-seen`, a JSON array) ONLY on a user action (×, Dismiss, its link), never when it appears; "See what's new" opens the release notes and leaves an unanswered warning up. Storage blocked shows neither the notices nor the update toast. ⚠️ The cards sit in the SAME `.toast-container` (never their own layer), use theme tokens only (small amber text and the primary button use `--notice-ink`, which the light skins darken a step because their `--yellow` is under 4.5:1 for that text on white; the primary button's label is `--bg-card`), are capped to the room below the stack's top and scroll inside themselves (the container is fixed and cannot scroll, so the accent bar is a background layer, never a positioned child), animate opacity and transform only, and nothing moves under `prefers-reduced-motion`. A closing card is inert and its buttons do nothing during the fade; links open with `noopener,noreferrer` and the https rule is checked again at the button (`CodemanSecurityNotices.urlOk`). The notice's own text is textContent with `data-i18n-skip` (it carries its own zh-CN); the card's own copy translates as usual. The server serves `.json` from `public/` as `no-cache`, never `immutable` (`test/static-cache-headers.test.ts`). Tests: `test/security-notices.test.ts`.
## Frontend modules and UI subsystems
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order is the `<script>` order in `index.html` (each module's `@loadorder` tag mirrors it). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The edit-based diff described below is settled first at that keydown, so the drain decides with the evidence the timer had and Enter's textarea clear cannot turn the pending line into DELs. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run. The same module replaces xterm's `_handleAnyTextareaChanges` (an append-only `newValue.replace(oldValue, '')` diff) with an edit-based one, so an Android autocorrect on space (delete + insert) reaches the PTY once instead of duplicating the line; ⚠️ that diff is settled at the NEXT keydown, before xterm handles that key, because xterm clears the textarea for Enter and a pending diff would then send one DEL per character ahead of the submitted line (except a composition xterm finalizes synchronously at that key, which stays xterm's). ⚠️ Every xterm that takes keyboard input wires its OWN controller from this module: the primary pane (terminal-ui.js `initTerminal()`) and each `TerminalTile` (terminal-tile.js `_createKeyCode229Recovery()`, so every grid tile and the split's Pane B, which a wide Android tablet reaches), each on its own textarea, composition helper and session; in both, `handleKeyEvent` must run ABOVE the custom key handler's keyCode-229 early return, and `notifyCanonicalData` sits in the onData lambda, never in the send path the recovered bytes also take. `mobile-ime-preview.js` (iOS WebKit only) paints the text an IME is composing: an iOS IME commit is routed into the local-echo overlay through the ordinary printable/paste branch and then `_transferMobileImeCommitToLocalEcho`, and without local echo the preview clears only on output parsed AFTER the commit (or its 2 s fallback). ⚠️ It watches keydown in the capture phase on `terminal.element`, never on the textarea, because xterm finalizes the composition and emits the commit in its own capture listener on the textarea.
+154
View File
@@ -3436,3 +3436,157 @@ if (typeof window !== 'undefined') {
TERMINAL_MIN_ROWS,
};
}
// ═══════════════════════════════════════════════════════════════
// Security notices
// ═══════════════════════════════════════════════════════════════
// A security notice is a sticky warning toast about something outside Codeman that
// its users should act on (the first: OpenCode's Console breach, October 2026). The
// notices are DATA in the hand-maintained src/web/public/security-notices.json, never
// in whats-new.json (`npm run version-packages` rewrites that one every release), and
// they ship with a release. This is the pure half: which notices this browser shows, and
// on which surface. The wiring (fetches, the seen lists) is checkWhatsNew() in settings-ui.js,
// the cards are showSecurityNotice() / showUpdateNotice() in panels-ui.js.
/** `YYYY-MM-DD`, and a real calendar day (no 2026-02-30). */
const SECURITY_NOTICE_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
/** An https URL with a plain host: no other scheme, no userinfo, no whitespace. */
const SECURITY_NOTICE_URL_PATTERN = /^https:\/\/[A-Za-z0-9.-]+(?::\d{1,5})?(?:[/?#]\S*)?$/;
/**
* The local-time bounds of one `YYYY-MM-DD` day, or null when the string is not one.
* Local time on purpose: a notice dated the 18th starts on the viewer's 18th.
*/
function securityNoticeDay(value) {
const match = typeof value === 'string' ? SECURITY_NOTICE_DATE_PATTERN.exec(value) : null;
if (!match) return null;
const year = Number(match[1]);
const month = Number(match[2]) - 1;
const day = Number(match[3]);
const start = new Date(year, month, day);
if (start.getFullYear() !== year || start.getMonth() !== month || start.getDate() !== day) return null;
const end = new Date(year, month, day + 1).getTime() - 1;
return { start: start.getTime(), end };
}
function securityNoticeText(value) {
return typeof value === 'string' && value.trim() ? value.trim() : '';
}
/**
* The notices to show, in file order, each as `{ id, title, message, label, url }`
* (`title` is '' when the entry has none).
*
* - A malformed entry is skipped, never repaired: no id, message or link label, a
* link that is not https, a date that is not a real `YYYY-MM-DD` day, a `title`
* that is present but not a non-blank string, or a `cli` that is not a registry
* id. A repeated id shows its first entry only.
* - Shown from the start of the `published` day to the END of the `expires` day.
* The expiry is what keeps an old notice from alarming a browser that first opens
* Codeman months later.
* - An id in `seen` is skipped (the user closed it, or followed its link).
* - A notice naming a `cli` is shown ONLY when `available[cli] === true`. Absent,
* unknown and false all mean "not installed" here, unlike isCliAvailable() in
* settings-ui.js (which offers a launch on a doubt): a warning about software you
* do not have is noise. A notice with no `cli` is for everyone.
* - `lang` 'zh-CN' takes the entry's `i18n['zh-CN']` title, message and label where
* they exist, each falling back to the English one on its own.
*
* The CLI id is data from the JSON, compared as a key; no code here names one.
*
* @param {Array<object>} notices - `notices` from security-notices.json
* @param {{available?: Record<string, boolean>, seen?: Iterable<string>, now?: number, lang?: string}} [opts]
* `available` is window.__codemanCliAvailable, `now` epoch milliseconds (default Date.now()).
* @returns {Array<{id: string, title: string, message: string, label: string, url: string}>}
*/
function selectSecurityNotices(notices, opts = {}) {
if (!Array.isArray(notices)) return [];
const available = opts.available && typeof opts.available === 'object' ? opts.available : {};
const seenSource = opts.seen;
const seen = new Set(
seenSource && typeof seenSource !== 'string' && typeof seenSource[Symbol.iterator] === 'function'
? Array.from(seenSource, String)
: []
);
const now = typeof opts.now === 'number' && Number.isFinite(opts.now) ? opts.now : Date.now();
const lang = opts.lang === 'zh-CN' ? 'zh-CN' : 'en';
const taken = new Set();
const shown = [];
for (const entry of notices) {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
const id = securityNoticeText(entry.id);
const title = securityNoticeText(entry.title);
const message = securityNoticeText(entry.message);
const link = entry.link && typeof entry.link === 'object' ? entry.link : null;
const label = securityNoticeText(link?.label);
const url = typeof link?.url === 'string' ? link.url.trim() : '';
const published = securityNoticeDay(entry.published);
const expires = securityNoticeDay(entry.expires);
if (!id || !message || !label || !SECURITY_NOTICE_URL_PATTERN.test(url) || !published || !expires) continue;
if (entry.title !== undefined && !title) continue;
if (entry.cli !== undefined && (typeof entry.cli !== 'string' || !CLI_ID_PATTERN.test(entry.cli))) continue;
if (taken.has(id)) continue;
taken.add(id);
if (now < published.start || now > expires.end) continue;
if (seen.has(id)) continue;
if (entry.cli !== undefined && available[entry.cli] !== true) continue;
const localized = lang === 'en' ? null : entry.i18n?.[lang];
shown.push({
id,
title: securityNoticeText(localized?.title) || title,
message: securityNoticeText(localized?.message) || message,
label: securityNoticeText(localized?.label) || label,
url,
});
}
return shown;
}
/**
* Which surface carries each selected notice on this page load, decided together with
* the once-per-release update toast (checkWhatsNew in settings-ui.js) so a browser gets
* ONE notification, never a card and a toast for the same notice.
*
* - The update toast shows exactly as checkWhatsNew always decided: this browser saw an
* older version (`seenVersion` set and different), and `notes` are this version's with
* at least one highlight. Then every selected notice rides INSIDE it (`updateNotices`)
* and no standalone card is shown.
* - Otherwise (a fresh install, a reload on the same version, notes missing or empty)
* each notice gets its own card (`cards`).
* - `recordVersion`: store `runningVersion` as seen, as checkWhatsNew always did: on a
* fresh install (so it is not greeted with "what's new") and when the toast shows.
* Notes that are missing or empty record nothing, so the next load tries again.
*
* @param {{seenVersion?: string|null, runningVersion: string, notes?: object|null, notices?: Array<object>}} state
* `notices` is the output of selectSecurityNotices().
* @returns {{recordVersion: boolean, update: boolean, updateNotices: Array<object>, cards: Array<object>}}
*/
function planStartupNotices(state = {}) {
const seenVersion = typeof state.seenVersion === 'string' && state.seenVersion ? state.seenVersion : null;
const runningVersion = state.runningVersion;
const notices = Array.isArray(state.notices) ? state.notices : [];
const notes = state.notes;
const update = Boolean(
seenVersion &&
seenVersion !== runningVersion &&
notes &&
notes.version === runningVersion &&
Array.isArray(notes.highlights) &&
notes.highlights.length > 0
);
return {
recordVersion: !seenVersion || update,
update,
updateNotices: update ? notices.slice() : [],
cards: update ? [] : notices.slice(),
};
}
if (typeof window !== 'undefined') {
window.CodemanSecurityNotices = {
select: selectSecurityNotices,
plan: planStartupNotices,
// The same https-only rule select() applies, for the card's link button to check again.
urlOk: (url) => typeof url === 'string' && SECURITY_NOTICE_URL_PATTERN.test(url),
};
}
+3
View File
@@ -848,6 +848,9 @@
'Settings saved': '设置已保存',
// The once-per-release toast (settings-ui.js checkWhatsNew); its highlights stay English.
"See what's new": '查看更新内容',
// The security-notice cards' own eyebrow (panels-ui.js showSecurityNotice/showUpdateNotice);
// each notice's title, message and link label carry their own zh-CN text and are skipped.
'Security notice': '安全提示',
'Settings applied': '设置已应用',
'Save and keep Settings open': '保存并保持设置打开',
'Settings saved locally': '设置已保存到本机',
+184 -10
View File
@@ -6249,16 +6249,7 @@ Object.assign(CodemanApp.prototype, {
closeBtn.onclick = (e) => { e.stopPropagation(); dismiss(); };
toast.appendChild(closeBtn);
// Cache toast container reference
if (!this._toastContainer) {
this._toastContainer = document.querySelector('.toast-container');
if (!this._toastContainer) {
this._toastContainer = document.createElement('div');
this._toastContainer.className = 'toast-container';
document.body.appendChild(this._toastContainer);
}
}
this._toastContainer.appendChild(toast);
this._toastStack().appendChild(toast);
requestAnimationFrame(() => toast.classList.add('show'));
@@ -6272,6 +6263,189 @@ Object.assign(CodemanApp.prototype, {
return { dismiss, setMessage: (text) => { msgSpan.textContent = text; } };
},
/** The fixed toast stack (`.toast-container`), created on first use and cached. */
_toastStack() {
if (!this._toastContainer) {
this._toastContainer = document.querySelector('.toast-container');
if (!this._toastContainer) {
this._toastContainer = document.createElement('div');
this._toastContainer.className = 'toast-container';
document.body.appendChild(this._toastContainer);
}
}
return this._toastContainer;
},
/**
* A security notice (security-notices.json) as its own card in the toast stack: shield,
* "Security notice" eyebrow, the notice's title and message, its link as the primary
* button, a quiet Dismiss and the ×. Same container as every toast, so it never fights
* the z-index stack. Sticky: only the user ends it.
*
* The notice's own text (title, message, link label) is already in the UI language, so it
* is set with textContent and carries `data-i18n-skip`; the card's own copy ("Security
* notice", "Dismiss", the × label) is application text and translates as usual.
*
* `opts.onClose(reason)` fires ONCE, and only on a user action: `'link'`, `'dismiss'` or
* `'close'`. Never on the handle's own `dismiss()`, which does not mean the user saw it.
*
* @param {{id: string, title: string, message: string, label: string, url: string}} notice
* @param {{onClose?: (reason: string) => void}} [opts]
*/
showSecurityNotice(notice, opts = {}) {
const card = this._noticeCard('security-notice', opts.onClose);
const main = this._noticeEl('div', 'security-notice-main');
main.append(this._noticeEl('div', 'security-notice-eyebrow', 'Security notice'));
main.append(...this._noticeTextBlock(notice));
const actions = this._noticeEl('div', 'security-notice-actions');
const link = this._noticeLinkButton(notice, card, () => card.close('link'));
if (link) actions.append(link);
actions.append(this._noticeButton('security-notice-quiet', 'Dismiss', () => card.close('dismiss')));
main.append(actions);
card.el.append(this._noticeIcon('shield', 'security-notice-icon'), main, card.closeButton);
card.mount();
return { dismiss: () => card.close(null) };
},
/**
* The once-per-release update toast when security notices ride INSIDE it
* (CodemanSecurityNotices.plan in constants.js decides that; with no notice the plain
* showToast toast is used, unchanged). Styled as the security card: the release text as
* before (translated by the same i18n.js pattern), one warning section per notice, then
* each notice's link button beside "See what's new".
*
* Sticky, since it carries a warning. `onNoticeSeen(id)` records a notice: the × records
* every notice in the card, a notice's link records that one and closes the card. "See
* what's new" opens the release notes and leaves the card up, because the warning in it
* has not been answered yet.
*
* @param {{text: string, releaseUrl: string, notices: Array<object>, onNoticeSeen: (id: string) => void}} opts
*/
showUpdateNotice({ text, releaseUrl, notices, onNoticeSeen }) {
const seen = (id) => { try { onNoticeSeen?.(id); } catch (err) { console.error('[notice] onNoticeSeen failed:', err); } };
const card = this._noticeCard('security-notice update-notice', (reason) => {
if (reason === 'close') for (const notice of notices) seen(notice.id);
});
const main = this._noticeEl('div', 'security-notice-main');
main.append(this._noticeEl('p', 'update-notice-text', text));
const actions = this._noticeEl('div', 'security-notice-actions');
for (const notice of notices) {
const section = this._noticeEl('section', 'security-notice-section');
const head = this._noticeEl('div', 'security-notice-section-head');
head.append(this._noticeIcon('shield'), this._noticeEl('span', 'security-notice-eyebrow', 'Security notice'));
section.append(head, ...this._noticeTextBlock(notice));
main.append(section);
const link = this._noticeLinkButton(notice, card, () => { seen(notice.id); card.close('link'); });
if (link) actions.append(link);
}
actions.append(
this._noticeButton(
'security-notice-secondary',
"See what's new",
card.act(() => window.open(releaseUrl, '_blank', 'noopener,noreferrer'))
)
);
main.append(actions);
card.el.append(this._noticeIcon('update', 'security-notice-icon update-notice-icon'), main, card.closeButton);
card.mount();
return { dismiss: () => card.close(null) };
},
/**
* The shell both notice cards share: role=alert, the ×, mount and a once-only close.
* A closing card stays in the DOM for its 200 ms fade, so close() also makes it inert, and
* `act(fn)` wraps a button's handler to do nothing once the card is closing (a double click
* on a link used to open it twice; the × and Dismiss go through close(), already once-only).
*/
_noticeCard(className, onUserClose) {
const el = this._noticeEl('div', className);
el.setAttribute('role', 'alert');
el.setAttribute('aria-live', 'assertive');
let closed = false;
const close = (reason) => {
if (closed) return;
closed = true;
el.inert = true;
el.classList.remove('show');
setTimeout(() => el.remove(), 200);
if (reason && typeof onUserClose === 'function') {
try { onUserClose(reason); } catch (err) { console.error('[notice] onClose failed:', err); }
}
};
const closeButton = this._noticeButton('security-notice-close', '×', () => close('close'));
closeButton.setAttribute('aria-label', 'Close');
const mount = () => {
this._toastStack().appendChild(el);
requestAnimationFrame(() => el.classList.add('show'));
};
const act = (fn) => () => { if (!closed) fn(); };
return { el, closeButton, close, mount, act };
},
/** The notice's own title (when it has one) and message: textContent, never translated. */
_noticeTextBlock(notice) {
const parts = [];
if (notice.title) parts.push(this._noticeEl('div', 'security-notice-title', notice.title, true));
parts.push(this._noticeEl('p', 'security-notice-body', notice.message, true));
return parts;
},
/**
* The notice's link as the primary button, or null when the link is not https.
* selectSecurityNotices() already drops such a notice; the URL is checked again here, at the
* sink, because showSecurityNotice/showUpdateNotice take any object. Opened with noopener
* AND noreferrer: noopener alone still sends this instance's origin (a tailnet name, a tunnel
* URL) to the linked site as the Referer.
*/
_noticeLinkButton(notice, card, after) {
const url = notice.url;
if (!window.CodemanSecurityNotices?.urlOk?.(url)) return null;
return this._noticeButton(
'security-notice-primary',
notice.label,
card.act(() => { window.open(url, '_blank', 'noopener,noreferrer'); after(); }),
true
);
},
_noticeButton(className, label, onClick, untranslated = false) {
const btn = this._noticeEl('button', className, label, untranslated);
btn.type = 'button';
btn.onclick = (e) => { e.stopPropagation(); onClick(); };
return btn;
},
_noticeEl(tag, className, text, untranslated = false) {
const el = document.createElement(tag);
el.className = className;
if (text !== undefined) el.textContent = text;
if (untranslated) el.setAttribute('data-i18n-skip', '');
return el;
},
/** Inline SVG icons (no asset): `shield` (with an exclamation) or `update` (arrow up). */
_noticeIcon(kind, wrapperClass) {
const NS = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(NS, 'svg');
for (const [k, v] of Object.entries({
viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2',
'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'aria-hidden': 'true', focusable: 'false',
})) svg.setAttribute(k, v);
const shapes = kind === 'shield'
? [['path', { d: 'M12 3 4.5 6v5.2c0 4.5 3.1 8.4 7.5 9.8 4.4-1.4 7.5-5.3 7.5-9.8V6L12 3z' }], ['path', { d: 'M12 8.5v4.5' }], ['path', { d: 'M12 16.5h.01' }]]
: [['circle', { cx: '12', cy: '12', r: '9' }], ['path', { d: 'M12 16.5v-9' }], ['path', { d: 'm8.5 11 3.5-3.5 3.5 3.5' }]];
for (const [tag, attrs] of shapes) {
const shape = document.createElementNS(NS, tag);
for (const [k, v] of Object.entries(attrs)) shape.setAttribute(k, v);
svg.appendChild(shape);
}
if (!wrapperClass) return svg;
const wrap = this._noticeEl('span', wrapperClass);
wrap.setAttribute('aria-hidden', 'true');
wrap.appendChild(svg);
return wrap;
},
/**
* A prominent, screen-centred status banner — for the small set of messages that are
* genuinely worth interrupting the eye for rather than living in the corner with every
+21
View File
@@ -0,0 +1,21 @@
{
"notices": [
{
"id": "opencode-console-breach-2026-10",
"severity": "warning",
"cli": "opencode",
"published": "2026-10-17",
"expires": "2026-12-31",
"title": "OpenCode Console breach: check your API keys",
"message": "OpenCode reported unauthorized access to a legacy system on 17 October. Provider API keys (OpenAI, Anthropic and others) saved in some OpenCode Console workspaces may have been exposed. If OpenCode emailed you about it, revoke and replace those keys and check their recent usage. If you got no email, OpenCode says you were not affected.",
"link": { "label": "Read OpenCode's notice", "url": "https://x.com/opencode/status/2111519637049319818" },
"i18n": {
"zh-CN": {
"title": "OpenCode Console 遭入侵:请检查你的 API 密钥",
"message": "OpenCode 于 10 月 17 日报告,其一个旧系统遭到未授权访问,部分用户保存在 OpenCode Console 工作区中的提供商 API 密钥(OpenAI、Anthropic 等)可能已泄露。如果你收到了 OpenCode 的相关邮件,请撤销并更换这些密钥,同时检查近期用量。OpenCode 表示,没有收到邮件的用户不受影响。",
"label": "查看 OpenCode 公告"
}
}
}
]
}
+107 -16
View File
@@ -378,32 +378,123 @@ Object.assign(CodemanApp.prototype, {
* The notes come from /whats-new.json, which `npm run version-packages` regenerates from
* CHANGELOG.md for each release. A browser that has never seen any version just records the
* current one, so a fresh install is not greeted with "what's new".
*
* The same pass places the security notices (/security-notices.json, see
* _loadSecurityNotices), so a load shows ONE notification per notice:
* CodemanSecurityNotices.plan() (constants.js) puts every selected notice INSIDE the update
* toast when that toast shows (showUpdateNotice), and gives each its own card otherwise, a
* fresh install or a reload on the same version (showSecurityNotice). With no notice
* selected the update toast is the plain one it always was. Storage blocked shows neither:
* nothing could remember a close.
*
* ⚠️ A detached session window (/session/:id) is rendered without __codemanCliAvailable, so
* it can never select a notice naming a CLI. Were it to take the update toast anyway, it
* would record the version and the dashboard would later show that notice as a separate
* card, not inside the update toast. So while any notice is pending (_loadSecurityNotices
* counts every CLI the file names as installed there), a solo window shows and records
* nothing and leaves both to the dashboard; with none pending it shows the plain update
* toast as it always did.
*/
async checkWhatsNew(runningVersion) {
if (this._whatsNewChecked) return;
this._whatsNewChecked = true;
const KEY = 'codeman-whats-new-seen';
let seen = null;
try { seen = localStorage.getItem(KEY); } catch { /* storage blocked: skip the announcement */ return; }
if (seen === runningVersion) return;
try { seen = localStorage.getItem(KEY); } catch { /* storage blocked: skip the announcement and the notices */ return; }
const remember = () => { try { localStorage.setItem(KEY, runningVersion); } catch { /* ignore */ } };
if (!seen) { remember(); return; }
try {
const res = await fetch('/whats-new.json', { cache: 'no-cache' });
if (!res.ok) return;
const notes = await res.json();
if (notes?.version !== runningVersion || !Array.isArray(notes.highlights) || !notes.highlights.length) return;
const [notes, notices] = await Promise.all([
seen && seen !== runningVersion ? this._fetchWhatsNewNotes() : null,
this._loadSecurityNotices(),
]);
if (this.isSoloWindow && notices.length) return;
const plan = window.CodemanSecurityNotices.plan({ seenVersion: seen, runningVersion, notes, notices });
if (plan.recordVersion) remember();
if (plan.update) {
const shown = notes.highlights.slice(0, 3).join(' · ');
const more = notes.highlights.length > 3 ? ` (+${notes.highlights.length - 3} more)` : '';
remember();
this.showToast(`Codeman updated to v${runningVersion}: ${shown}${more}`, 'info', {
duration: 20000,
action: {
label: "See what's new",
onClick: () => window.open(`https://github.com/Ark0N/Codeman/releases/tag/codeman%40${runningVersion}`, '_blank', 'noopener'),
},
const text = `Codeman updated to v${runningVersion}: ${shown}${more}`;
const releaseUrl = `https://github.com/Ark0N/Codeman/releases/tag/codeman%40${runningVersion}`;
if (plan.updateNotices.length) {
this.showUpdateNotice({
text,
releaseUrl,
notices: plan.updateNotices,
onNoticeSeen: (id) => this._rememberSecurityNotice(id),
});
} else {
this.showToast(text, 'info', {
duration: 20000,
action: {
label: "See what's new",
onClick: () => window.open(releaseUrl, '_blank', 'noopener'),
},
});
}
}
for (const notice of plan.cards) {
this.showSecurityNotice(notice, { onClose: () => this._rememberSecurityNotice(notice.id) });
}
},
/** This release's notes, or null (offline or malformed: try again next load). */
async _fetchWhatsNewNotes() {
try {
const res = await fetch('/whats-new.json', { cache: 'no-cache' });
return res.ok ? await res.json() : null;
} catch {
return null;
}
},
/**
* The security notices this browser should see now, from the HAND-maintained
* /security-notices.json (docs/releasing.md says how to add and retire one), selected by
* CodemanSecurityNotices.select() in constants.js: dates, the seen list, and for a notice
* naming a CLI, `window.__codemanCliAvailable[cli] === true` only (unknown means not
* installed). [] on any failure, and when storage is blocked (a close could not be
* remembered, so the card would return on every load). In a solo window every CLI the file
* names counts as installed: there the answer only decides whether to leave the release to
* the dashboard (see checkWhatsNew), and is never shown.
*/
async _loadSecurityNotices() {
let seen;
try { seen = this._readSecurityNoticesSeen(); } catch { /* storage blocked: show nothing */ return []; }
try {
const res = await fetch(CodemanBase.url('/security-notices.json'), { cache: 'no-cache' });
if (!res.ok) return [];
const data = await res.json();
const list = Array.isArray(data?.notices) ? data.notices : [];
const available = this.isSoloWindow
? Object.fromEntries(list.filter((entry) => typeof entry?.cli === 'string').map((entry) => [entry.cli, true]))
: window.__codemanCliAvailable;
return window.CodemanSecurityNotices.select(data?.notices, {
available,
seen,
now: Date.now(),
lang: window.CodemanI18n?.language || window.__codemanLanguage,
});
} catch { /* offline or malformed: try again next load */ }
} catch { /* offline or malformed: try again next load */ return []; }
},
/** The ids the user closed or followed (`codeman-security-notices-seen`). Throws when storage is blocked. */
_readSecurityNoticesSeen() {
const raw = localStorage.getItem('codeman-security-notices-seen');
let ids = [];
try { ids = raw ? JSON.parse(raw) : []; } catch { /* a corrupt value counts as nothing seen */ }
return Array.isArray(ids) ? ids.filter((id) => typeof id === 'string') : [];
},
/**
* Record a notice as seen. Only a user action calls this (a close, Dismiss, its link), never
* the card appearing, so one shown while nobody was looking comes back on the next load.
* Re-reads first: another tab may have recorded a notice since this one loaded.
*/
_rememberSecurityNotice(id) {
try {
const ids = this._readSecurityNoticesSeen();
if (!ids.includes(id)) ids.push(id);
localStorage.setItem('codeman-security-notices-seen', JSON.stringify(ids));
} catch { /* storage blocked since load: it shows again next load */ }
},
openAppSettings() {
+286
View File
@@ -9742,6 +9742,292 @@ kbd {
.toast-warning { border-color: rgba(234, 179, 8, 0.4); }
.toast-info { border-color: rgba(59, 130, 246, 0.4); }
/* Security notice card (security-notices.json; showSecurityNotice / showUpdateNotice in
panels-ui.js). Its own card in the toast stack, never the plain pill, and in the SAME
container, so it shares the toasts' position and z-index. Theme tokens only, so every skin
reads. Small amber text and the primary button use --notice-ink: --yellow on the dark
skins, and on the light skins --yellow darkened a step, because their --yellow alone is
under 4.5:1 for 11-12px text on white (3.9 to 4.4 measured). The primary button's label is
--bg-card (dark on the dark skins' light amber, white on the light skins' dark amber). The
entrance animates opacity and transform only, and nothing moves under
prefers-reduced-motion.
The card is capped to the room below the stack's top and scrolls inside itself: the
container is fixed and does not scroll, so on a short screen (a phone in landscape) the
buttons at the bottom were otherwise off-screen and unreachable. The accent bar down the
left edge (amber running into red) is therefore a background layer, not a positioned
child: a background stays put while the content scrolls. */
.security-notice {
--notice-ink: var(--yellow);
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
column-gap: 0.75rem;
align-items: start;
box-sizing: border-box;
width: min(420px, calc(100vw - 1rem));
max-height: calc(var(--app-height, 100dvh) - var(--header-height) - 1rem);
padding: 0.85rem 0.55rem 0.9rem 1.05rem;
background:
linear-gradient(180deg, var(--yellow), var(--red)) left top / 4px 100% no-repeat,
linear-gradient(180deg, color-mix(in srgb, var(--yellow) 9%, transparent), transparent 6rem),
var(--bg-card);
border: 1px solid color-mix(in srgb, var(--yellow) 32%, var(--border));
border-radius: 10px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.28), 0 2px 6px rgba(0, 0, 0, 0.16);
color: var(--text);
font-size: 0.8rem;
line-height: 1.45;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
pointer-events: auto;
opacity: 0;
transform: translateY(-6px) scale(0.985);
transition: opacity 0.22s ease, transform 0.22s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.security-notice.show {
opacity: 1;
transform: none;
}
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) .security-notice {
--notice-ink: color-mix(in srgb, var(--yellow) 85%, black);
}
/* The update toast carrying a notice is still an update: its frame takes the accent colour,
and the amber is left to the warning section inside it. */
.security-notice.update-notice {
background:
linear-gradient(var(--accent), var(--accent)) left top / 4px 100% no-repeat,
var(--bg-card);
border-color: color-mix(in srgb, var(--accent) 30%, var(--border));
}
.security-notice-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 50%;
color: var(--notice-ink);
background: color-mix(in srgb, var(--yellow) 15%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--yellow) 30%, transparent);
}
.security-notice-icon svg {
width: 1.1rem;
height: 1.1rem;
}
/* The update toast carrying a notice leads with the release, in the accent colour. */
.security-notice-icon.update-notice-icon {
color: var(--accent);
background: color-mix(in srgb, var(--accent) 14%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 28%, transparent);
}
.security-notice-main {
display: flex;
flex-direction: column;
gap: 0.3rem;
min-width: 0;
padding-top: 0.1rem;
}
.security-notice-eyebrow {
color: var(--notice-ink);
font-size: 0.66rem;
font-weight: 700;
letter-spacing: 0.09em;
line-height: 1.3;
text-transform: uppercase;
}
.security-notice-title {
color: var(--text);
font-size: 0.9rem;
font-weight: 700;
line-height: 1.3;
overflow-wrap: anywhere;
text-wrap: balance;
}
/* Chinese has no spaces, so a balanced title would break inside a word (遭入 / 侵).
Break only at the spaces and punctuation the title has; overflow-wrap above still
rescues a run that cannot fit. */
:lang(zh) .security-notice-title {
word-break: keep-all;
}
.security-notice-body,
.update-notice-text {
margin: 0;
overflow-wrap: anywhere;
}
.security-notice-body {
color: var(--text-dim);
}
.update-notice-text {
color: var(--text);
}
/* One warning section per notice inside the update toast. */
.security-notice-section {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-top: 0.4rem;
padding: 0.6rem 0.7rem 0.65rem;
border: 1px solid color-mix(in srgb, var(--yellow) 26%, transparent);
border-radius: 8px;
background: color-mix(in srgb, var(--yellow) 7%, transparent);
}
.security-notice-section-head {
display: flex;
align-items: center;
gap: 0.4rem;
color: var(--notice-ink);
}
.security-notice-section-head svg {
flex-shrink: 0;
width: 0.95rem;
height: 0.95rem;
}
.security-notice-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.45rem 0.8rem;
margin-top: 0.5rem;
}
/* The update toast's buttons share their row, and when it wraps (a phone, a long label)
each fills its own, so the two never sit at odd widths one under the other. */
.update-notice .security-notice-actions {
column-gap: 0.6rem;
}
.update-notice .security-notice-actions > button {
flex: 1 1 auto;
}
.security-notice-primary,
.security-notice-secondary,
.security-notice-quiet {
font: inherit;
font-size: 0.78rem;
font-weight: 700;
line-height: 1.2;
cursor: pointer;
}
.security-notice-primary {
padding: 0.45rem 0.85rem;
border: 1px solid transparent;
border-radius: var(--btn-radius);
background: var(--notice-ink);
color: var(--bg-card);
}
.security-notice-primary:hover {
background: color-mix(in srgb, var(--notice-ink) 86%, var(--text));
}
.security-notice-secondary {
padding: 0.45rem 0.85rem;
border: 1px solid var(--border-light);
border-radius: var(--btn-radius);
background: transparent;
color: var(--text);
}
.security-notice-secondary:hover {
background: var(--control-bg-hover);
}
.security-notice-quiet {
padding: 0.45rem 0.15rem;
border: none;
background: none;
color: var(--text-dim);
font-weight: 600;
}
.security-notice-quiet:hover {
color: var(--text);
text-decoration: underline;
text-underline-offset: 3px;
}
.security-notice-close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
margin-top: -0.3rem;
padding: 0;
border: none;
border-radius: 6px;
background: none;
color: var(--text-dim);
font-size: 1.15rem;
line-height: 1;
cursor: pointer;
}
.security-notice-close:hover {
background: var(--control-bg-hover);
color: var(--text);
}
.security-notice button:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* Phones: the full width minus the stack's 0.5rem gutters, and every button grows to fill
its row (so one that wraps fills its own instead of sitting stray at the left). On a touch
pointer, below, buttons are at least --touch-target-min tall and the × gets a touch-sized
box, its negative margin keeping the glyph where it sits on a desktop. */
@media (max-width: 599px) {
.security-notice {
width: calc(100vw - 1rem);
}
.security-notice-actions > button {
flex: 1 1 auto;
}
}
/* Touch sizing follows the pointer, not the width: a phone turned to landscape is wider
than 599px and still needs fingertip-sized targets. */
@media (pointer: coarse) {
.security-notice-actions > button {
min-height: var(--touch-target-min);
}
.security-notice-close {
width: var(--touch-target-min);
height: var(--touch-target-min);
margin: -0.75rem -0.5rem 0 0;
}
}
@media (prefers-reduced-motion: reduce) {
.security-notice {
transform: none;
transition: none;
}
}
/* Notification Bell Button */
.btn-icon-header.btn-notifications {
position: relative;
+10 -1
View File
@@ -964,7 +964,7 @@ export class WebServer extends EventEmitter {
// `ServerResponse` to a `FastifyReply`, so it is `reply.header()` here and
// NOT `res.setHeader()`. A v9-style body throws TypeError on every static
// request, which is every page load. See the v10.0.0 release notes.
setHeaders: (reply) => {
setHeaders: (reply, filePath) => {
// ⚠️ That same change ALSO flipped precedence, and silently. Under v9 this
// callback wrote to the raw response and Fastify's staged reply headers then
// overwrote it, so a route that set its own Cache-Control before .sendFile()
@@ -973,6 +973,15 @@ export class WebServer extends EventEmitter {
// no-store` its route asks for — a service worker that can never update.
// So: a route that already decided keeps its answer.
if (reply.getHeader('Cache-Control') !== undefined) return;
// A JSON file here is DATA the page fetches at runtime (whats-new.json is rewritten every
// release, security-notices.json by hand, manifest.json), never content-hashed and never
// `?v=`-busted, so it must revalidate. Under `immutable` a browser may keep last release's
// copy for a year, and the page's `fetch(..., { cache: 'no-cache' })` is the only thing
// asking it not to; the response header is what every browser honours.
if (filePath.endsWith('.json')) {
reply.header('Cache-Control', 'no-cache');
return;
}
reply.header('Cache-Control', 'public, max-age=31536000, immutable');
},
});