* docs: add NotebookLM GitHub export script (#1062)
* docs: add NotebookLM GitHub export script
* fix: make NotebookLM export TOC anchors work
* fix: escape TOC link text markdown chars
* fix: include merged PRs when exporting --prs all
* fix: allow --prs merged mode
* fix: treat --limit as total export budget
* fix: avoid starving buckets under global --limit
* fix: support --issues none and handle repos w/ issues disabled
* fix: avoid underfilling export when buckets empty
* fix: keep disabled-issues fallback quiet
* fix: silence disabled issues fallback
* fix: satisfy script typecheck
* prevent duplicate saves and add template deletion (#1294)
* prevent duplicate template entries on repeated save
* add delete button to saved template list
Templates can now be removed from the template picker via a hover x button, calling the existing DELETE /api/templates/:id endpoint.
* add missing onDeleteTemplate prop in test fixtures
* add template deletion flow test for NewProjectPanel
* reject template names longer than 100 characters
* preserve original createdAt on template update
* feat: add FAQ page skill (#1162)
* fix: set writable OD_DATA_DIR default for nix run
Fixes#1157
When running via 'nix run github:nexu-io/open-design', the daemon
attempted to create runtime state under the Nix store package path:
/nix/store/.../lib/open-design/.od/projects
The Nix store is read-only at runtime, causing startup to fail with
ENOENT when mkdir() tried to create the projects directory.
This commit updates the nix run wrapper to export OD_DATA_DIR with
a writable default ($HOME/.od) when the variable is unset. Users
can still override it by setting OD_DATA_DIR before running.
The Home Manager and NixOS modules already set OD_DATA_DIR, so they
are unaffected by this change.
* feat: add FAQ page skill
Add a new skill for generating Frequently Asked Questions pages with:
- Collapsible accordion sections for Q&A pairs
- Real-time search functionality
- Category filtering (Billing, Account, Technical, General)
- Smooth animations and transitions
- Keyboard navigation support
- Mobile-friendly responsive design
- Semantic HTML with proper ARIA attributes
The skill includes:
- SKILL.md with triggers, workflow, and output contract
- example.html demonstrating a complete FAQ page with 12 questions
Use cases: help centers, support pages, product documentation
* fix: address PR review feedback for FAQ page skill
- Fix craft slugs: use accessibility-baseline and state-coverage instead of non-existent slugs
- Remove overly broad 'questions and answers' trigger
- Add edge case handling for insufficient/excessive FAQs
- Remove search highlighting requirement (XSS risk)
- Update self-check to reflect filtering instead of highlighting
Addresses review comments from @lefarcen and @chatgpt-codex-connector
* feat: add localized copy for faq-page skill
Add German, French, and Russian translations for the FAQ page skill
example prompt to fix validation test failure.
- DE: FAQ-Seite mit Akkordeon-Abschnitten, Suchfunktion und Kategoriefilterung
- FR: Page FAQ avec sections accordéon, recherche et filtrage par catégorie
- RU: Страница FAQ со складными секциями-аккордеонами, поиском и фильтрацией
* fix: escape apostrophe in French translation
Use double quotes to avoid syntax error with d'auth
* fix(platform): add legacy ~/.fnm path to wellKnownUserToolchainBins (#1110)
* fix(platform): add legacy ~/.fnm path to wellKnownUserToolchainBins
fnm legacy installations use ~/.fnm/node-versions. Closes#1102
* fix: remove stray .fnm token from type declaration
* docs: add Windows troubleshooting guide (#478) (#1170)
* docs: add Windows troubleshooting guide (#478)
Add docs/windows-troubleshooting.md with step-by-step fixes for the
most common native-Windows setup errors:
- Node 24 / nvm-windows gotchas (fake nvm file in System32)
- pnpm not found after installation
- Build scripts blocked by pnpm 10 (better-sqlite3, sharp)
- Visual Studio / gyp build errors
- Starting the dev server
- Optional OpenCode CLI setup
Also update CONTRIBUTING.md and QUICKSTART.md to link to the new
guide instead of the vague "file an issue if it doesn't" note.
* docs: fix Windows guide command accuracy (#1170)
Address all 6 inline review comments from lefarcen:
- Pin npm-global pnpm install to @10.33.2 (matches packageManager field)
- Use where.exe instead of bare where (PowerShell alias conflict)
- Fix OpenCode package: opencode-ai (not opencode), binary is opencode
- Add EPERM fallback note for corepack enable on protected installs
- Add Python check for gyp ERR! find Python
- Expand diagnostic checklist with corepack, python, execution policy
Also remove redundant corepack pnpm --version from checklist.
* feat(daemon): inject compiled design-system tokens + fixture into prompts (#1385)
* feat(daemon): inject compiled design-system tokens + fixture into prompts
Follow-up to #1231. The prior PR landed the structured form of two
brands (`default` + `kami`) and codified the schema; this PR teaches
the daemon to actually consume those files when assembling the system
prompt, so agents stop having to re-derive token names from DESIGN.md
prose every turn.
Gated behind `OD_DESIGN_TOKEN_CHANNEL=1` for the smoke-test phase —
flag-off keeps the daemon byte-equivalent to today's behavior, flag-on
appends two new prompt blocks (the brand's `tokens.css` :root contract
and its `components.html` reference fixture) right after the existing
DESIGN.md block. Brands without those sibling files (every brand
except `default` and `kami` today) skip silently in either mode.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(daemon): only swallow ENOENT/ENOTDIR in readFileOptional, rethrow rest
Reviewer feedback (nettee, #1385). The prior catch-all hid permission
errors, EISDIR, and broken packaged-resource paths behind the same
"undefined = absent" branch the legacy ~138-brand fallback uses,
which would let `OD_DESIGN_TOKEN_CHANNEL=1` silently degrade to the
DESIGN.md-only prompt while reporting success. That corrupts the
exact signal the smoke-test rollout depends on.
Now `readFileOptional` only returns undefined for ENOENT / ENOTDIR
(real "file does not exist" cases) and rethrows everything else.
Added a focused test that plants a directory at the tokens.css path
to exercise the EISDIR branch, plus a partial-presence regression
test to confirm the stricter contract preserves the legacy fallback.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: chaoxiaoche <chaoxiaoche@192.168.10.16>
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(daemon): make connection-test timeouts configurable (#1222)
* feat(daemon): make connection-test timeouts configurable
Provider and agent connection tests had hardcoded 12s / 45s budgets,
which are too tight for slow networks or distant providers (the user
sees "timeout" in Settings with no way to extend the budget).
- Add OD_CONNECTION_TEST_PROVIDER_TIMEOUT_MS (default 12_000)
- Add OD_CONNECTION_TEST_AGENT_TIMEOUT_MS (default 45_000)
- Invalid values (non-numeric, zero, negative, fractional) emit a
console.warn and fall back to the default, so a typo in the env
never silently disables the safety timeout.
- Export resolveConnectionTestTimeoutMs for unit testing; cover the
three resolution paths (fallback / honored override / invalid).
41 connection-test tests pass (+3 new), full daemon suite 1170/1170.
* fix(daemon): reject connection-test timeout overrides above Node's setTimeout maximum
Node's `setTimeout` silently clamps any delay above `2^31-1` ms
(2_147_483_647) to ~1 ms with a TimeoutOverflowWarning. The previous
`Number.isInteger(n) && n >= 1` check accepted oversized values
unchanged and passed them straight to `setTimeout`, so an override
that *intended* to raise the budget — e.g.
`OD_CONNECTION_TEST_AGENT_TIMEOUT_MS=3000000000` — instead caused
every connection test to fail almost immediately. The safety
timeout was effectively disarmed.
Add `MAX_CONNECTION_TEST_TIMEOUT_MS = 2_147_483_647` and switch the
guard to `Number.isSafeInteger(n) && n >= 1 && n <= MAX...`. The
boundary value is still accepted; one millisecond past it falls
back with a warn. Regression test exercises `3_000_000_000`,
`2_147_483_647`, and `2_147_483_648`.
Addresses #1222 review feedback from @chatgpt-codex-connector,
@mrcfps, and @lefarcen.
* fix(security): strip trailing dot in normalizeBracketedIpv6 (FQDN SSRF bypass) (#1122)
* fix(security): strip trailing dot in normalizeBracketedIpv6 (FQDN bypass)
new URL('http://192.168.1.5./').hostname returns '192.168.1.5.' — the
trailing dot is the RFC 1034 absolute-FQDN form and resolves identically
to '192.168.1.5'. parseIpv4 fails on the dotted form, so 169.254.169.254.
slips past the metadata-service block, 192.168.1.5. slips past the LAN
block, and localhost. slips past the loopback identification.
Strip trailing dots in normalizeBracketedIpv6 so all downstream checks
(isLoopbackApiHost, isBlockedExternalApiHostname, isBlockedIpv4, IPv6
range tests) see the canonical form.
Adds 6 vitest cases covering loopback FQDN forms (localhost.,
foo.localhost., 127.0.0.1.) and SSRF FQDN bypasses (169.254.169.254.,
192.168.1.5., 10.0.0.5.).
Refs nexu-io/open-design#1119 review feedback (P2 from @lefarcen).
* test(connectionTest): tighten trailing-dot coverage per #1122 review
Two issues from #1122 review:
1. (P2 from @mrcfps + codex bot) The original `foo.localhost.` case
asserted error===undefined on validateBaseUrl, which only proves the
URL passed validation — not that the host is identified as loopback.
Replaced with direct isLoopbackApiHost(...) assertions on the actual
loopback FQDN forms (localhost., 127.0.0.1., 127.0.0.5.) so the test
exercises the loopback path the comment claims.
2. (P3 from @lefarcen) Original blocked-FQDN tests covered only 3 of 7
ranges that isBlockedIpv4 handles. Added a dedicated case per range
(0.0.0.0/8, 10/8, 100.64/10, 169.254/16, 172.16/12, 192.168/16,
multicast >=224) so future regressions in normalizeBracketedIpv6
surface against the full coverage.
* docs: drop misleading foo.localhost./endsWith claim in normalizer comment
@lefarcen review feedback: isLoopbackApiHost only accepts exact 'localhost',
'::1', loopback IPv4, and mapped loopback IPv4 — there's no subdomain or
endsWith handling, so referencing 'foo.localhost.' overstates what the
trailing-dot strip enables. Rewrite the comment to match actual call sites
(isLoopbackApiHost equality + isBlockedIpv4 numeric parse).
* feat(daemon): export self-contained HTML via /export/*?inline=1 endpoint (#1312)
* test(daemon): add Red unit tests for inlineRelativeAssets helper
14 cases pinning the behavior contract for the upcoming
apps/daemon/src/inline-assets.ts helper:
- link/script inlining with verbatim body preservation
- non-src script attrs preserved (type=module, defer, crossorigin)
- relative path resolution (root + nested + deep-nested owners)
- self-closing and single-quoted attr forms
- negative cases: missing rel, rel=preload, absolute/data/blob/leading-slash
- escaping: </style and </script inside body
- null-fileReader graceful degradation
- duplicate identical tags fully replaced (diverges from
apps/web/src/components/FileViewer.tsx:5313's first-match-only;
locked decision per plan §3.3)
- HTML-escaped data-od-inline-asset attr
Tests intentionally Red — module ../src/inline-assets.js does not yet
exist. Phase B-G of plan declarative-roaming-gosling.md will turn them
green by porting FileViewer.tsx:5248-5354 server-side.
Refs nexu-io/open-design#368.
* feat(daemon): port inlineRelativeAssets server-side for export endpoint
Adds apps/daemon/src/inline-assets.ts — a pure helper that takes
(html, ownerFileName, fileReader closure) and returns the HTML with
every relative <link rel=stylesheet> and <script src> contents inlined
into <style data-od-inline-asset="…">/<script>…</script> blocks. The
fileReader closure keeps the helper free of fs/Express coupling so the
route handler owns the filesystem boundary.
Port source: apps/web/src/components/FileViewer.tsx:5248-5354 — five
functions (inlineRelativeAssets, resolveProjectRelativePath, baseDirFor,
readHtmlAttr, escapeHtmlAttr). The fetch hop becomes the fileReader
closure; replace-all replaces first-match-only per locked design
decision §3.3 (inline comment in inline-assets.ts cites the divergence
from FileViewer.tsx:5313 and notes the web inline path is on a
deprecation track since PR #384 made URL-load the default).
Phase B-G of plan declarative-roaming-gosling.md. All 14 unit cases
from the Red commit (a60a9023) now pass; tightens one case to use a
realistic '&'-only filename (the original `<`/`>`-bearing filename was
unreachable in real filesystems and exposed a regex limitation the
web client carries too).
Daemon delta: +14 tests (1704 → 1718). Typecheck clean.
Refs nexu-io/open-design#368.
* test(daemon): add Red integration tests for /export/*?inline=1 route
9 HTTP cases against GET /api/projects/:id/export/*?inline=1:
- 3-file React-ish layout returns self-contained HTML (wiring guard:
body assertions catch removal of the await inlineRelativeAssets(...)
line, not just helper-internals changes)
- missing inline / non-canonical values (0, false, foo, empty) → 400
- non-HTML file → 400 UNSUPPORTED_FILE_TYPE
- missing file → 404 FILE_NOT_FOUND
- invalid project id (..) → some 4xx (Express normalizes before route)
- null-origin OPTIONS preflight → 204 + Access-Control-Allow-Origin: *
- missing sibling asset → 200 with <link> tag intact, other asset inlined
- nested HTML entry (pages/index.html + ../shared/util.js) → 200 inlined
8 of 9 tests Red (404 / 403); the invalid-project-id case is tolerant
about how Express rejects .. so it accidentally passes Red — Green
will tighten to 400 BAD_REQUEST via isSafeId.
Phase C-R of plan declarative-roaming-gosling.md. C-G will register
the route in apps/daemon/src/import-export-routes.ts.
Refs nexu-io/open-design#368.
* feat(daemon): wire GET /api/projects/:id/export/*?inline=1 endpoint
Adds the export-inline endpoint into registerProjectExportRoutes
(import-export-routes.ts) alongside /export/pdf and /archive. The
route:
- Validates project id via ctx.validation.isSafeId
- Requires ?inline=1 (accept-list: 1 / true / yes / on, matching Part
1's parseForceInline at file-viewer-render-mode.ts:59-66)
- Reads the owner HTML via ctx.projectFiles.readProjectFile; maps
ENOENT to 404 FILE_NOT_FOUND, everything else to 400 BAD_REQUEST
- Gates non-HTML callers with 400 UNSUPPORTED_FILE_TYPE
- Builds a fileReader closure that silently returns null on any sibling
read failure (failure-local, not fatal — matches the web client's
null-filter at FileViewer.tsx:5311)
- Hands the buffer + relPath to inlineRelativeAssets and returns the
result as text/html
DI: RegisterProjectExportRoutesDeps gains 'projectFiles' | 'validation';
server.ts:2879 passes the corresponding deps. Mirrors the dep shape of
RegisterFinalizeRoutesDeps used by PR #832's /finalize/anthropic.
Null-origin support intentionally omitted (decision §10 in the PR
description): the daemon's null-origin allowlist is /raw/* and
/codex-pets/.../spritesheet only, and export consumers are same-origin
UI or server-side tooling — sandboxed-iframe srcdoc previews fetch
/raw/* instead. Integration test #7 pins the 403 contract so a future
allowlist change is deliberate.
Phase C-G of plan declarative-roaming-gosling.md. All 23 tests green
(14 unit + 9 integration); full daemon suite 1727 passing (delta +9
over B-G's 1718). Typecheck clean.
Refs nexu-io/open-design#368.
* test(daemon): add Red regression for inlined-body tag-literal corruption
Reproduces the correctness bug Siri-Ray (looper) and codex-bot flagged
on PR #1312: the reduce/split-join approach in inlineRelativeAssets
re-scans the progressively mutated HTML, so a tag literal that happens
to appear inside an already-inlined asset body gets the inner literal
also replaced — corrupting the body and producing duplicate inlining.
Concrete reproducer (CSS, where </style escape doesn't touch <link>):
HTML: <link rel="stylesheet" href="a.css">
<link rel="stylesheet" href="b.css">
a.css: /* see also <link rel="stylesheet" href="b.css"> */
b.css: body{color:red}
Under split/join the second pass splits on `<link rel="stylesheet"
href="b.css">` and matches BOTH the real outer tag AND the literal
inside a.css's comment. Result: b.css's <style> block is injected
inside a.css's comment, and b.css gets inlined twice.
Phase F-R of plan declarative-roaming-gosling.md (post-PR-#1312
review round). F-G will rewrite the helper to collect matches by
position in the original HTML and concat slices in a single pass,
so already-inlined content is never re-scanned.
Refs nexu-io/open-design#1312 review threads at
apps/daemon/src/inline-assets.ts:122 (Siri-Ray looper + codex bot).
* feat(daemon): replace inliner reduce/split-join with position-based concat
Fixes the inlined-body tag-literal corruption Siri-Ray (looper) +
codex-bot flagged on PR #1312. The previous `replaceAllOccurrences`
(`source.split(from).join(to)`) re-scanned the progressively mutated
HTML on each pass, so a tag literal that appeared inside an already-
inlined CSS/JS body got the inner literal replaced too, producing
duplicate inlining and corrupted bodies.
New shape: collect every match's {start, end} byte span from the
ORIGINAL html via `matchAll`, await the per-match replacements in
parallel, sort by start, and concat slices of the original html with
the replacement strings in a single pass. Text introduced by an
earlier replacement is never scanned for matches.
The dup-tag fix (decision §8 — replace every occurrence, not
first-match-only) is preserved: every original-tag position gets its
own slice, so all duplicates are inlined.
Also extracts buildInlineStyleBlock / buildInlineScriptBlock so the
match-collection loops stay readable.
Phase F-G of plan declarative-roaming-gosling.md. Regression test
(c809bccc) goes Green; all 24 unit + integration tests pass; daemon
suite still clean.
Refs nexu-io/open-design#1312.
* test(daemon): add Red CSP-sandbox test + P3 coverage gaps from PR #1312 review
Three tests covering lefarcen's review on PR #1312:
1. [Red] CSP sandbox header (P2, lefarcen @ import-export-routes.ts:423).
Top-level browser navigation to /export/*?inline=1 sends no Origin
header, so the daemon middleware lets it through and any JS in the
exported document runs with daemon-origin privileges. Asserts the
response sends `Content-Security-Policy: sandbox allow-scripts` so
the browser treats it as a sandboxed iframe with an opaque origin
(scripts still run, but no cookies / no /api/ access). This test
fails until G1-G adds the header in the handler.
2. [Green-on-commit] Accept-list cases (P3, lefarcen @ test.ts:262).
PR body decision §7 promises `inline=true/yes/on` case-insensitive,
but round-1 tests only exercised inline=1. Pin the full accept list
(true / yes / on + TRUE / Yes / ON). Already passes — the route's
parser already implements the accept list; this just makes the
contract testable.
3. [Green-on-commit] isSafeId guard (P3, lefarcen @ test.ts:287).
Previous `..` test was normalized by Express before reaching the
route. New input uses `bad!id` (URL-safe, but outside isSafeId's
/^[A-Za-z0-9._-]+$/ char class), so Express passes it into
req.params unchanged and isSafeId rejects with the documented
400 BAD_REQUEST envelope.
Phase G1-R / H of plan declarative-roaming-gosling.md. Refs
nexu-io/open-design#1312 review comments.
* feat(daemon): send Content-Security-Policy: sandbox allow-scripts on /export
Closes the same-origin XSS surface lefarcen flagged on PR #1312 (P2
at import-export-routes.ts:423): top-level browser navigation to the
export URL sends no Origin header, so the daemon's /api middleware
admits the request and any JS in the exported document executes with
daemon-origin privileges (cookies, /api/, localStorage).
`Content-Security-Policy: sandbox allow-scripts` on the response
makes the browser treat the document as a sandboxed iframe with an
opaque origin. Scripts still execute (necessary for the screenshot
use case — the whole point of inlining JS), but they cannot read
cookies, hit /api/, or otherwise escalate to the daemon's origin.
Phase G1-G of plan declarative-roaming-gosling.md. Daemon delta: +3
tests (the Red CSP test from 58151356 turns Green; the P3 coverage
gap tests stay green).
Refs nexu-io/open-design#1312.
* test(daemon): add Red regression for <link> stylesheet attr preservation
Currently `<link rel="stylesheet" href="print.css" media="print">`
becomes a plain `<style data-od-inline-asset="print.css">…</style>`
with no media query — print-only styles apply unconditionally. Same
problem for `title` (alternate stylesheet sets), `disabled` (initial
disabled state), and `nonce` (CSP nonce). All four are valid on
both `<link rel=stylesheet>` and `<style>` per HTML spec, so the
inliner must carry them across.
PR #1312 round-2 review (lefarcen P2 @ inline-assets.ts:44). Phase
G2-R; G2-G will extend buildInlineStyleBlock to copy the four attrs
off the source <link>.
Refs nexu-io/open-design#1312.
* feat(daemon): preserve <link> stylesheet semantics on inlined <style>
Closes lefarcen's P2 review note on PR #1312 (inline-assets.ts:44):
`<link rel="stylesheet" href="print.css" media="print">` was becoming
a plain <style> with no media query, so print-only styles applied
unconditionally. Same issue for `title` (alternate stylesheet sets),
`disabled` (initial disabled state), and `nonce` (CSP nonce).
buildInlineStyleBlock now carries four attrs across from the source
<link>:
- media, title, nonce (value attrs, HTML-escaped via escapeHtmlAttr)
- disabled (boolean attr — copied as bare presence)
Other <link> attrs (rel, href, type, crossorigin, integrity,
referrerpolicy) don't apply to <style> and are intentionally dropped.
New `hasBooleanHtmlAttr` helper distinguishes presence-as-attr from
substring-inside-another-attr-value via a regex that requires a
word boundary after the name (whitespace, `=`, or `>`).
Phase G2-G of plan declarative-roaming-gosling.md. All 28 tests pass.
Refs nexu-io/open-design#1312.
* docs(daemon): narrow inliner contract claim + document size-limit policy
Closes lefarcen's P2 review notes on PR #1312:
1. "Self-contained" incomplete (inline-assets.ts:67): the helper
only rewrites top-level <link rel=stylesheet> / <script src>.
`<img src>`, CSS `url(...)`, CSS `@import`, ES module imports,
font sources, and similar remain external in the response. The
PR title/body claimed "self-contained HTML" which over-promised
for screenshot tooling expecting bundled images/fonts.
Module docstring now enumerates the full not-rewritten list and
names the screenshot path as the primary use case (headless
browser fetches each external asset on render, so inline-CSS-
and-JS-only is sufficient). The route handler comment block
mirrors the contract.
A fully offline export with image/font bundling is filed as a
follow-up — out of scope for this PR.
2. No response cap (inline-assets.ts:72): the helper does
concurrent reads + multiple string copies and could spike daemon
memory. The daemon is local-first (single-user, developer's
machine — see open_design_architecture.md), so the effective
ceiling is the size of the user's own project. The docstring now
states this rationale and names the conditions under which a
bounded-concurrency reader and output-size limit would be
needed (non-trusted callers).
Docs-only — no behavior change, all 28 tests still pass.
Refs nexu-io/open-design#1312.
* test(daemon): add Red regression for hasBooleanHtmlAttr quoted-value match
PR #1312 round-2 review (lefarcen P3): `hasBooleanHtmlAttr` tests the
tag string with no attr-quoting awareness, so the literal text
`disabled` appearing inside any quoted attribute value followed by
another whitespace char satisfies `\sdisabled(?=\s|=|/?>)`.
<link rel=stylesheet href=x.css data-note="content disabled stuff">
emits a <style disabled> block, silently disabling a stylesheet the
author wrote without that attr.
Also adds a counterweight test for the legitimate-disabled case
(<link … disabled>) so the next-commit fix doesn't over-correct
and start dropping real boolean attrs.
Phase I3-R of plan declarative-roaming-gosling.md (post-PR-#1312
round-2 review). I3-G will strip quoted attribute values from the
tag string before testing for the bare attr.
Refs nexu-io/open-design#1312.
* feat(daemon): make hasBooleanHtmlAttr quote-aware to avoid false positives
Closes lefarcen's P3 review note on PR #1312:
`hasBooleanHtmlAttr` previously ran `\sname(?=\s|=|/?>)` over the
full tag string, so the literal text `disabled` appearing inside any
quoted attribute value followed by whitespace satisfied the regex.
Source tags like `<link rel=stylesheet href=x.css
data-note="content disabled stuff">` were emitting a <style
disabled> block — silently disabling a stylesheet the author wrote
without that attr.
Fix: strip `="…"` and `='…'` substrings out of the tag with two
regex passes BEFORE testing for the bare attr. The lookahead still
requires `\s|=|/?>` after the attr name, so `<link disabled>`,
`<link disabled="">`, `<link disabled/>`, etc. all match — but the
attr name as a substring of any quoted value cannot match because
values have been stripped to `""` / `''`.
Phase I3-G of plan declarative-roaming-gosling.md. All 30 tests
green (28 prior + 2 round-3 regression cases: false-positive and
legitimate-disabled). Refs nexu-io/open-design#1312.
* test(daemon): add Red cap-enforcement tests + scaffold InlineOptions
PR #1312 round-2 review (lefarcen P2 — still open): round-2 only
documented that no cap is enforced. Reviewer pushed back: the helper
still builds unbounded candidate arrays + runs Promise.all over all
asset reads + concatenates the full output in memory. Need actual
limits in code.
This commit adds the Red test surface that drives the next commit's
enforcement:
- InlineAssetsLimitError("owner") when owner HTML > maxOwnerBytes
- InlineAssetsLimitError("candidates") when tag matches > maxCandidates
- Per-asset graceful: oversized asset → tag stays as URL ref
- InlineAssetsLimitError("total") when assembled output > maxTotalBytes
- Bounded read concurrency: peak in-flight reads ≤ maxReadConcurrency
- Integration: route maps the throw to 413 PAYLOAD_TOO_LARGE
InlineOptions interface is added to the helper signature as a no-op
test-door (per feedback_test_doors_over_fake_timers.md), so tests
can exercise tiny fixtures while production callers use module-level
defaults. The next commit (H3-G) wires the enforcement.
Phase H3-R of plan declarative-roaming-gosling.md. Daemon delta on
this commit: +6 tests (5 unit + 1 integration), all Red.
Refs nexu-io/open-design#1312.
* feat(daemon): enforce inliner caps + map limit errors to 413 PAYLOAD_TOO_LARGE
Closes lefarcen's still-open P2 review on PR #1312 round 2 ("the
code still builds unbounded candidate arrays + Promise.all over all
asset reads + concatenates the full output in memory"). Caps are now
enforced in code with the documented defaults:
MAX_INLINE_OWNER_BYTES = 2 MiB
MAX_INLINE_ASSET_BYTES = 5 MiB per sibling
MAX_INLINE_CANDIDATES = 500 link/script matches
MAX_INLINE_TOTAL_BYTES = 50 MiB assembled output
MAX_INLINE_READ_CONCURRENCY = 8 simultaneous fileReader calls
Enforcement points:
- Owner cap (input): fires immediately at function entry. Cheap —
Buffer.byteLength of the already-decoded UTF-8 string.
- Candidate cap (planning): fires after matchAll, BEFORE any sibling
read. Pathological HTML with thousands of <link>/<script src>
tags is rejected without opening a single file descriptor.
- Asset cap (per-sibling): post-read length check; oversized assets
return null from the wrapped reader, so the tag stays as a URL ref
and the response is still 200. This is the only "graceful" cap —
one bad asset doesn't fail the whole export.
- Total cap (output): tracked across the slice-and-concat loop,
guarding both preserved-html slices AND injected replacements.
- Concurrency cap (planning): a tiny in-module runWithConcurrency
worker-pool keeps at most maxReadConcurrency fileReader calls in
flight, with order-preserving results.
`InlineAssetsLimitError` carries a `limit` discriminator so logs and
clients can disambiguate owner/asset/candidates/total. The route
handler catches it and emits 413 PAYLOAD_TOO_LARGE.
Drive-by error-envelope fix while in the route: UNSUPPORTED_FILE_TYPE
(an unregistered ApiErrorCode) → UNSUPPORTED_MEDIA_TYPE (the
canonical code) with HTTP 415. The round-1 string was a slip;
caught by reading packages/contracts/src/errors.ts:11 while wiring
PAYLOAD_TOO_LARGE.
Phase H3-G of plan declarative-roaming-gosling.md. All 36 tests
green (28 prior + 2 round-3 quoted-attr + 5 cap unit + 1 cap
integration). Refs nexu-io/open-design#1312.
* feat(daemon): enforce inliner caps pre-buffer via AssetHandle contract
Closes lefarcen's still-open P2 review on PR #1312 round 3 ("the
helper enforces maxTotalBytes only after all candidate assets have
already been read and converted to replacement strings" /
"maxAssetBytes is checked after fileReader fully buffers each
sibling"). Round-3 caps were defensive against the final output
size but did not bound peak memory during read fanout — 500 assets
at 5 MiB each could materialize ~2.5 GiB before the 413 fired.
Contract change: InlineAssetReader now returns `AssetHandle | null`
where AssetHandle is `{ readonly size: number; read(): Promise<...> }`.
Callers expose `size` from a cheap stat-equivalent (the route uses
`resolveProjectFilePath`) and defer the full materialization to
`read()`. The helper checks size against maxAssetBytes BEFORE
invoking read, and against the running total BEFORE the reservation
is committed.
Enforcement flow inside runWithConcurrency:
1. await fileReader(p.resolved) → cheap stat-only call
2. if (handle.size > maxAssetBytes) return null ← pre-buffer
3. if (runningBytes + handle.size > maxTotalBytes) ← pre-buffer
totalAborted = true; return null
4. runningBytes += handle.size ← reserve
5. await handle.read() ← only now
6. if (read returned null) runningBytes -= refund
`totalAborted` is a shared flag the workers check at entry, so
once the running total hits the cap, no new reads start. With
maxReadConcurrency = 8, at most ~8 stat-side calls finish after
abort — peak memory bounded.
The concat-time guard stays as the exact final assertion (the
pre-buffer reservation is approximate — it counts the original tag
bytes and skips wrapper overhead).
Route closure updated to do `resolveProjectFilePath` first, then
`readProjectFile` inside the deferred `read()`. Test reader helpers
(`readerFrom` + the concurrency-test reader) updated to the new
shape.
Two new unit tests pin the pre-buffer semantics:
- `maxAssetBytes` is checked via handle.size BEFORE handle.read()
(the reader's `read()` throws — must never run)
- Running total abort stops further reads once exceeded (counting
reader observes ≤ 2 reads when cap should fire after the first)
Phase K of plan declarative-roaming-gosling.md (post-PR-#1312
round-3 review). All 38 tests green (36 prior + 2 round-4
pre-buffer cases).
Refs nexu-io/open-design#1312.
* test(daemon): add Red test pinning owner pre-buffer 413 before mime 415
PR #1312 round-5 (lefarcen P2): the route currently reads the owner
file with readProjectFile() before any size check, so a 100 MiB owner
HTML is fully buffered into memory before the helper's ownerBytes
check fires. The fix is to stat with resolveProjectFilePath first,
reject pre-buffer with 413 PAYLOAD_TOO_LARGE on oversize, then fold
in the mime check (still 415 on mismatch, now pre-buffer), then
readProjectFile when both gates pass.
The Red→Green discriminator is the combination 'oversize AND
non-HTML': pre-fix the route reads the buffer first and the
text/plain mime check fires → 415; post-fix the route stats first
and the size check fires before the mime check → 413. Asserting
'got 413, not 415' pins both the pre-buffer property and the check
ordering (size before mime, per lefarcen's locked round-5 sequence).
2 MiB+1 byte fixture is acceptable in test setup; MAX_INLINE_OWNER_BYTES
is the production 2 MiB so no test-door is needed.
Red verified: AssertionError: expected 415 to be 413 (pre-fix flow
reads → mime → 415).
* feat(daemon): stat owner before readProjectFile in /export route to bound owner pre-buffer
PR #1312 round-5 (lefarcen P2 confirmed at PR-1312#issuecomment-4424868413
follow-up): the route previously called readProjectFile() unconditionally
on the owner, so a 100 MiB owner HTML was fully buffered into memory
before the helper's ownerBytes check fired with InlineAssetsLimitError
('owner'). That meant the 413 envelope returned to the caller but only
after peak memory had already hit the file size.
Fix mirrors the sibling-asset stat-then-read contract round 4 added via
the AssetHandle interface: call resolveProjectFilePath first (cheap
stat), reject pre-buffer with 413 PAYLOAD_TOO_LARGE on size >
MAX_INLINE_OWNER_BYTES, fold in the mime check (still 415
UNSUPPORTED_MEDIA_TYPE on mismatch, now also pre-buffer per lefarcen's
'fold-in is welcome'), then readProjectFile() only when both gates
pass. Size check fires before mime check, so an oversize non-HTML file
returns 413 rather than 415 — the observable Red→Green discriminator
for this round.
The helper's ownerBytes check (inline-assets.ts:127-133) stays as
defense-in-depth for direct in-process callers that skip the route and
for any drift between stat-reported size and the bytes returned by
readFile.
Verifies the round-5 Red at apps/daemon/tests/export-inline-route.ts
('returns 413 (not 415) for an oversize non-HTML file'). Daemon suite
1743/1743 passing.
* test(daemon): add Red test pinning stat-vs-actual byte reconciliation
PR #1312 round-5 (lefarcen P3 confirmed at PR-1312#issuecomment-4424868413
follow-up): the helper trusts handle.size for the running-total guard
and never reconciles with the actual byte length of content unless the
per-asset cap is exceeded. A reader that under-reports size (stale
stat, UTF-8 expansion at decode, sparse file, deliberate lie) can let
many strings materialize in memory before the concat-time guard at
the bottom of inlineRelativeAssets throws — defeating the round-4
pre-buffer cap intent.
Fix is lefarcen-confirmed path-a: post-read, the helper computes
actualBytes = Buffer.byteLength(content, 'utf8'), reconciles
runningBytes (add actualBytes, refund handle.size), and if running
total exceeds maxTotalBytes flips totalAborted = true and returns
null. Subsequent workers see totalAborted before invoking their own
read(). Helper still throws InlineAssetsLimitError('total') after
Promise.all settles — preserving the round-2/3/4 graceful-fallback
pattern instead of racing throws across in-flight workers.
Red→Green discriminator is read count. Pre-fix the helper trusts
the lying handle.size (10), so both reads complete (each returning
1000 bytes) under the reservation total of 56+10+10=76 < cap 500.
The concat-time guard then catches the 2000+-byte assembly and
throws 'total' — but only after both reads materialized in memory.
Post-fix worker 1's reconciliation trips totalAborted as soon as
actualBytes (1000) is folded into runningBytes; worker 2 skips its
read.
Red verified: AssertionError expected 1, received 2 (pre-fix flow
completes both reads before concat-guard fires).
* feat(daemon): reconcile inliner reservation with post-read actual bytes
PR #1312 round-5 (lefarcen P3 confirmed at PR-1312#issuecomment-4424868413
follow-up, path-a): the helper trusted handle.size for the running-
total guard and only reconciled with actual bytes for the per-asset
cap. A reader that under-reported size — stale stat, UTF-8 decode
expansion at read time, sparse file, deliberate lie — could let
many strings materialize before the concat-time guard at the bottom
of inlineRelativeAssets caught the excess. That defeated the
round-4 pre-buffer cap intent.
Fix: after a successful read(), compute actualBytes =
Buffer.byteLength(content, 'utf8'), reconcile runningBytes by
folding in (actualBytes - handle.size), and re-check the total cap.
If the reconciliation pushes runningBytes past maxTotalBytes,
drop the asset's inlining (tag stays as URL ref), set
totalAborted = true to block subsequent worker reads, and let
Promise.all settle. The helper then throws
InlineAssetsLimitError('total') below — matching the round-2/3/4
graceful-fallback pattern (no throw-before-settle race between
in-flight workers).
The per-asset cap check at line 228 is preserved for stat-lying
readers that blow a single asset past maxAssetBytes; that branch
refunds handle.size and drops without flipping totalAborted, so
sibling assets still get a fair shot.
Verifies the round-5 Red at apps/daemon/tests/export-inline-route.ts
('reconciles handle.size with actual content bytes'). Daemon suite
1744/1744 passing.
---------
Co-authored-by: DevForgeAI CI/CD Engineer <devforge-ai@development.ai>
* fix: truncate long template names on project cards (#1220) (#1302)
Add min-width: 0 to .design-card-name so text-overflow: ellipsis
works correctly in flex layouts. Long template names were pushing
the task execution status (Running, Failed, etc.) out of view on
project cards.
Closes#1220
Co-authored-by: laomo <laomo@openclaw.ai>
* fix(desktop): swallow setTypeOfService EINVAL crashes in dev main (#647) (#1298)
* fix(desktop): swallow harmless setTypeOfService EINVAL crashes in dev main
The packaged Electron entry (apps/packaged/src/logging.ts) already
filters the undici "setTypeOfService EINVAL" crash that issue #895
introduced for the prod build, but the dev / source-built desktop
entry was missing the parallel guard. Result: switching settings
tabs in a from-source desktop run could fire a fresh fetch, undici
would try to set IP_TOS on the outbound socket, the kernel would
refuse on certain macOS / VPN configurations, and the rejection
bubbled to Electron's default handler as the "JavaScript error in
the main process" dialog reported in issue #647.
Add the same defensive filter to apps/desktop:
- isHarmlessSocketOptionError matches only the canonical undici
shape (syscall name AND EINVAL code). A contradicting code
(EACCES, EPERM, etc) explicitly fails the match so real bugs
don't get hidden.
- The uncaughtException handler logs harmless cases at warn and
returns silently. For anything else it removes itself from the
listener list and re-throws via setImmediate, restoring Node's
default crash path so Electron's native dialog renders exactly
as it would without this filter.
- unhandledRejection mirrors the same harmless / fall-through
split.
The filter is installed BEFORE app.whenReady so it is armed by the
time the renderer fires its first fetch.
The helper is duplicated rather than imported from apps/packaged
because AGENTS.md forbids cross-app private-source imports. The file
header calls out the parallel and notes that the two copies should
stay in sync until the helper is promoted to a shared workspace
package (follow-up); the contract is identical so a regression in
one will surface in the other's test suite.
Tests in apps/desktop/tests/main/uncaught-exception.test.ts mirror
apps/packaged/tests/logging.test.ts: 8 cases pinning the matcher
shape, 2 cases pinning the handler's harmless-log-warn vs
fall-through-rethrow split.
Validated: pnpm guard, pnpm --filter @open-design/desktop typecheck,
pnpm --filter @open-design/desktop build, and pnpm --filter
@open-design/desktop test (14 passed, 10 new).
* fix(desktop,packaged): fail-fast on non-harmless unhandled rejections
The previous unhandledRejection listeners logged non-harmless reasons
and returned, which kept the main process alive after any rejected
promise. A real bug, a failed IPC registration, or any unexpected
async exception was reduced to a console line instead of surfacing
through Node/Electron's default crash path the filter was meant to
preserve.
Both copies now route non-harmless rejections through a parallel
factory (createDesktopUnhandledRejectionHandler /
createFatalUnhandledRejectionHandler) that mirrors the
uncaughtException policy: harmless setTypeOfService EINVAL shapes log
at warn and return, anything else logs at error, removes the
listener, and re-throws via setImmediate. Listener removal happens
before the scheduled throw, so the rethrown reason lands in the
uncaughtException path with no recursion.
Tests cover the harmless branch, the detach + ordered rethrow, and
non-Error / primitive rejection reasons (Promise.reject(42)) which
must fall through. Desktop suite: 13/13, packaged suite: 16/16.
Flagged on PR #1298 by Siri-Ray and the codex P2 review thread; the
two file copies stay in lockstep per the AGENTS.md sync invariant.
---------
Co-authored-by: Nagendhra <nagendhra405@gmail.com>
* feature: refine assistant artifact feedback (#1379)
* feature: refine assistant artifact feedback
* fix: clear hidden custom feedback reason
* test: update assistant feedback expectations
* fix: support object-style question-form options (#1293)
* fix: support object-style question-form options
* fix: preserve stable option values in form submissions
* fix(daemon/acp): terminate ACP child after clean prompt completion (#1286)
* fix(daemon/acp): terminate ACP child after clean prompt completion (Bug B / #1265)
Some ACP agents (notably Devin for Terminal) keep the child process
alive after stdin closes, waiting for the next prompt. Open Design
spawns a fresh agent per chat turn and relies on child.on('close') to
finalize the run, so without an explicit signal-driven shutdown the
chat sits stuck in the 'working' state indefinitely.
Three small, targeted changes:
- apps/daemon/src/acp.ts: After a clean session/prompt response we
schedule a 500ms grace period and then SIGTERM the child. This
mirrors the pattern detectAcpModels() already uses after model
discovery. The grace period leaves well-behaved agents that exit on
stdin.end() unaffected.
- apps/daemon/src/acp.ts: New completedSuccessfully() method on the
session handle reports whether the prompt resolved without a fatal
error or abort, so the consumer can distinguish 'clean signal exit'
from 'genuine signal failure'.
- apps/daemon/src/server.ts: child.on('close') now treats a SIGTERM
exit as 'succeeded' when acpSession.completedSuccessfully() is true.
- apps/web/src/providers/daemon.ts: Trust the server's authoritative
endStatus; the signal/non-zero-code safety net no longer overrides
an explicit 'succeeded' status, so the chat doesn't surface a fake
'agent exited with signal SIGTERM' error after a clean ACP run.
Daemon tests cover the SIGTERM grace timer, clean early-exit (timer
cleared), and completedSuccessfully() abort/error states. Manual UI
test on plain main + this fix confirms Devin chats now return to ready
automatically after Done · ...
* fix(daemon/connectionTest): treat ACP clean SIGTERM as success
Codex review on #1286 caught that the new SIGTERM in attachAcpSession
breaks ACP connection tests for agents that don't shut down on
stdin.end() (the exact Devin behavior the patch targets).
attachAgentStreamHandlers() in connectionTest.ts now also respects
acpSession.completedSuccessfully(), mirroring the same check we apply
in server.ts. Without this, a clean prompt response followed by our
SIGTERM would set winner.signal === 'SIGTERM', flip exitedCleanly to
false, and the connection test would report 'agent_spawn_failed'
even when the agent had returned a healthy response.
Also widened the AgentSpawnHandle type so completedSuccessfully is
visible on the structural type used inside connectionTest.ts.
All 56 daemon tests still pass; typecheck + guard clean.
* fix(daemon/acp): narrow ACP success-on-signal override to forced-SIGTERM
Looper review on #1286 caught that the success predicate was broader
than the SIGTERM case it was meant to handle. `completedSuccessfully()`
flips to true as soon as the ACP `session/prompt` response is
processed, but it does not say why the child later closed. With the
broad predicate, an ACP agent that returned a prompt result and then
exited with code 1 (or was killed by SIGKILL/SIGSEGV) was still marked
'succeeded', regressing the existing close-status behavior for genuine
post-response process failures.
Scope the override to the exact forced-shutdown shape this PR
introduces:
code === null && signal === 'SIGTERM' && acpCleanCompletion
Applied to both `server.ts` (chat run finalization) and
`connectionTest.ts` (connection-test classification). Any other
post-response failure now falls through to 'failed' / 'agent_spawn_failed'
as before.
All 59 daemon tests still pass; typecheck + guard clean.
* fix(web/daemon): only bypass exit-code safety net on explicit server success
Looper review on #1286 caught that the previous web change trusted
`endStatus === 'succeeded'` absolutely, but `endStatus` can become
'succeeded' in two distinct ways:
1. The SSE end event explicitly carries `status: 'succeeded'`
(authoritative server declaration).
2. The end event omits or has an invalid `status` field and the
handler silently falls back to 'succeeded' as a local default.
Both produced `endStatus === 'succeeded'` in the existing code, so
the new safety-net bypass treated them identically. That regressed
backward compat: a compatible or older daemon emitting an end event
like `{code:1}` or `{code:null,signal:"SIGTERM"}` with no
`status` would suddenly skip the failure banner.
Track explicit success separately via `serverDeclaredSuccess`,
set true only when:
- The SSE end event has `status === 'succeeded'`, or
- The fallback `fetchChatRunStatus` REST path returns
`status === 'succeeded'` (which the existing `isChatRunStatus()`
guard already proves is explicit).
The safety net is now bypassed only on that explicit signal; the
local-fallback success path still reaches the exit-code/signal
check so real failures surface as before.
Adds three web-side regression tests in `apps/web/tests/providers/sse.test.ts`:
- Explicit `status: 'succeeded'` + SIGTERM → onDone called, no error
- End event with `{code:1}` and no `status` → onError surfaces
'agent exited with code 1' as before
- End event with `{code:null,signal:'SIGTERM'}` and no `status` →
onError surfaces 'agent exited with signal SIGTERM' as before
`pnpm guard` + daemon typecheck clean; 27/27 SSE tests pass (up
from 24).
* Fix Codex wrapper launch paths (#1395)
* test: add Memory and Routines coverage (#1400)
* test: align extended Playwright coverage with current UI behavior
* test: address extended suite review feedback
* test: fix Codex fallback config hydration in e2e
* test: add Memory and Routines coverage
* test: fix Memory and Routines component test typing
* test: include Memory and Routines e2e in extended suite
* refactor(settings): use tiled language picker instead of dropdown (#1406)
The Language section in Settings rendered a single-button dropdown
trigger that opened a floating menu. With one visible label and lots of
empty panel space, the layout misled users into thinking only one
language existed. Replace the dropdown trigger + portaled menu with an
inline tile grid that shows every locale at a glance and clicks
directly to switch.
Side effects of the new layout: the languageOpen / languageMenuRect
state, the dynamic placement effect, the resize-close effect, the
mousedown click-outside handler, and the languageRef are gone. The
global Escape handler no longer needs to guard against the menu being
open. CSS for .settings-language-picker, .settings-language-button,
.settings-language-menu, and .settings-language-option is replaced by
.settings-language-grid (auto-fill 180px minmax columns) +
.settings-language-tile.
Tests in SettingsDialog.execution.test.tsx that drove the dropdown
(click trigger → click menuitemradio → assert menu closed) are
rewritten to drive the tiles directly via the radio role.
Refs #1347
* fix(web): restore consistent app header layout
* fix(web): restore consistent app header layout
Generated-By: looper 0.7.2 (runner=fixer, agent=opencode)
* fix(web): restore consistent app header layout
Generated-By: looper 0.7.2 (runner=fixer, agent=opencode)
* fix(web): restore consistent app header layout
Generated-By: looper 0.7.2 (runner=fixer, agent=opencode)
* fix(web): hide project output chips in header
---------
Co-authored-by: Prantik Medhi <140103052+prantikmedhi@users.noreply.github.com>
Co-authored-by: 이용진 <90879448+Leesin0222@users.noreply.github.com>
Co-authored-by: Nicholas-Xiong <2482929840@qq.com>
Co-authored-by: Hesam <chngyzkhanwhsht@gmail.com>
Co-authored-by: Yuhao Chen <godcorn001@outlook.com>
Co-authored-by: chaoxiaoche <fanzhen910412@gmail.com>
Co-authored-by: chaoxiaoche <chaoxiaoche@192.168.10.16>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: eggward han <32223217+Eggwardhan@users.noreply.github.com>
Co-authored-by: @aaronjmars <61592645+aaronjmars@users.noreply.github.com>
Co-authored-by: Bryan <121247296+bankielewicz@users.noreply.github.com>
Co-authored-by: DevForgeAI CI/CD Engineer <devforge-ai@development.ai>
Co-authored-by: mrzhangkris <92247501+mrzhangkris@users.noreply.github.com>
Co-authored-by: laomo <laomo@openclaw.ai>
Co-authored-by: Nagendhra Madishetti <nagendhra.madishetti24@gmail.com>
Co-authored-by: Nagendhra <nagendhra405@gmail.com>
Co-authored-by: Mason <jinmeihong0201@gmail.com>
Co-authored-by: Yiang Yiyan <15089131836@163.com>
Co-authored-by: Rocky <101849785+MrRockySL@users.noreply.github.com>
Co-authored-by: nettee <nettee.liu@gmail.com>
Co-authored-by: shangxinyu1 <shangxinyu@refly.ai>
Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com>