Commit Graph
306 Commits
Author SHA1 Message Date
Yizuki_Ameandwyuc 283a3096aa fix(editor): share line bounds for alignment and dragging (#1631)
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-22 15:39:56 +08:00
LING DUANandwyuc 7be21f2d89 fix(chat): pin Pi routing and preserve provider errors (#1637)
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-22 15:18:54 +08:00
AbelWangYaBoandwyuc 7e81e44c36 fix(media): refuse redirects on adapter generation and poll calls (#1636)
#930 made the connectivity probes in the media adapters pass
`redirect: 'manual'`. The generation and poll calls in the same 14 files were
left following redirects. Those requests carry the provider credential and go
to a base URL that comes from provider settings a caller can supply, so a 3xx
would replay the credential at a host the caller chose — and the redirect
target can be an address the outbound guard already refused.

Every such call now passes `redirect: 'manual'` and rejects a 3xx through a
shared `assertNotRedirected` helper, which reports it as
"<provider>: Redirects are not allowed (HTTP <status>)" instead of letting the
generic failure path describe it as a provider error.

- 26 call sites across the image adapters (seedream, openai, qwen, grok,
  lemonade, minimax, nano-banana), the video adapters (seedance, kling, grok,
  happyhorse, minimax, veo) and ComfyUI's submit and image fetch.
- ComfyUI's `pollHistory` keeps its contract of handing the caller a retryable
  failure rather than aborting the generation: it logs the refusal and returns
  null.
- ComfyUI's same-origin workflow load is deliberately untouched — it reads the
  app's own public/ asset, carries no credential and is not provider-influenced.
- The two adapters added since #930 (OpenRouter image and video) already did
  this.

tests/media/adapter-redirects.test.ts covers one case per adapter family. Each
serves a 302 and asserts that the call rejects with the redirect message and
that every request carrying an init object asked fetch not to follow redirects;
each case fails if its adapter stops passing `redirect: 'manual'`.

The HappyHorse test asserted the exact request init, so it now includes the new
option.

AI-assisted commit

Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-22 13:00:02 +08:00
b0e481eaa5 fix: keep long Grok relay generations alive and inline image bytes (#1364)
Two independent Grok failures seen when the provider is reached through a relay
(a custom base URL) instead of api.x.ai directly:

- lib/ai/providers.ts: a long non-streaming chat completion was cut off by the
  relay with a 504 at its idle timeout (~5 min), because nothing is sent
  upstream until the model has the whole answer. Adding 'grok' to the existing
  streaming-compat path (OPENAI_COMPAT_USE_STREAMING_CHAT=true) keeps bytes
  flowing across the idle window; the SSE is buffered back into a normal JSON
  response for the caller.
  The path is for relays only. usesCustomOpenAIBaseUrl recognises OpenAI's
  origin alone, so Grok's own api.x.ai also read as "custom" and was forced
  onto the compat transport; the provider's native endpoint is now excluded.

- lib/media/adapters/grok-image-adapter.ts: response_format 'url' returns a
  link on the relay's CDN host (imgen.x.ai), which may be unreachable from the
  server's network. The generation then failed at the follow-up fetch through
  /api/proxy-media even though the image had been produced successfully.
  'b64_json' inlines the bytes and removes that second hop.
  Inline bytes declare no media type, so the adapter reports one on
  ImageGenerationResult and returns a typed data URL, which is the shape
  openrouter-image-adapter already uses. Consumers take the type from there:
  agent image persistence records it, the client's stored media row keeps the
  type its data URL states, and classroom-media-generation names the file with
  the matching extension. A JPEG is no longer stored, served or named as a PNG.

The process-wide undici timeout that previously accompanied these changes is
dropped: upstream #1404 now gives LLM calls their own undici headers/body
timeouts, which covers the same failure without raising the defaults globally.

Co-authored-by: ciclou1 <ciclou1@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-22 12:04:20 +08:00
LING DUAN 2a77a8a476 feat(chat): make Pi classroom runtime the default (#1628)
* feat(chat): make Pi classroom runtime the default

* chore(chat): align docs and E2E with Pi default
2026-09-22 11:41:13 +08:00
Yizuki_Ameandwyuc df16d7e322 fix(export): include active line geometry in bounds (#1626)
Share corrected line bounds across renderer and React editing paths, preserve double-elbow routing, and translate PPTX points into the shape bounding box.

Refs #674 and the prior implementation/review in #675. AI-assisted.

Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-21 22:22:35 +08:00
6dfeb62dd8 fix(media): emit keyframe images as data URLs [AI-assisted] (#1444)
* fix(media): emit keyframe images as data URLs

The local media extractor stored keyframe bytes as raw base64 in
`DocumentAsset.data`. Every other extractor emits a data URL, and the
document bundle forwards this field as `pdfImages[].src` to `storeImages`,
which decodes it with `decodeBase64DataUrl`. With no `data:` prefix the
comma split yields no payload, so `atob(undefined)` throws and course
generation fails with "Failed to store image bundle at image img_1".

`pdf-compat`'s `dataUrlMimeType` also derives the image asset mime from
this same field, so the declared `image/webp` was silently dropped too.

Emitting `data:${mime};base64,...` matches `mineru-parser`, which already
normalizes prefix-less base64 the same way.

* fix(media): decode keyframe data URLs

* fix(media): accept legacy and data-url assets

* fix(media): reject malformed or empty media asset data URLs

A string that starts with data: but does not parse as a data URL used to
fall through to the raw-base64 path, where Node's decoder skips
non-alphabet characters and yields garbage bytes. Throw instead, and
reject an empty base64 payload rather than storing a 0-byte image.

Correct the keyframe test comment: local keyframe assets are consumed by
material extraction, and the test needs ffmpeg so it does not run in CI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DYifP8wM4XJQ3Hc2qsF6zf

---------

Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 21:48:02 +08:00
Yizuki_Ameandwyuc 54d626bc61 fix(export): surface video render rejection reasons (#1414)
* fix(export): surface video render rejection reasons

* fix(export): keep render diagnostics out of user toasts

---------

Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-21 18:49:25 +08:00
LeoParkerOuandwyuc ec20ba345f refactor(classroom): share per-course session lifecycle (#1619)
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-20 21:14:20 +08:00
2aa7e3e12b fix(audio): play discussion lines through one reused media element (#1610)
Discussion narration created a new Audio element per line, so on mobile every
line after the first had its programmatic play() refused with NotAllowedError:
the dialogue went silent while the lesson kept advancing. This is the
discussion side of #1474, which #1477 fixed for the narration player.

One module-scoped element now serves every line, kept separate from the
narration element in AudioPlayer so neither can cut the other off. Because
element identity can no longer tell lines apart, the stale-event guard became a
per-line token, handlers are assigned rather than added (a listener would
accumulate once per line), and finish()/cleanup() release the line by removing
the source attribute and reloading instead of leaving the element pointed at
the page's own URL.

Co-authored-by: talkman <jaxgen@163.com>
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-20 19:23:00 +08:00
Huang Geyangandwyuc 67f568848a fix(server): warn when access-code protection is disabled (#1599)
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-20 19:14:38 +08:00
Easternandwyuc 33ff189a11 fix(persistence): correlate 5xx responses with request ids (#1601)
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-20 16:30:53 +08:00
d7b31aa5e7 fix(generation): add browser-safe package entry (#1609)
Co-authored-by: SY <sy@SYdeMacBook-Pro.local>
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-20 16:10:52 +08:00
Frank_zhuandwyuc 2540cbaff7 fix(chat): omit undefined spotlight dimOpacity in session persist (#1554) (#1596)
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-20 15:53:24 +08:00
Frank_zhuandwyuc f4294c44ff fix(i18n): use common.loading for SlideThumbnail (#1562) (#1595)
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-20 15:51:24 +08:00
puxiao d6f65a3d67 fix(tests): make media tests pass on Windows path separators (#1615) 2026-09-20 15:38:14 +08:00
LeoParkerOuandwyuc 19f3eff6ac fix(tts): derive Azure SSML locale from selected voice (#1566)
* fix(tts): derive Azure SSML locale from voice

* fix(tts): preserve Azure voice script locales

---------

Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-19 22:31:33 +08:00
6a8db813bb refactor(upload): derive the workbench material MIME policy from the shared format registry (#1590)
* refactor(upload): derive the workbench material MIME policy from the shared format registry

#1498 fixed the Kylin generic-Office-MIME failure on both upload paths but
left the workbench with its own extension→MIME table, alias map, and
generic-MIME set next to the document registry, and the two had already
drifted. The workbench policy now derives every MIME/extension fact from
lib/document/mime.ts (the single source of truth); only the accepted-format
list stays workbench policy — fixed and extractor-independent, unlike the
classic path's provider-scoped whitelist.

- Register csv and webm in DOCUMENT_FORMATS (accepted by no document
  provider, so classic-mode whitelists are unchanged) and add the
  audio/x-m4a alias to m4a.
- material-upload-policy.ts keeps its export names (route, session-store,
  and composer consumers unchanged) but resolves, whitelists, and builds
  its accept string from registry helpers.
- The workbench gate now also accepts the registry's curated aliases it
  previously missed: image/jpg, text/x-markdown, and audio/x-wav (stored
  canonically as audio/wav).

Closes #1589.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* test(workbench): pin the audio/mp3 alias closure in the material policy

Deriving the workbench gate from the shared registry normalization
(#1589) also accepts the browser-reported audio/mp3 alias the hand-rolled
alias map rejected — the same gap class as image/jpg and text/x-markdown,
so pin it alongside them and name it in the comment.

Co-Authored-By: Claude Code <noreply@anthropic.com>

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-19 21:49:56 +08:00
LING DUANandClaude Opus 5 f29bbc4daa feat: sample declared interactive state before classroom questions (#1508)
* feat: sample declared interactive state before classroom questions

* test: exercise generated publication example through the iframe reader

* chore(generation): bump package version for observation prompt contract

* fix: keep interactive state optional on insecure HTTP origins

* fix(playback): keep component picking and separate state from reference

A declared state interface replaced the component picker with a forced
whole-area `#experiment` reference, removing the per-component selection
and outline that `main` already ships. Sampling was also gated on the
reference selector, so the only way to obtain state was to give up the
selection.

Reference identity and area state are now independent request-scoped
evidence items:

- `handleToggleElementPick` always arms the picker again, so a scene that
  declares the interface keeps main's per-component selection, outline,
  and send-time clearing.
- `sampleInteractiveState` follows the current Scene instead of the draft
  reference, so an unreferenced follow-up still reports current facts and
  never re-creates or extends a reference.
- The Host carries area state with or without a component reference. The
  evidence header names both identities and refuses to present area facts
  as properties of the referenced component.
- `metadata` is absent when only area state travels, so no element
  identity and no Spotlight authorization can be derived from it, and the
  accepted-reference receipt stays driven by explicit references only.

Review follow-ups in the same change:

- Client sampling follows `NEXT_PUBLIC_COURSEWARE_REFERENCE_ENABLED`.
  An ungated packet turned an ordinary Pi question into a 400 while the
  reference feature was disabled.
- A Scene that declares the interface always receives an availability
  boundary, including when the browser produced no packet at all. It is
  reported as `not-sampled` rather than the previous `no-interface`,
  which was a false statement about an activity that does declare one.
  Courseware without the interface keeps its unreferenced behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(generation): register the observation snippet as a packaged asset

The interactive-observation snippet is referenced by all six widget
content templates but was never added to the packaged-asset manifest, so
the asset test and the golden scene prompt both failed.

- `SNIPPET_IDS` now lists `interactive-observation`, restoring both the
  "exactly the generation-owned templates and referenced snippets" check
  and the "every referenced snippet is packaged" cross-check.
- The interactive system-prompt snapshot is re-pinned. The change is
  purely additive: the snippet is appended to the simulation template.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(pi): stop injecting state constraints while the feature is disabled

The route rejects a request that carries a reference or a state packet
while `NEXT_PUBLIC_COURSEWARE_REFERENCE_ENABLED` is off, but an ordinary
question carries neither. It still reached the Host, and a Scene that
declares the state interface then received the full page-state block —
several kilobytes of constraints about evidence the deployment can never
sample.

The Host now returns before building that note when the feature is off.
A route-level regression asserts that neither the Director prompt nor the
Child prompt gains `PAGE-REPORTED STATE` in that configuration; disabling
the guard makes it fail with exactly that symptom.

Also reopens the composer before the unreferenced follow-up in the
classroom browser spec. An accepted answer may close it, which made the
assertion flaky without changing the behaviour under test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(pi): decouple reference Scene from state freshness and bound assembled evidence

Cross-review found two defects in the request-scoped state evidence.

The reference's Scene was folded into the sample's staleness test. The packet
is already bound to the current Scene by the identity check above it, so a
valid current-Scene sample was being discarded as `stale-sample` purely because
the student's component reference came from an earlier Scene. Reference and
area state are independent evidence items; freshness is a property of the
sample alone. With the coupling gone the two can now disagree on Scene, so the
note says so explicitly rather than letting the model attribute area facts to a
component that may not be on the current Scene.

The assembled evidence had no stated output budget. The static component packet
is bounded to 24,000 code points upstream, but that bound covers the static
packet alone; the note and the escaped observation JSON were appended without a
recheck. Escaping `<` for the prompt expands one code point into six, and `<` is
legal in a label or a fact value, so a packet the Host accepts could assemble to
149,385 code points. The budget is now declared as the static bound plus the room
the note frame needs, which is what makes the degradation terminate. Over budget,
the state body drops whole to an explicit `unavailable` statement: truncating the
JSON would emit a broken packet, and thinning a `complete` relation set would turn
an exhaustive set into a false one. The relationship summary degrades with it, so
the prose never asserts COMPLETE over a body that is gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(pi): keep slide references independent of activity state

* refactor(interactive): simplify declared state and unify iframe preparation

Accept any JSON report within byte and depth budgets, without generated field
requirements or relationship-completeness semantics. Keep publishState and an
optional rendered result in the generation guidance.

Prepare the observation responder through patchHtmlForIframe and let the pool
own document identity, preserving state across placeholder remounts. Settle
sampling failures locally and align browser/server nesting limits.

Cover permissive JSON delivery, resource limits, lifecycle, legacy behavior,
and real renderer remounts with focused regression tests.

* fix(generation): publish automatic activity changes with clear positions

* fix(interactive): report missing legacy scope as no interface

* fix(interactive): guard sampling capabilities and bind scopes lazily

* chore(generation): bump version after main integration

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 18:17:30 +08:00
d51f4b8350 fix(audio): rebuild platform FormData bodies for the undici transport (#1580)
#1514 moved every lib/audio provider call to the npm undici package's
fetch so the pinned dispatcher is guaranteed to be honored. But the
adapters kept building multipart bodies with the platform-global
FormData — a class of Node's bundled undici — and undici's serializer
brand-checks a FormData body against its own class. A foreign FormData
fell through to the string branch and left the process as
`content-type: text/plain;charset=UTF-8` with the 17-byte literal
`[object FormData]` as the whole body: the audio bytes and every field
(including `model`) were dropped, downstream gateways fell back to
whisper-1 and answered 503, and every multipart audio request (ASR,
TTS FormData paths, voice registration/cloning) failed with a generic
internal error.

Bare Blob/File, string/JSON/Buffer and stream bodies were never
affected: undici 7.29.0 exports no File/Blob classes of its own and
its bare-body brand checks (and multipart part handling) bind the
platform classes.

Normalize the body in the transport, once, before either transport
path serializes it, so the adapters can keep the platform globals as
their public API boundary: a foreign FormData is re-created as
undici's own with every entry carried over verbatim — `append` (not
`set`) so repeated field names survive, and no filename argument so a
platform File part keeps its own name/type/lastModified. Everything
else passes through untouched.

Also drive real loopback regression tests with a platform-global
FormData (direct, with repeated field names, empty, and across a 307
redirect hop whose per-hop loop re-issues the normalized body) and a
bare Blob, asserting multipart on the wire instead of
`[object FormData]`, and update the voxcpm unit test that asserted
the buggy contract (a platform FormData at the undici boundary).

Fixes #1579

Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-18 17:39:02 +08:00
DDLandwyuc 97cf12eccd fix(playback): queue widget messages until iframe ready (#1532)
* fix(playback): queue widget messages until iframe ready

* fix(playback): preserve iframe queue in StrictMode

---------

Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-18 16:09:37 +08:00
XHaoandwyuc e14c3a1d12 fix(api): validate pdfContent input (#1578)
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-18 12:36:29 +08:00
f1b34e3abb fix(audio): reuse one narration element so mobile playback survives the first segment (#1477)
On mobile browsers classroom narration plays the first segment and then goes
silent for every following one, while the lesson keeps advancing: the player
created a new HTMLAudioElement per line, and only the first line is covered by
the user's gesture, so every programmatic play() after it is refused with
NotAllowedError and the engine falls back to its reading-time timer. #651/#652
fixed the blob leak from that rejection, not the missing voice.

Keep one element per player instead:

- getAudioElement() creates it on first use and every line reuses it, so the
  element the first gesture activated stays playable for the rest of the lesson
- stopAudioElement() releases the line's state rather than the element: onended
  cleared, src removed, load() called -- a stopped line must not keep reporting
  speech it is no longer playing, nor retain narration bytes through a revoked
  object URL until the next play()
- onended is assigned rather than added: the element now outlives a single line,
  so a listener would accumulate once per segment and call the engine back
  several times for one line

tests/audio/audio-player-element-reuse.test.ts stubs the mobile policy itself
(the first element plays, every element created after it is refused): the reuse
tests fail on main and pass with this change.

Verified on a live deployment at the fixed entry point (instrumented Chromium,
default autoplay policy, one real click on Play): a single element for 8+
consecutive lines, no refused play, currentTime advancing line by line.

Fixes #1474

Co-authored-by: Shaoxuhua <jaxgen@163.com>
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-18 12:21:46 +08:00
a8726ec4f3 feat(media): add OpenRouter image and video providers (#1356)
* feat(media): add OpenRouter image and video providers

OpenMAIC ships six separate video providers (Veo, Kling, Seedance,
MiniMax, Grok, HappyHorse) and seven image providers, each needing its
own key. OpenRouter fronts those same model families behind one key and
one account, so this adds it as a provider on both sides.

Both use OpenRouter's dedicated media endpoints, not chat-completions:

- Image: POST /images -> { data: [{ b64_json }] }
- Video: POST /videos -> 202 { id, status }, poll GET /videos/{id},
  then GET /videos/{id}/content for the mp4 bytes

The model list is fetched live from GET /images/models and
GET /videos/models through /api/openrouter-models rather than pinned in
the registry: OpenRouter hosts 48 image and 28 video models today and
adds more, so a hardcoded shortlist would decide for the operator which
models exist. The registry keeps a three-entry seed as an offline
fallback, and the existing custom-model UI still accepts any model id.
Both catalogs answer unauthenticated, so the picker fills before a key
is pasted; a key is forwarded when present for proxied base URLs.

Adapter contracts are covered by stubbed-fetch tests (request shape,
empty-response handling, and the video job state machine including
terminal failure). No test performs a billable call.

Closes #1355

* fix(media): validate the key and tolerate a pasted endpoint URL

Three fixes found while configuring the new provider:

1. Both connectivity probes hit the model catalogs, which answer 200
   unauthenticated — so "Test Connection" reported success for any
   string, including an invalid key. Probe GET /key instead: equally
   cheap, and it actually rejects a bad key.

2. The settings field is labelled "Base URL" but the panel echoes it
   back as "Request URL", so pasting the full endpoint
   (https://openrouter.ai/api/v1/images) is the natural mistake. That
   built /api/v1/images/images and 404'd. Trim a trailing slash and a
   trailing /images or /videos so both forms work; a proxy path that
   merely contains the word is left alone.

3. The image and video settings panels read `data.message` on a failed
   test, but failures answer with `error` (apiError) and only successes
   carry `message`. Every failing connectivity test — for any provider,
   not just OpenRouter — rendered "connection failed: undefined" instead
   of the reason. Pre-existing; surfaced by 1 and 2 above.

Closes #1355

* fix(media): make every OpenRouter model selectable, and always select a provider

Two gaps found while configuring the new provider.

The settings Models list is a read-only catalog for every provider; the
actual model picker is the media popover. That picker built its groups
from the static registry array, so OpenRouter offered only the
three-entry seed while settings listed the full live catalog — the
models were visible but not choosable. Feed the same live catalog into
the popover, fetched only once the provider is usable so an
unconfigured install makes no request.

Separately, `imageProviderId`/`videoProviderId` are empty until a
provider is chosen (first-run auto-config leaves them blank when the
server reports no media provider). Opening the settings panel on an
empty id selected nothing: the header rendered the missing name key as
"settings.undefined", and Test Connection posted a blank
x-image-provider/x-video-provider, so it failed with "No image/video
provider configured" whatever key was typed. Fall back to the first
catalog entry so the panel always has a selection. Pre-existing and not
specific to OpenRouter.

Closes #1355

* fix(tts): request a browser-playable format from custom providers

`generateOpenAITTS` serves every custom OpenAI-compatible TTS provider but
never sent `response_format`, so it inherited whatever each provider
defaults to. OpenAI defaults to mp3; OpenRouter's /audio/speech defaults
to raw `pcm`. The unknown content type then fell through to the `'mp3'`
default below, the client built `data:audio/mp3;base64,…` from headerless
PCM samples, and playback failed with "no supported source was found" —
while the server logged a clean 200, because the audio really was
generated. Name the format instead of inheriting it.

Also stop mislabelling an unrecognised body: `pcm`/`l16` now raises a
message naming the cause, and `aac`/`opus` are recognised.

Two supporting fixes:

- /api/openrouter-models normalises its base URL the way the adapters do
  and falls back to the public catalog when a custom base URL fails, so a
  typo in a free-text settings field cannot empty the model picker. Also
  types the headers object so tsc accepts the conditional.
- provider-neutrality-guard pins exact per-vendor occurrence counts in
  lib/server/provider-config.ts. Adding the image and video env entries
  raises "openrouter" from 2 to 6 (each entry contributes both its key and
  its value); CI failed without the bump.

Closes #1355

* fix(security): never send the operator key to a client-chosen host

Review found `/api/openrouter-models` was an SSRF and key-exfiltration
path, and the finding is correct. The route took `x-base-url` from the
caller at highest precedence while preferring the *server* env key, so any
caller could make the server send the operator's OpenRouter credential as
an `Authorization: Bearer` header to an arbitrary URL. The route's own
comment claimed it followed `/api/verify-image-provider`; that pattern
runs `validateUrlForSSRF` on client base URLs, and this route did not.

The boundary is now explicit: the server key travels only to the
operator's own base URL. A client-supplied URL is SSRF-validated and
carries only that caller's own `x-api-key` — the server key is dropped —
and the unauthenticated public-catalog fallback never forwards a
credential chosen for a different host. Redirects are no longer followed
(`redirect: 'manual'`), since a redirect would carry the Authorization
header off-host and reopen the same hole, and upstream reads are bounded
by a timeout.

The per-URL cache is now keyed by destination *and* a hash of the
credential, and bounded to 64 entries with oldest-first eviction, so
client-supplied URLs cannot grow it without limit and one caller's
key-authorised catalog is never served to another.

Also from the review:

- The image adapter discarded the reported `media_type`. The
  orchestration layer wraps a bare `base64` as `data:image/png`
  unconditionally, so jpeg/webp results were mislabelled; the adapter now
  returns a data URL carrying the real type.
- Adapter generation and poll requests set `redirect: 'manual'`, matching
  the `/key` probe that already did.
- `runPolledTask` accepts an `AbortSignal` so the sleep between polls is
  cancellable; the video adapter passes the caller's signal. Without it a
  cancelled generation still slept out a full 10s interval.

Tests cover the highest-risk paths the review named: which credential
reaches which URL, that an SSRF-rejected destination is never contacted,
that the fallback is unauthenticated, cache isolation between callers,
and MIME preservation.

Findings 2 (base-URL normalisation) and 4 (neutrality-guard debt) were
already fixed in d553a08, pushed after the review was submitted; CI is
green on that commit.

Closes #1355

* ci: retry flaky voice clone timeout

* fix(vercel): keep OpenRouter catalogs within Hobby function limit

* fix(vercel): avoid tracing self-hosted sharp binaries

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-18 00:01:48 +08:00
83d692a6ce fix(import): persist ZIP media in the server asset pool (#1520)
Upload imported media before publishing document references and retain browser-only behavior. Add cache-failure, upload-failure and independent HTTP reader regressions; document the export/import migration path.

Validation: 256 focused tests passed; tsc, full Prettier check and ESLint passed (18 existing warnings in unchanged files). Full browser playback and production build remain unverified.

Co-authored-by: qianbs <ninesheng99@163.com>
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-17 23:58:09 +08:00
Nguyen Quang Thiepandwyuc 73ea6a72ce feat: auto-detect Vietnamese for browser-native TTS narration (#1487)
Browser TTS auto-detection only distinguished Chinese (CJK ratio) from
everything else (en-US), so Vietnamese narration was spoken by an English
voice. Add Vietnamese detection to the shared language helper and bind an
installed vi voice in the playback engine when the user has not picked one
explicitly:

- lib/audio/browser-tts-preview.ts: new detectSpeechLang() (zh-CN /
  vi-VN / en-US), used by both the Test TTS preview and playback. A hit on
  đ/ơ/ư or the U+1EA0-U+1EF9 precomposed block (ớ, ừ, ồ, ế, …) — absent
  from French/Romanian Latin — marks Vietnamese; bare ă/â/ê/ô only count
  toward a low ratio.
- lib/playback/engine.ts: use the shared helper; when it reports vi-VN,
  prefer an installed vi voice so pronunciation is correct out of the box.
  An explicitly configured voice still wins.
- tests/audio/detect-speech-lang.test.ts: zh/vi/en/fr cases.

Verified end-to-end (Playwright WebKit): utterances carry lang vi-VN with
an installed Vietnamese voice auto-selected, advancing through every line.

Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-17 23:57:59 +08:00
8c22e27a29 fix(generation): tolerate non-array mediaGenerations in generated outlines (#1469)
* fix(generation): tolerate non-array mediaGenerations in generated outlines

LLM-generated outline JSON can carry mediaGenerations as a string or
object instead of an array. Every downstream consumer (media
orchestrator, video manifest, scene generator) requires an array, and
uniquifyMediaElementIds called .map() after only a falsy check, so one
malformed outline aborted course generation with
"outline.mediaGenerations.map is not a function".

- sanitize at parse time: drop non-array mediaGenerations from each
  enriched outline (outline-generator)
- harden uniquifyMediaElementIds: treat non-array values as absent,
  strip them, and open the early-return guard on field presence so
  all-malformed outlines still get sanitized
- add unit tests covering array, string, object, number, and missing
  shapes

* chore(generation): bump package version to 0.3.8

Required by the package version bump check: the PR changes the
publishable @openmaic/generation package sources.

* test(generation): use correct SceneOutline fixture fields in outline media tests

The first commit of this branch accidentally staged an earlier draft of
the test file (sceneType instead of type, stale id regex). Re-stage the
final version that passes tsc and the full suite locally.

---------

Co-authored-by: PassCode023 <269712126+PassCode023@users.noreply.github.com>
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-17 23:29:05 +08:00
LeoParkerOuandwyuc 784f2a9f95 fix: bound model discovery response body timeout (#1478)
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-17 23:07:23 +08:00
xuyuanwei678 f7b8769e7f fix(importer, renderer): preserve PPTX text, chart, and image styling (#1534)
* fix: preserve PPTX text, chart, and image styling

* fix(importer): preserve hyperlink semantics and remove inferred alignment

* test(charts): replace untyped assertions to satisfy CI

* fix(importer): honor source-linked chart axis number formats

* fix(renderer): preserve picture bar clustering and clipping

* fix(pptx): address chart scales and soft-edge review findings

* fix(pptx): preserve percent-stack labels and inherited text sizes

* fix(pptx): synchronize agent chart schema and axis defaults
2026-09-17 21:41:12 +08:00
wyucandClaude Opus 5 2cbd011c1f feat(token-plan): add TokenDance one-key preset for every modality (#1525)
* feat(token-plan): add TokenDance one-key preset for every modality

TokenDance is a model gateway: chat and images are OpenAI-compatible at
/gateway/v1, and the same key authenticates vendor-protocol routes on the
same host (Ark, MiniMax, Bocha). The preset reuses the existing adapters
with those route prefixes as base URLs, so one key lights up LLM, image,
video, TTS and web search from Settings -> Token Plan.

- providers: add a built-in `tokendance` OpenAI-compatible provider
  (TOKENDANCE_* env prefix, logo, provider name in all locales)
- token-plan: add the TokenDance preset (Seedream image, MiniMax H3 video,
  MiniMax speech TTS, Bocha web search)
- seedream: use a base URL that already ends in a version segment verbatim,
  so gateway routes like `/ark/v3` do not get `/api/v3` appended
- minimax-video: route H3-family models through the v2 task API (content
  array submit, task-envelope poll); connectivity checks for H3 probe auth
  on the v2 query route instead of submitting a billable task
- README: add a one-key quick example and replace the Gemini-specific
  model recommendation with a provider-agnostic setup recommendation

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qrrq9CPwb718mpouz8Y2KL

* fix(token-plan): accept preset web-search base URLs and report H3 dimensions per ratio

- web-search: the client base URL allowlist also accepts the exact base URL
  a built-in token plan preset writes for that provider, derived from
  TOKEN_PLAN_PRESETS. Applying a plan whose web-search route is not an
  official vendor host previously stored a URL that the route rejected with
  400. Any other client URL is still rejected.
- minimax-video: report H3 v2 clip dimensions for 16:9, 9:16, 4:3 and 1:1
  instead of assuming landscape for every non-portrait ratio.
- tests: pin the allowlist for every preset, the 1:1 H3 dimensions, and
  clear TOKENDANCE_* in the provider-config env isolation list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qrrq9CPwb718mpouz8Y2KL

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 13:28:09 +08:00
wyuc b5605ef979 feat(agent-runtime): workbench generate_image / generate_video write through the asset pool (#1007 part 6) (#1524)
The workbench tools store generated bytes in the asset pool under the shared principal and write the allocated id into the document, the same discipline as the classic chain since #1392; the runner's putScene creates the reference rows and commits the allocations. The video completion patch rewrites every placeholder slot and retires anything that would shadow the new id; immediate render is preserved by leasing the id at the render boundary. A store-full refusal fails the tool with a model-readable error and writes nothing. Legacy /api/classroom-media documents keep rendering. Closes #1522.
2026-09-16 11:57:54 +08:00
DDLandwyuc b735609e14 fix(playback): stop superseded scene engines (#1510)
Detach and stop the active playback engine before asynchronous scene teardown so switching to a non-playable scene cannot leave narration, timers, or effects running.\n\nRecheck engine ownership after lecture-session creation in manual and auto-play paths, close stale sessions, and reject progress callbacks from detached engines. Add regression coverage for all three races.

Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-16 06:02:27 +08:00
杨慎andwyuc 4a8219af4e fix(audio): resolve CDN-backed narration consistently (#1521)
* fix(audio): resolve CDN-backed narration consistently (#1515)

* fix(audio): keep export fallback resolution consistent

---------

Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-15 22:03:29 +08:00
wyuc 1d3f62d80b refactor(media): one client-side pool commit primitive; keep refused narration instead of re-billing it (#1523)
Extracts commitToPool, the single client-side sequence for storing bytes in the asset pool, writing the allocated id back, and mirroring locally; routes the media pass, narration adoption and fresh TTS through it. A store-full refusal during TTS now retains the already-billed clip so the next load adopts it with zero provider calls. Closes #1467.
2026-09-15 21:27:38 +08:00
wyucandClaude Opus 4.8 87c4524b4d fix(audio): validate redirects and pin connections on provider requests (#1514)
Audio provider requests (TTS, ASR, voice registration and voice cloning)
validated a client-supplied base URL once and then issued a plain fetch with
default redirect-follow and no pinned dispatcher. A base URL that resolved to a
public address but answered with a redirect to an internal one was followed, and
a DNS answer that changed between the guard's lookup and the connect reached an
internal host — both readable in-band.

- Route every lib/audio provider request through a new
  lib/server/audio-provider-fetch.ts that combines per-hop redirect
  re-validation with a pinned undici dispatcher, so the socket can only reach an
  address the guard validated, on every hop.
- Select the public-vs-local policy server-side from isServerConfiguredProvider;
  a client-supplied base URL is always strict public and can never reach a
  private, loopback or cloud-metadata address, even with ALLOW_LOCAL_NETWORKS
  set.
- Pin the result-audio download hop as well, keeping its host allowlist and
  redirect:'error'.
- Add a coverage-matrix test that fails if any lib/audio module regains a raw
  provider fetch.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-15 17:47:30 +08:00
wyucandClaude Opus 4.8 ee79762ef9 fix(access-code): expire tokens server-side and throttle verification behind a trusted proxy (#1513)
When ACCESS_CODE is set, verification tokens (timestamp.HMAC) never expired
because neither verifier checked the timestamp, and POST /api/access-code/verify
had no attempt throttling.

- Enforce a 7-day token lifetime in a shared, Edge-safe module used by both the
  Node verifier and the middleware Web-Crypto verifier, and reject non-canonical
  signatures in both.
- Rate-limit verification only when the client identity is trusted
  (TRUST_PROXY_HEADERS=true): a per-client sliding window (10 failures / 60s)
  whose attempt is reserved atomically at check time, returning 429 with
  Retry-After. Without a trusted proxy the app cannot attribute requests to a
  client, so no shared throttle is applied — a long random ACCESS_CODE is the
  protection, and a warning is logged when it is short.
- Store bounded, copied identity keys so a large forwarding header cannot retain
  memory.
- Document the behavior in .env.example, README, and configuration docs.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-15 17:47:14 +08:00
DDLandwyuc 0b2e502811 fix(classroom): keep transient load failures off the not-found card (#1485)
* fix(classroom): keep transient load failures off the not-found card

/api/classroom 5xx and network errors used to become null, so ClassroomSurface treated them like a missing course and rendered the terminal not-found claim with no retry. Classify fetch answers as found/absent/unavailable and only show not-found on a positive 404/410 miss.

Fixes #1450

* fix(classroom): handle terminal load states

---------

Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-15 16:25:21 +08:00
DDLandwyuc 1271cba69e fix(tts): treat Add-dialog default URL as a configured credential path (#1482)
addCustomTTSProvider stores the dialog Base URL in customDefaultBaseUrl and leaves baseUrl empty. isTTSProviderConfigured ignored that field, so generation silently skipped narration even after Test TTS succeeded. Accept the dialog URL so already-saved custom providers start working without retyping the field.

Fixes #1471

Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-15 16:20:36 +08:00
Zhenghan Songandwyuc 1da720377c test(security): isolate image URL guard environment (#1476)
Why:
- Keep the image URL-guard regression deterministic when contributors export generic OpenAI credentials.
- Prevent this test file from deleting inherited environment values for later tests in the same worker.

What:
- Stub image-provider and generic OpenAI fallback variables as absent with restorable Vitest environment stubs.
- Restore environment stubs after each case.
- Assert the allowed-local-network case reaches generation with the client API key.

Risk:
- Test-only change; production provider selection and SSRF behavior are unchanged.

Tests:
- 120 focused server security and provider tests with OPENAI_API_KEY=openmaic-test-sentinel
- pnpm test with OPENAI_API_KEY=openmaic-test-sentinel (7894 passed, 81 skipped)
- pnpm check; pnpm lint; tsc --noEmit; pnpm check:i18n-keys
- pnpm build
- package version, internal dependency range, and Node engine checks

Live Docs:
- Not applicable; test isolation defect tracked in #1475.

Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-15 16:19:50 +08:00
wyuc c605e9e6ef feat(persistence): turn on the server-owned asset lifecycle and release assets on course deletion (#1007 amendment, part 2) (#1473)
App wiring for the @openmaic/storage 0.31.0 lifecycle: reference tracking and document references are paired unconditionally and declared at startup, course deletion withdraws references inside the tombstone transaction, ASSET_PENDING_TTL_MS configures the pending window, and the dead client-side reclamation code is removed.
2026-09-15 09:55:26 +02:00
wyuc ee7a7b64df fix(agent-runtime): allow one owner material to be bound to multiple sessions (#1500)
* fix(agent-runtime): allow one owner material to be bound to multiple sessions

`agent_session_materials.id` is a global primary key, but
`bindOwnerMaterialsToSession` de-duplicated on `(sessionId, id)` and then
inserted the owner-side material id as the row id. Binding the same owner
material to a second session therefore raised a duplicate-key error
(23505) that the route surfaced as HTTP 500, so an owner material could
only ever be used by one course.

Keep row ids globally unique (every extraction method keys on `id` alone)
and store the shared owner id as metadata instead: the binder now mints a
fresh session row id, records `owner_material_id`, and looks that column
up for idempotent rebinding; a partial unique index on
`(session_id, owner_material_id)` adjudicates concurrent rebinds and the
loser adopts the winner's row. The schema change is additive and
idempotent (`ADD COLUMN IF NOT EXISTS` + `CREATE UNIQUE INDEX IF NOT
EXISTS`), so existing databases upgrade in place with `NULL` for legacy
rows. No FK from `owner_material_id` to the owner library, to keep
owner-library deletion decoupled from session rows.

Tests: backend-neutral contract test (same owner material bound to two
sessions, both readable), PGlite in-place upgrade test, pinned-schema
update, and a host-level test covering the route's 202 path.

* fix(agent-runtime): reuse legacy owner-material bindings and clean up losing uploads on concurrent bind

Rows written by the previous binder use the owner upload id as the session
row id and leave `owner_material_id` NULL, so the new owner-id lookup missed
them and a rebind created a duplicate row and copied the bytes again. The
binder now falls back to the session row id and adopts it only when it is
unambiguously that owner upload: source kind, matching title, no source URL
or derivative, no text, and the deterministic legacy object key with a byte
length that matches the owner record. Adoption stamps `owner_material_id`
through a conditional `backfillOwnerMaterialId` update that leaves extraction
columns untouched; a lost race re-reads the fast path. A different session is
still a fresh row.

The concurrent-bind loser also left its uploaded object behind after
adopting the winner. It now removes that object when the winner references a
different key, best-effort so a failed cleanup cannot fail a bind that
succeeded.

Tests: host-level PGlite regressions for the legacy rebind (id, row count,
byte copies, extraction state, and backfill) plus a different-session bind,
and a controllable byte store that parks the loser after its upload so the
winner commits first, asserting one row and only the winner's object remain.
A storage unit test pins the backfill contract. Bumps @openmaic/storage to
0.30.1 for the new public store method.
2026-09-14 18:43:31 +02:00
wyuc cfae106b89 fix(storage): keep jsonb writes valid when model output contains NUL or lone surrogates (#1499)
* fix(storage): keep jsonb writes valid when model output contains NUL or lone surrogates

PostgreSQL jsonb rejects the `\u0000` and lone UTF-16 surrogate escape
sequences that JSON.stringify emits verbatim, failing the enclosing
statement with SQLSTATE 22P05/22P02. In the agent-session store the failed
tree-entry/event write is treated as critical, so the whole run aborts and
all work in that run is lost.

Add a shared encodeJson at @openmaic/storage/src/pg-json.ts that replaces
U+0000 and unpaired surrogates with U+FFFD while preserving valid surrogate
pairs (emoji), sanitizing in-memory values and object keys before
JSON.stringify. Route every jsonb parameter through it: the agent-session,
document, runtime, asset, and material backends, plus owner-material
registration in lib/persistence.

The document/runtime/asset backends keep their existing assertJsonValue
guard, which rejects these code points with a readable error before the
shared serializer runs; the agent-session and material paths had no guard
and were the live 22P05 exposure. Literal backslash-u text is untouched.

* chore(storage): bump @openmaic/storage to 0.29.2

* fix(storage): keep colliding sanitized keys and own __proto__ members when encoding jsonb

encodeJson replaces NUL and lone surrogates in object keys, but two distinct
keys can sanitize to the same string: "a\u0000" and "a\uFFFD" both become
"a\uFFFD". The rebuild assigned each member by its sanitized key, so a later
member silently overwrote an earlier one and that earlier value was lost.

Keep the first member under the sanitized key and give each later colliding
member a deterministic suffix (#2, #3, ... appended until the key is unique
among the keys emitted so far, whether sanitized or original). Member order is
preserved. The suffix is ASCII and contains no NUL or surrogate, and the
sanitizer only ever emits U+FFFD, so a suffixed key can never be confused with
a bare replacement result.

The rebuild also used a plain {} object, so assigning an own "__proto__" member
invoked the prototype setter and dropped it from the output whenever a sibling
key needed sanitizing. Build the rebuilt object with a null prototype so
"__proto__" stays an own data property; JSON.stringify then emits it and
PostgreSQL round-trips it. Arrays and the no-op fast path (return the original
reference when nothing needs sanitizing) are unchanged.
2026-09-14 18:29:55 +02:00
16fd8583a6 fix(settings): maintain asr language state invariant on provider selection (#1082) (#1443)
* fix(settings): maintain asr language state invariant on provider selection (#1082)

* test(store): import ASRProviderId type in server sync tests

* fix(settings): validate custom ASR languages against CUSTOM_ASR_DEFAULT_LANGUAGES (#1082)

* test(store): refactor custom ASR sync and rehydration test suite for clarity

---------

Co-authored-by: cham <2577781125@qq.com>
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-14 18:14:45 +02:00
90cf53f142 fix(upload): resolve the generic Office MIME (application/vnd.ms-office) via filename extension (#1498)
* fix(upload): resolve the generic Office MIME via filename extension (#1497)

On Linux, Chrome derives File.type from the XDG shared-mime-info database.
Older or trimmed databases (e.g. Kylin OS V10) map every OOXML extension to
the generic container `application/vnd.ms-office` instead of the concrete
format MIME, so .pptx/.docx/.xlsx uploads were rejected with "当前解析器不支持该格式"
on the generation toolbar and with 415 unsupported_mime on POST /api/materials
— while the same files upload fine on macOS/Windows.

Treat `application/vnd.ms-office` as a generic upload fallback alongside the
zip family, so the filename extension decides the concrete format:

- lib/document/mime.ts: add it to GENERIC_DOCUMENT_MIME_TYPES. This covers
  the toolbar's provider-whitelist check and the extract-document API gate
  (both call normalizeDocumentMimeType). It must NOT go into per-format
  aliasMimes — canonicalFromAlias iterates DOCUMENT_FORMATS in declaration
  order, so a shared alias would misroute x.pptx to application/msword.
- lib/workbench/material-upload-policy.ts: new resolveWorkbenchMaterialMime()
  grants missing/octet-stream/zip/vnd.ms-office types the same extension
  fallback the document path already had (the workbench gate previously
  rejected empty and zip-family types outright on every OS).
- app/api/materials/route.ts + session-store.ts: gate, store, and send the
  resolved MIME instead of the raw browser value.

The spoofing surface is unchanged: a specific but unsupported MIME still
falls through verbatim for the whitelist to reject, and an unknown extension
keeps the generic MIME. Full suite: 7992 passed.

Closes #1497

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix(review): log the declared mime, harden the fallback chain, pin adversarial cases

Review follow-ups on #1498 (no correctness or security findings — hygiene only):

- materials route: the reject/context log now carries declaredMime alongside
  the resolved mime whenever they differ, so a generic-header spoof stays
  visible in upload failure logs instead of collapsing to the resolved type.
- uploadWorkbenchMaterial: the returned record's mimeType falls back to the
  locally resolved mime before the raw browser value, which may still be the
  generic Office container if the server echo is ever absent.
- material-upload-policy: export WORKBENCH_MATERIAL_EXTENSIONS and add a
  drift guard asserting every accepted extension resolves (via a generic
  MIME) to a whitelisted type — a hand-maintained map entry going missing
  now fails tests instead of silently 415ing.
- tests: pin the legacy-.ppt provider split (mineru rejects, mineru-cloud
  accepts), uppercase-extension resolution, and 415-before-400 precedence
  for a generic mime with no filename.

Full suite: 7996 passed.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix(upload): support WPS Office MIME aliases

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-14 17:53:08 +02:00
Aris-lijinheandwyuc dee421621f feat(skills): add 习题课(最近发展区) (#1382)
* feat(skills): add ZPD-based exercise lesson skill

* fix(skills): localize exercise lesson title in all workbench locales

---------

Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-14 17:41:09 +02:00
Percyandwyuc 44c3c8aed0 fix: tolerate malformed authored CSS in classroom exports (#1422)
* fix: tolerate malformed authored CSS in classroom exports

Interactive-scene HTML authored by LLMs can carry browser-tolerated CSS
syntax errors (e.g. `-- crop-green: #22c55e`), which browsers silently
drop. The strict postcss pass used by resource-pack / classroom-zip
export threw on them, failing the whole export while PPTX (which never
parses interactive HTML) kept working.

Inlining is an enhancement, not a precondition: on strict-parse failure
the stylesheet/style attribute/SVG attribute is now kept byte-for-byte
with a warning, and export continues. Remote dependencies inside
unparseable CSS (url() / @import, absolute or relative, quoted with
parens) are still surfaced via a comment- and string-aware fallback
scanner so zip exporters warn and offline validation is not silently
bypassed.

* fix: stop the css fallback scanner at bad-string tokens

Per CSS syntax, an unescaped \n/\r/\f inside a quoted string produces a
bad-string token and the browser recovers right there. The fallback
scanner previously ran unterminated strings to EOF, swallowing every
following rule and hiding browser-active url()/@import dependencies from
dependency reporting and residual validation.

Both string-scanning loops now terminate at an unescaped newline and
resume scanning from it; an escaped newline (\ + \n, \ + \r\n, \ + \r,
\ + \f) continues the string, and escaped newlines are stripped from
captured URLs — in the fallback scanner and in the strict-path
collectors (cssUrlReferences / cssImportReference), which previously
kept the escape in the URL for valid CSS too.

* fix: keep EOF-truncated css strings as valid tokens

Per CSS Syntax 4.3.5, only an unescaped \n/\r/\f produces a bad-string
token; a quoted string truncated at EOF is a valid string token that
browsers keep (with the function auto-closed and a trailing backslash
consumed). The previous round wrongly dropped EOF-truncated url(".../
@import "... values, hiding real remote dependencies again — the exact
shape of an LLM-authored <style> cut off mid-generation.

Both string paths now share scanCssString with three-way termination
(quote / unescaped newline / EOF), and the collectors were aligned with
CSS token semantics on both the strict and fallback paths, with parity
tests: escaped newlines (\ + LF/CRLF/CR/FF) are stripped escape-aware
from quoted values; an unquoted url() containing a backslash-newline is
a bad-url and no dependency; non-whitespace (comments allowed) around a
quoted payload makes a bad-url, and a bad url consumes through its ')'
so nested url(...) text is not re-scanned; an escaped ')' belongs to an
unquoted url value.

---------

Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-14 17:21:37 +02:00
Nguyen Quang Thiepandwyuc 3e3cac5f76 fix: enable Pro workbench flag in Docker builds; allow non-TLS localhost cookie (#1484)
* fix: enable Pro workbench flag in Docker builds; allow non-TLS localhost cookie

Two deployment fixes discovered while self-hosting with Docker Compose:

1. Expose NEXT_PUBLIC_PRO_WORKBENCH_ENABLED as a Docker build arg.
   Persistence and other NEXT_PUBLIC_* flags are already wired through
   Dockerfile + docker-compose.yml, but the Pro workbench entry flag was
   missing, so Docker deployments could not enable the workbench at all.

2. Add COOKIE_SECURE opt-out for the anonymous owner cookie.
   Production builds always set `Secure` on the anonymous_id cookie. Safari
   refuses to store Secure cookies served over plain http://localhost (it
   does not special-case localhost like Chromium/Firefox), so every request
   minted a fresh anonymous owner and owner-scoped document writes were
   rejected with 403 — scenes never persisted and generation appeared to
   hang after the first scene. COOKIE_SECURE=0 lets plain-HTTP deployments
   opt out; the default (Secure in production) is unchanged.

Documents COOKIE_SECURE in .env.example alongside the persistence opt-ins.

* fix: share the COOKIE_SECURE opt-out with the Server Action cookie mint

Review follow-up on the COOKIE_SECURE opt-out.

- Extract anonymousCookieSecure() in owner.ts and use it in
  lib/workbench/workspace-actions.ts, which re-implements the anonymous_id
  mint: with COOKIE_SECURE=0 that path still sent a Secure cookie, so a
  Server Action running before any /api/agent/* request (e.g. deleting a
  workspace session) minted an ephemeral owner in Safari.
- State the opt-out accurately in owner.ts; the previous comment claimed the
  flag never forces Secure off.
- Add the regression test beside the existing production case (verified it
  fails when the opt-out is dropped).
- .env.example: document the security cost of dropping Secure and that only
  the exact value 0 disables it.

---------

Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-14 17:01:59 +02:00
wyuc 68aed2932a fix(proxy-media): connect only to addresses the SSRF guard validated (#1488)
* fix(proxy-media): connect only to addresses the SSRF guard validated

The route validated a URL with validateUrlForSSRF and then handed the
hostname to fetch(), which resolved it again at connect time. An
attacker-controlled name could answer a public address to the guard and an
internal address (loopback, RFC1918, link-local, cloud metadata) to the
socket, and every redirect hop repeated the same validate-then-refetch gap.

Add a shared policy-aware pinned dispatcher
(lib/server/pinned-dispatcher.ts) whose connect.lookup resolves all
addresses, runs the same local-network policy as validateUrlForSSRF over
every answer (cloud metadata is always blocked, even with
ALLOW_LOCAL_NETWORKS=true), and passes only the vetted list to the
connection. proxy-media creates one dispatcher per request and uses it on
every hop, mapping a connect-time refusal to 403 INVALID_URL instead of a
generic 500. The agent-runtime pinned agent now reuses the same builder
with its strict assertSafeIp policy unchanged.

The other routes that validate with validateUrlForSSRF and then hand the
URL to provider SDKs (parse-pdf, transcription, generate/*, verify-*,
probe-models, azure-voices) are not covered; that is a follow-up.

Tests: new tests/server/proxy-media-rebinding.test.ts covers the two-answer
rebind, the ALLOW_LOCAL_NETWORKS opt-in through the pinned path, metadata
refusal under the opt-in, and a redirect-hop rebind.

* fix(proxy-media): destroy the per-request dispatcher and align non-unicast policy across layers

Follow-up hardening for the proxy-media SSRF pin.

1. Handler hang: the route awaited `dispatcher.close()` in `finally`.
   undici's `close()` drains in-flight requests, and the response body is
   intentionally left unread on the 30x (`redirect: 'manual'`), non-2xx,
   oversize and connect-refusal paths, so an upstream that keeps its body open
   never completes and the POST never settles. Tear the pool down with a
   non-awaited `destroy()` instead; the response blob is already in memory by
   the time `finally` runs. Regression tests drive a loopback server that
   answers 404, a 30x, and an oversize 200 while trickling its body forever and
   assert the route settles in well under a second.

2. Headline rebinding test sensitivity: install an attacker-controlled global
   dispatcher (`setGlobalDispatcher` plus a lookup that always answers
   loopback) in `beforeEach` so the unpinned fetch path really reaches the
   loopback server. Removing the route's dispatcher now fails on
   `internal.requests() === 0` rather than on an unrelated DNS resolution
   error. The positive control also asserts the pinned connect lookup was
   invoked, so a runtime that silently ignores `init.dispatcher` goes red.

3. Non-unicast policy parity: `validateUrlForSSRF` only classified IP-literal
   URLs through `isPrivateIP`, so a literal in CGNAT 100.64/10 or an IANA
   reserved/special-use range (240/4, 198.18/15, the TEST-NET blocks,
   192.0.0.0/24, multicast, broadcast) passed the URL layer while the same
   address reached through a hostname was refused at connect time. Both layers
   now share `isNeverAllowedRange`, applied to IP-literal URLs and to every
   resolved answer, with or without `ALLOW_LOCAL_NETWORKS`.
   `connectionAddressBlockReason` drops its blunt `range() !== 'unicast'` test
   so the two layers agree exactly, while private, loopback and link-local
   answers keep following the opt-in.

   Behaviour change: a hostname resolving into CGNAT 100.64/10 or an IANA
   reserved/special-use range is now refused (403 INVALID_URL) where it was
   previously proxied, including under `ALLOW_LOCAL_NETWORKS=true`, since these
   ranges are never legitimate proxy targets.

4. Do not echo an unparseable connect-time answer in the refusal. Return the
   generic block message instead of `Unable to classify network address:
   <value>`; the route relays that message to the client in a 403 body.

Tests: new trickle-body regression tests and CGNAT/reserved cases for both
literals and resolved answers, plus a `connectionAddressBlockReason` parity
suite. One existing fixture is updated from the IANA documentation prefix
2001:db8::/32 (reserved) to a real public prefix 2001:4860::/32.

* fix(ssrf-guard): let the local-network opt-in cover CGNAT and decode local-use NAT64 prefixes
2026-09-13 18:00:56 +02:00
wyuc 1fd4348f21 fix(classroom): generate classroom ids server-side and create files exclusively (#1489)
* fix(classroom): generate classroom ids server-side and create files exclusively

The legacy file-backed classroom store accepted a caller-chosen stage.id and
renamed a temp file over the target, so anyone who knew a public share URL
could POST the same id and replace that classroom's content. POST
/api/classroom now mints the id itself (nanoid, 10 characters, matching the
generation pipeline), ignores any client-supplied stage.id, and rebinds the
stage and every scene to the generated id. Both the route and
classroom-generation persist through a new exclusive create that hard-links a
temp file into place, so an existing classroom is never replaced; EEXIST is
retried with a fresh id a bounded number of times and then reported as 409.

Unchanged: the server-backed persistence path (app/api/persistence,
lib/persistence), the read/GET path, middleware, and the access-code gate;
writeJsonFileAtomic keeps its overwrite semantics for the other callers.
Adds regression tests pinning non-overwrite, the typed EEXIST error, collision
retry/exhaustion, and generation-path exclusivity.

* fix(classroom): fall back to exclusive open without hard links and reserve ids before media generation

writeJsonFileExclusive kept `fs.link` as its only route to the destination, so
classroom creation failed hard on mounts without hard links (gcsfuse, s3fs and
some FUSE gateways return ENOSYS/ENOTSUP/EOPNOTSUPP/EPERM/EXDEV). Keep `link`
as the fast path and, for exactly those codes, fall back to an exclusive `wx`
open: still never replaces an existing file and still maps EEXIST to
ClassroomAlreadyExistsError. Any other code keeps propagating, and the temp
file is always removed.

The generation pipeline wrote media and TTS into
<CLASSROOMS_DIR>/<id>/{media,audio} before the exclusive create, so a collision
would have landed the new classroom's files in an existing classroom's
directory and the retried document's URLs would still point at that other id.
Reserve the id first by exclusively creating the classroom file with a
placeholder document (`reserved: true`, empty scenes). The file is the only
token that atomically covers the whole collision namespace: a classroom created
through POST /api/classroom has a JSON file but no directory, so reserving a
directory would miss it. readClassroom treats a reserved document as absent, so
an in-flight or crashed reservation is never served as an empty classroom. The
retry on EEXIST now happens at reservation time (bounded, before any media),
and the final persist is an ordinary overwrite of the id the process owns, so
the post-media id retry is gone.

Document OPENMAIC_CLASSROOMS_DIR in .env.example, including that
CLASSROOM_JOBS_DIR does not move with it.

* fix(classroom): release an unused reservation when generation fails
2026-09-13 17:49:57 +02:00
LING DUANandwyuc ebf665f316 feat(playback): reference static GenUI components (#1281)
* feat(playback): reference static GenUI components

* feat(playback): gate courseware references

* fix(pi): bound Interactive source HTML before parsing

* fix(pi): bound interactive reference HTML processing

* fix(playback): close interactive reference review gaps

---------

Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-09-11 19:16:10 +02:00