Skip to content

fix: give the chat upload size cap and type allowlist a value (issue #1405) - #1426

Merged
sakibsadmanshajib merged 2 commits into
mainfrom
fix/1405-chat-upload-limits
Aug 29, 2026
Merged

sakibsadmanshajib merged 2 commits into
mainfrom
fix/1405-chat-upload-limits

Conversation

@sakibsadmanshajib

Copy link
Copy Markdown
Owner

Closes #1405.

What was happening

Measured live on https://chat-hive.scubed.co on 2026-08-29: a 28.6 MB attachment produced a composer chip and a POST /api/v1/files/?process=true that had still not returned after 105 seconds, with no progress bar, no spinner text, no timeout and no error. The chip looked identical to the chip for a 12 byte file that uploaded in under a second. Separately, evil.exe uploaded and was processed with a 200.

The stall matters as much as the missing cap. It is the interface asserting a state the backend has not reached, which is the quiet-absence shape this repository has spent the day removing.

The enforcement already exists. It had no value to enforce

Read out of the running container rather than assumed (docker exec hive-open-webui-1, image hive-open-webui:v0.10.2-branded, box at 69e9be9b2):

  • routers/files.py:315-323 refuses an extension outside rag.file.allowed_extensions with 400 naming the type, before the bytes reach storage.
  • routers/files.py:355-361 refuses a file over rag.file.max_size megabytes with 413 naming the limit, and deletes the object it had just stored.
  • main.py:2014 publishes rag.file.max_size to clients as file.max_size, which is what lets MessageInput.svelte refuse an oversized file before the request is made.

Both config keys were unset on the box, so all three checks were inert.

The trap, which is #722's and is why this needs more than an env var

docker-compose.yml has carried RAG_FILE_MAX_SIZE since the #1108 follow-up, with an empty default and a comment saying it serves rag.file.max_size to clients so the composer's guard fires client-side. It never did. deploy/docker/owui-patches/hive_rag_env_config.py's RAG_CONFIG_ENV did not list the key, so nothing reconciled it, and the row Open WebUI's seed_defaults wrote on first boot (None, meaning no limit) has outranked the environment ever since. RAG_ALLOWED_FILE_EXTENSIONS was not in compose at all.

So setting either variable on an already-booted box was a silent no-op, exactly like #722, #772, #832 and #997, and the same shape as the skills permission #1388 fixed. This change uses the seam #1388 built.

Where the limits are actually enforced

Server-side, in the upload handler, on every request. The client-side check is a faster and more legible refusal of the same rule, driven by the same persisted value published through /api/config; it is not the enforcement and does not need to be trusted. A client that skips it gets a 413.

One residual is stated rather than left unsaid: upstream gates the type check on if process and allowed_file_extensions, so a POST ?process=false skips it. The size cap is not gated that way and applies to every upload. This is pre-existing, is not made worse here (there was no allowlist at all before), and needs its own exact-literal backend patch, so it is filed as #1425. It is also why this change does not need image extensions in the allowlist and does not break image attachments: MessageInput.svelte uploads anything with an image/ MIME type through that same process=false path.

The values, chosen deliberately

Size: 25 MB. Not a preference and not a guess. RAG_MAX_UPLOAD_BYTES is already 26214400 on edge-api and on the markitdown sidecar, and Open WebUI multiplies its megabyte value by 1024 * 1024, so 25 is byte for byte the ceiling Hive already enforces on its own document ingest path. Accepting more in chat than the product accepts elsewhere would give two different answers to "how large a document can Hive read". It is comfortably above any realistic demo document, and if a real one is refused the operator raises RAG_FILE_MAX_SIZE in .env, documented in .env.example. A refusal is immediate and names the limit, so the failure mode of a cap set too low is a legible error rather than the stall this replaces.

Types: everything this deployment can turn into text. Derived by a rule, not by taste: Open WebUI's own known_source_ext, plus the extensions Loader._get_loader names in its own branches (pdf, doc, docx, odt, ppt, pptx, xls, xlsx, csv, msg, rst, xml, html, htm, md, epub), plus txt. 69 entries. A narrower list would refuse a file the product could have read, which fails in front of a user and is worse than no cap. .exe, .zip, .dll, .bin, .so, .msi, .apk, .jar, .iso and .dmg are all outside it, and a test asserts that.

An allowlist is the only type control upstream offers, and it is needed because _get_loader's final else falls back to TextLoader for any unrecognised extension: without one, an executable is "processed" into mojibake, stored, and served back on request.

Two coercions that carry weight

  • rag.file.max_size persists as an int. Upstream parses the variable with int(), so a first boot seeds a number. A string happens to work in both consumers by coincidence, and a reconciled value whose type differs from the seeded one is how ui.enable_login_form went wrong before.
  • rag.file.allowed_extensions persists as a list. This one is load bearing. Upstream evaluates file_extension not in allowed_file_extensions, so persisting the raw comma string would silently turn a membership test into a substring test: extension df would be admitted because pdf contains it, and so would every other extension that is a substring of an allowed one.

What this does on failure

  • A malformed RAG_FILE_MAX_SIZE ("25MB", "twenty") raises at startup naming the variable. The container fails to boot rather than booting with no cap and looking configured.
  • An allowlist that parses to no extensions (",") raises the same way, because an empty list is falsy in upstream's if process and allowed_file_extensions and would turn the check off while the configuration says it is on.
  • An unset or blank variable writes nothing, so an administrator's own choice survives and an enterprise deployment that never sets these is not silently capped.
  • An oversized file is refused client-side with a toast naming the limit before the upload starts, and server-side with 413 if the client is bypassed.
  • A disallowed type is refused server-side with 400 naming the type; uploadFile throws the detail string, MessageInput surfaces it as a toast and removes the chip.

Deliberately not in this pull request

Per-file progress on the composer chip. The reported stall was an unbounded upload, and a 25 MB cap bounds it: an oversized file is now refused before the request is made rather than hanging. A progress indicator is a separate frontend change with its own design questions and belongs in its own pull request. Saying so here rather than quietly shipping half the issue.

Tests

scripts/test_owui_rag_env_config.py, run by make test-scripts, which is a required CI check. Eight new cases. Seven were verified red before the implementation existed, individually, with the failures recorded:

FAIL test_upload_size_cap_is_reconciled_as_an_integer KeyError 'rag.file.max_size'
FAIL test_upload_type_allowlist_is_reconciled_as_a_list KeyError 'rag.file.allowed_extensions'
FAIL test_a_malformed_size_cap_is_refused_rather_than_ignored AssertionError a non-numeric RAG_FILE_MAX_SIZE was accepted
PASS test_unset_upload_limits_leave_the_persisted_values_alone
FAIL test_compose_sets_the_upload_size_cap
FAIL test_compose_allowlist_refuses_executables
FAIL test_compose_allowlist_covers_every_format_this_deployment_can_read
FAIL test_env_example_documents_the_upload_limits

The eighth, test_unset_upload_limits_leave_the_persisted_values_alone, passed before the change and is reported as what it is: an invariant guard, not a red-to-green test. It could not go red beforehand because the keys did not exist at all. It can now: coercing a blank value to 0 or [] fails it, which is the regression it exists to catch (issue #797).

make test-scripts exits 0. python3 scripts/test_owui_rag_env_config.py prints ok.

Not verified end to end, and why

No 30 MB file was uploaded against a running stack carrying this change, and I am not claiming otherwise. The chat stack cannot be started locally: this checkout's .env has SUPABASE_URL, SUPABASE_ANON_KEY and SUPABASE_SERVICE_ROLE_KEY empty, and #1254 covers the wider breakage. The deployed box cannot be used either, because the change is not on it until this merges, and mutating its persisted config by hand to simulate the result would be a production change made outside the deploy path during a period when the owner may walk the demo at any time.

What was verified, on the real substrate:

  1. The enforcement code inside the running hive-open-webui-1 container, both the 400 and the 413 branch, at their real line numbers, matching the vendored copy this repository reasons about.
  2. /api/config publishing rag.file.max_size as file.max_size in that same container, which is the mechanism the client-side refusal depends on.
  3. The box is at 69e9be9b2, confirmed with git -C ~/hive rev-parse HEAD rather than by trusting a green deploy.
  4. The reconcile itself, by unit test, including both coercions and both refusal paths.

The gap between that and an end-to-end upload is: this change writes two config rows, and the rows are then read by code that was verified to exist and to enforce. The first deploy after merge closes it, and a 30 MB attachment plus an .exe against the deployed chat is the acceptance test.

No UI code is touched, so the visual proof rule does not apply to this diff; the user-visible behaviour change comes entirely from Open WebUI's own existing error paths.

Buglog entry

{"date":"2026-08-29","title":"Chat upload had no size or type limit: a 30 MB file stalled with no error and an .exe was accepted","error_message":"POST /api/v1/files/?process=true never returns for a 28.6 MB attachment; no progress, no timeout, no error. evil.exe uploads with 200.","root_cause":"Open WebUI enforces rag.file.max_size and rag.file.allowed_extensions in upload_file_handler, but both were unset. RAG_FILE_MAX_SIZE was in docker-compose.yml with an empty default and a comment claiming it fed the client-side guard, while the key was missing from hive_rag_env_config.py's RAG_CONFIG_ENV, so the persisted first-boot row outranked the environment and no value could ever reach a booted box. RAG_ALLOWED_FILE_EXTENSIONS was absent entirely.","fix":"Reconcile rag.file.max_size and rag.file.allowed_extensions through hive_rag_env_config.py, with an int coercion for the size and a list coercion for the allowlist (a persisted comma string turns upstream's not-in membership test into a substring test). Compose defaults 25 MB, matching RAG_MAX_UPLOAD_BYTES 26214400, and an allowlist derived from known_source_ext plus the loader's own document branches. A malformed size or an empty allowlist fails startup rather than booting unenforced.","tags":["open-webui","uploads","persistent-config","silent-failure","issue-1405","issue-722"]}

…1405)

A 28.6 MB attachment produced a composer chip and a POST that had still not
returned after 105 seconds, with no progress, no timeout and no error, and a
Windows executable uploaded and was processed with a 200. Both limits were
simply unset.

Open WebUI already enforces both, in the pinned image's own
upload_file_handler, read out of the running container rather than assumed.
An extension outside rag.file.allowed_extensions is refused with 400 before
the bytes reach storage. A file over rag.file.max_size megabytes is refused
with 413 and the stored object deleted. The size cap is also published
through /api/config as file.max_size, which is what lets the composer refuse
an oversized file before the request is made.

docker-compose.yml has carried RAG_FILE_MAX_SIZE since the #1108 follow-up,
with an empty default and a comment claiming it served that client-side
guard. It never did: the key was missing from hive_rag_env_config.py's
reconcile map, so the value could not reach a box that had already booted.
That is the #722 class exactly, and the same seam #1388 built for the
permission tree is the fix.

The cap is 25 MB because RAG_MAX_UPLOAD_BYTES is already 26214400 on
edge-api and on the markitdown sidecar, and Open WebUI multiplies its
megabyte value by 1024 * 1024, so this is byte for byte the ceiling the
product already enforces on its own document ingest path. The allowlist is
derived by a rule rather than by taste: Open WebUI's own known_source_ext
plus the extensions Loader._get_loader names in its branches plus txt, so
nothing the product can read is refused. A test asserts that derivation
against the vendored loader so an upstream bump that adds a format fails
there rather than silently starting to refuse it.

Two coercions carry weight. The size persists as an int, matching the row a
first boot seeds, because a type that differs from the seeded one is how the
boolean keys went wrong before. The allowlist persists as a list, because
upstream evaluates `file_extension not in allowed_file_extensions` and a
persisted comma string would turn that membership test into a substring
test, admitting `df` because `pdf` contains it.

A malformed size, or an allowlist that parses to nothing, fails the
container at startup naming the variable rather than booting with no limit.
An unset variable still writes nothing, so an administrator's own choice
survives and an enterprise deployment that never sets these is not capped.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@coderabbitai

coderabbitai Bot commented Aug 29, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 43 minutes.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ddf1d82c-7c3e-47b9-87f7-1acbc45d3880

📥 Commits

Reviewing files that changed from the base of the PR and between 69e9be9 and c21f0f5.

📒 Files selected for processing (4)
  • .env.example
  • deploy/docker/docker-compose.yml
  • deploy/docker/owui-patches/hive_rag_env_config.py
  • scripts/test_owui_rag_env_config.py

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Five findings from the CodeRabbit CLI and the Antigravity adversarial pass,
four fixed and one rebutted.

A size cap of zero is now refused. It is not merely a useless value, it is a
value the two consumers read oppositely: Open WebUI's server-side check is
`if max_size and len(contents) > ...`, where zero is falsy and enforces
nothing, while the browser's is `file.size > max_size * 1024 * 1024`, where
zero rejects every file of non-zero length. A deployment set to zero would
refuse every upload in the composer while leaving the API accepting files of
unlimited size, and would read as a working cap.

Allowlist entries written with a leading dot now match. `.pdf,.txt` is the
obvious thing for an operator to write, and upstream compares against an
extension it has already stripped the dot from, so persisting the dot would
produce a list matching nothing and refuse every upload while the deployment
looked correctly configured.

The refusal message for an all-separator allowlist no longer suggests
leaving the variable unset as a way to remove the check. In a compose
deployment an unset variable means the compose default applies, and in the
reconcile it means the persisted row is left alone, so neither removes
anything. Removing the allowlist is a deliberate change to the compose
default. `.env.example` carried the same wrong implication for both
variables and is corrected the same way.

The vendored-loader coverage test could not fail: its regex assumed single
quotes, and an upstream reformat would have yielded an empty set, which is a
subset of anything, so the test would have reported full coverage over
nothing. It now accepts either quote style and asserts a plausible count
first.

Rebutted: the `?process=false` bypass of upstream's extension check is real
and is filed as #1425. It is pre-existing, is not created or widened here,
and needs its own exact-literal backend patch rather than riding along with
a config reconcile.
@sakibsadmanshajib

Copy link
Copy Markdown
Owner Author

Adversarial review, two streams. Six findings, five fixed, one rebutted

This diff is an input-parsing path, so the security pass is mandatory and was run.

Stream 1: CodeRabbit CLI

Ran against the committed diff on this branch, base origin/main, four files reviewed. One finding, minor, and it was right.

.env.example:252-259, functional correctness. The comments said an unset RAG_FILE_MAX_SIZE accepts uploads of any size and an unset RAG_ALLOWED_FILE_EXTENSIONS accepts every extension. Both are false in a compose deployment: docker-compose.yml substitutes :-25 and a non-empty allowlist, so an unset or empty variable means the compose default applies, not "no limit". Documenting an escape hatch that does not exist is the same defect class this pull request is fixing.

Fixed. Both blocks now state the substitution rule and say that removing a limit is a deliberate change to the compose default.

This is the CLI, not the GitHub App check. If the App reports pass with detail "Review rate limited", that is a check that cannot go red and is not a review; it should not be counted as one.

Stream 2: Antigravity, gemini-3.1-pro-high, effort high, security-weighted

Prompted with the full diff pasted inline (it runs outside this worktree, so nothing was read from the repository) and with upstream's exact enforcement expressions. Its findings named files in this diff, so the review reached the right tree. Five findings.

1. ?process=false bypasses the type allowlist. Critical. Rebutted, and filed.

Correct, and already known: upstream gates the extension check on if process and allowed_file_extensions, so a direct POST /api/v1/files/?process=false skips it. The size cap is not gated that way and applies to every upload.

Not fixed here, for three reasons. It is pre-existing and is not created or widened by this change, which only narrows the default path where there was previously no restriction at all. Closing it needs a new exact-literal patch against the pinned image's backend, a different and riskier kind of diff than a config reconcile, and bundling it here would make both harder to review. And the same process=false route is what the composer itself uses for images, so the fix has to keep that working rather than simply deleting the conjunct.

Filed as #1425 with the reproduction, the rename-to-.png variant, and the shape of the fix.

2. The refusal message pointed at an escape hatch that does not exist. Fixed.

The message for an all-separator allowlist told the operator to "leave the variable unset" if they wanted no allowlist. Unset means the compose default applies, and in the reconcile it means the persisted row is left alone, so neither removes anything. Same defect CodeRabbit found in .env.example, reached from the other direction. The message now says removing the allowlist is a deliberate change to the compose default.

3. A size cap of 0 splits the two consumers. Critical. Fixed, and the best finding of the round.

"0".isdigit() was true, so 0 persisted. Upstream's server-side check is if max_size and len(contents) > ..., where 0 is falsy and enforces nothing at all. The browser's is file.size > max_size * 1024 * 1024, where 0 rejects every file of non-zero length. A deployment set to 0 would refuse every upload in the composer while leaving the API accepting files of unlimited size, and would read as a working cap.

0 is now refused at startup with a message naming both halves. test_a_zero_size_cap_is_refused pins it.

4. A leading dot made the allowlist match nothing. Major. Fixed.

RAG_ALLOWED_FILE_EXTENSIONS=.pdf,.txt persisted as [".pdf", ".txt"], and upstream compares against an extension it has already stripped the dot from, so "pdf" in [".pdf"] is false and every allowed upload would have been refused while the deployment looked correctly configured. Entries are now stripped of a leading dot on the way in. test_allowlist_entries_written_with_a_leading_dot_still_match pins it.

5. The vendored-loader coverage test could not fail. Major. Fixed.

test_compose_allowlist_covers_every_format_this_deployment_can_read extracted known_source_ext with a regex assuming single quotes. An upstream reformat to double quotes would have yielded an empty set, an empty set is a subset of anything, and the test would have reported full coverage over nothing. It now accepts either quote style and asserts a plausible extraction count first, so a reformat fails loudly instead of going blind. Issue #797's shape again.

Result

python3 scripts/test_owui_rag_env_config.py prints ok. make test-scripts exits 0. Fixes are in c21f0f5.

@sakibsadmanshajib

Copy link
Copy Markdown
Owner Author

CodeRabbit GitHub App: SKIPPED, not passed

gh pr checks reports the App's status as:

CodeRabbit	pass	0		Review rate limited

That is a green check that cannot go red. It reviewed nothing, and counting it as a passing review stream would be exactly the quiet-absence shape this repository has spent the day removing. Reported as SKIPPED.

The CodeRabbit CLI did run against this branch and its result is recorded in the review comment above. That is the CodeRabbit signal for this pull request; the App's green square is not.

@sakibsadmanshajib

Copy link
Copy Markdown
Owner Author

CI note: one required check failed on an unrelated flaky test, and it was not silently rerun away

Go tests (agent-engine) failed on this pull request's head commit c21f0f5, in TestSandboxEngine_Status_CancelDuringPublishIsRaceFree, with:

dial unix /tmp/.../c/agent.sock: connect: no such file or directory

This diff contains no Go code. Four files, none under apps/. The same commit passed on a plain gh run rerun --failed with nothing changed, so it is non-deterministic.

Diagnosed rather than shrugged at, and filed as #1427: the test's own comment says "whether the publish or the reap wins is a legitimate race in outcome and this deliberately does not pin it", and then line 1439 pins it by requiring Status to return nil even in the branch where the reap wins and has already removed the socket. The invariant the test actually exists to protect, that no Create ran with a blank bearer JWT, is a separate assertion and is unaffected.

Recorded here so the rerun is visible as a rerun. A required check that fails at random on unrelated work trains everyone to rerun on red, which is how a real failure eventually gets rerun away.

@sakibsadmanshajib

Copy link
Copy Markdown
Owner Author

One more thing verified rather than assumed: compose interpolation of a 69-entry default

A ${VAR:-a,b,c,...} default containing 68 commas is the kind of thing that parses as YAML, reads fine in review, and then resolves to something else at runtime. Checked against the real docker compose binary on an isolated copy of just those two lines, so no secrets were resolved and nothing in this stack was touched:

$ docker compose -f probe/docker-compose.yml config
      RAG_ALLOWED_FILE_EXTENSIONS: bash,bat,c,cmd,conf,cpp,cs,css,csv,dart,db2,doc,dockerfile,docx,env,epub,erl,ex,exs,go,h,hpp,hs,hsc,htm,html,ini,java,js,json,jsx,lhs,log,lua,m,md,mm,msg,nginxconf,odt,pdf,perl,php,pl,plsql,pm,ppt,pptx,ps1,py,r,rb,rs,rst,scala,sh,sql,svelte,swift,toml,ts,tsx,txt,vue,xls,xlsx,xml,yaml,yml
      RAG_FILE_MAX_SIZE: "25"

All 69 entries survive intact, and the size cap arrives as the string "25", which is what the INTEGER_KEYS coercion in hive_rag_env_config.py exists to turn into the JSON number Open WebUI's own first boot would have seeded.

Note the deliberate omission: docker compose config on the real docker-compose.yml prints every resolved secret in plaintext, including the Supabase service-role key and the database URL, so it must never be run anywhere that gets logged. This probe was two lines in a throwaway file for that reason.

@sakibsadmanshajib

Copy link
Copy Markdown
Owner Author

CI green: all 9 required checks pass or skip on c21f0f5, including Web E2E (full stack) and Go tests (agent-engine) after the rerun of the unrelated flake filed as #1427. Zero unresolved review threads. One non-required check, Interaction coverage (console controls), was still running at the time of writing. Not merging, per the dispatch.

@sakibsadmanshajib
sakibsadmanshajib merged commit 908e48a into main Aug 29, 2026
46 of 47 checks passed
@sakibsadmanshajib
sakibsadmanshajib deleted the fix/1405-chat-upload-limits branch August 29, 2026 16:04
sakibsadmanshajib added a commit that referenced this pull request Aug 29, 2026
) (#1436)

Closes #1428. Follow-up to #1426, which was correct and landed inert.

## What was wrong

`deploy/docker/docker-compose.yml` passed `RAG_FILE_MAX_SIZE:
${RAG_FILE_MAX_SIZE:-25}`, derived carefully in #1426 to match
`RAG_MAX_UPLOAD_BYTES` byte for byte. Line 98 of the demo box's `.env`
reads `RAG_FILE_MAX_SIZE=100`. An explicit value beats a compose
fallback, so the chat surface went on publishing a 100 MB cap to every
browser while edge-api and the markitdown sidecar refused anything over
25 MB.

The defect is not the number. Two services held two independently
settable ceilings for one user action, in two different units, with
nothing making them agree. Setting either alone diverges the product,
and the chat surface diverged in the generous direction: it accepted
what Hive refuses everywhere else. Editing the box's `.env` to 25 would
have closed today's gap and left the mechanism that produced it fully
intact.

## What this does

One settable ceiling, `RAG_MAX_UPLOAD_BYTES`, in bytes.

Compose already interpolated the identical expression into edge-api and
into the markitdown sidecar as `MAX_UPLOAD_BYTES`. Open WebUI was the
lone outlier. It now takes that same expression, and
`hive_rag_env_config.derived_upload_cap` floors it into the whole
megabytes Open WebUI wants. That unit mismatch was the only reason a
second variable ever existed, and it is the only reason this is code
rather than plain interpolation.

`RAG_FILE_MAX_SIZE` is no longer read. A container that finds it set
refuses to start, naming both variables, rather than quietly obeying or
quietly ignoring it.

Rounds down, deliberately. The chat surface must never accept a file the
ingest path would refuse, so a byte value that is not a whole number of
megabytes yields the smaller cap.

`edge-api`'s own parse of the same variable now fails the boot on a
malformed value instead of logging a warning and falling back to 25 MB.

One variable, one parse rule, and that claim is literally true rather
than approximately true only because review caught that it was not.
`strconv.ParseInt` accepts a leading sign and Python's `isdigit()` does
not, so `RAG_MAX_UPLOAD_BYTES=+26214400` would have booted edge-api and
crashed the open-webui container: the divergence had moved from the
value to the parser, which is this pull request's own defect class one
layer down. Both sides now require ASCII digits only, and both refuse
the Unicode digits `str.isdigit()` accepts, one of which `int()` parses
happily and the other of which it raises on.

## The box `.env` needs no change, and I made none

Because compose no longer names `RAG_FILE_MAX_SIZE` in the `open-webui`
service's `environment:` block, the box's line 98 never enters the
container. The fix is correct on the deployment without editing a file
that has no off-box backup.

Removing that now-inert line from `/home/sakib/hive/.env` is recommended
tidy-up, not a requirement. I deliberately did not do it silently. If it
is ever re-plumbed into the container, `derived_upload_cap` fails the
boot rather than letting it win.

## Measured before, on the deployed chat

Signed in as a run-key-scoped fixture account (`owui-e2e+capdiv2908@…`,
created through `scripts/seed-owui-e2e-user.py`, not the demo account).
No password was set, reset or rotated anywhere in this work.

| Path | File | Result |
| --- | --- | --- |
| Composer | 64 KB `.txt` | `POST /api/v1/files/?process=true` 200, chip
resolves |
| Composer | 30 MB `.txt` | POST issued, no response in 44 s, spinner,
no progress, no error |
| Composer | 110 MB `.txt` | Refused client side in under 1 s, toast
"File size should not exceed 100 MB.", zero requests issued |
| Straight at the container | 30 MB | 200 in 0.33 s, accepted and stored
|
| Straight at the container | 110 MB | 413 in 14 s |
| Authenticated `GET /api/config` | | `file.max_size: 100` |

Two claims that circulated during triage are wrong, and the measurements
above are why.

The client-side guard is not dead code and `/api/config` does publish a
`file` block. It is auth-gated, and Open WebUI's token lives in
`localStorage` rather than a cookie, so any fetch without an explicit
`Authorization` header sees the unauthenticated shape, which genuinely
has no `file` key. A guard reading `undefined` cannot produce the string
"File size should not exceed 100 MB."

The backend was not hanging. 30 MB straight at the container is a 200 in
a third of a second. The stall is transport, and it has its own issue
now: see below.

## What is lost by capping chat at 25 MB

Something real, and it is worth saying rather than implying otherwise.

The chat attachment path does not traverse edge-api's upload cap or the
markitdown sidecar. There is no `CONTENT_EXTRACTION_ENGINE` on the
`open-webui` service, so Open WebUI extracts in process and only the
embedding call leaves through the gateway. A 30 MB attachment therefore
did work on that path, in 0.33 s, and this change refuses it.

So this is a coherence decision, not the removal of a pure lie: Hive
should give one answer to "how large a document can you read", and the
smaller of its two existing answers is the honest one, since the
developer API and the sidecar cannot be talked into the larger. Part of
the old 100 MB was a lie regardless, because nothing that large survives
the transport (see #1435).

## The advertised 25 MB is close to what the transport delivers, and
#1435 says so

Raised during review of this change, and it is a fair challenge, so it
was measured rather than estimated. Every upload that traverses
Cloudflare hits a wall at about 120 seconds. From the box itself, out to
the public hostname and back: 24 MB completes with a 200 in 118.9 s, 25
MB fails with a 524 at 125.1 s, 30 MB fails with a 524 at 125.1 s. The
125 s figure is constant and size independent, which is a timeout rather
than a bandwidth limit.

So a file sitting exactly at the cap this change advertises can still
die at Cloudflare after two minutes of silence. That is not something
this change can fix, because the wall is in front of the developer API's
`/v1/rag` upload too, and lowering the chat cap below the ingest ceiling
would re-create the very divergence being removed here. It is filed as
**#1435** with the measurements, and the composer's missing per-file
progress, which is what makes a 119 s success and a 125 s failure look
identical while running, is filed there too.

What this change does fix is the reported case: a 30 MB attachment now
takes the immediate, readable path the 110 MB one already took, naming
the limit before a single byte is transferred.

## What was and was not verified end to end

#1426 carried this disclosure and it is why its inertness was caught in
a day, so it is a habit rather than a one-off.

**Exercised against the deployed stack, signed in, with a real file
through the real composer:** every "before" row in the table above, the
authenticated and unauthenticated `/api/config` shapes, and the
server-side statuses at 24, 25, 30 and 110 MB from three network
positions.

**Exercised against the deployed frontend with one value stubbed:** the
"after" behaviour. The frontend, account, session and file are the
deployed ones; the only thing rewritten is `file.max_size` in the
`/api/config` response, from the 100 the box publishes today to the 25
this change derives. That is the single value the merge changes, and the
deploy that sets it happens on merge, so this is the only way to
exercise the post-change composer beforehand. **It is not an end-to-end
capture.** That the derivation produces 25 from 26214400 is proven by
the unit tests, not by that screenshot. The scope is stated in the
committed capture log too, not only here.

**Not exercised at all:** the reconcile actually running inside a
rebuilt open-webui container, and therefore the boot-time refusal when
`RAG_FILE_MAX_SIZE` is present. Both are unit tested and neither has
been observed on a real container start. The first real deploy is the
first time `derived_upload_cap` runs in place, and if it raises,
open-webui fails to start. That risk is the reason the refusal path has
four tests rather than one, but tests are not a deploy.

**The recommended follow-up after merge:** re-run the same probe with
the stub removed and confirm the deployed `/api/config` publishes
`file.max_size: 25`, plus `docker compose logs open-webui` for the
reconcile's summary line.

## Not in scope

**#1425 is untouched and stays open.** The allowlist is bypassed by
`process=false`, which the composer's own image path uses. That needs a
new exact-literal backend patch under `deploy/docker/owui-patches/`, a
larger and riskier diff than a config derivation, and it should not ride
along. Nothing here narrows or widens that seam, so its survival is not
a regression from this change. The extension allowlist #1426 landed is
unchanged.

## Guard that can go red

`scripts/test_owui_rag_env_config.py` fails if `docker-compose.yml` ever
sets `RAG_FILE_MAX_SIZE` again as a key or an interpolation, or if the
three services stop sharing one expression. Verified red by
re-introducing the knob and running it, then reverted:

```
AssertionError: docker-compose.yml sets 'RAG_FILE_MAX_SIZE:': a second, independently
settable chat upload cap is what issue #1428 is.
```

That file is run by `make test-scripts`, which is a required check.

## Tests

- `python3 scripts/test_owui_rag_env_config.py` green, including
derivation, floor-not-round, refusal of the superseded variable, refusal
of a malformed or sub-megabyte ceiling, and the single-source-of-truth
compose guard.
- `go test ./apps/edge-api/cmd/server/... -run
TestParseRAGMaxUploadBytes` green, covering the empty fallback and
refusal of `25MB`, `26214400 bytes`, `0`, `-1`, `1.5` and `twenty`.

## Buglog entry

```json
{"id":"BUG-1428-upload-cap-divergence","date":"2026-08-29","title":"Chat upload cap and RAG ingest ceiling were two independently settable numbers, so the #1426 fix deployed inert","error_message":"Chat composer published a 100 MB attachment cap while edge-api and the markitdown sidecar enforced 26214400 bytes; a 30 MB attachment was accepted and stored by Open WebUI after a silent multi-minute upload","root_cause":"docker-compose.yml gave the open-webui service its own RAG_FILE_MAX_SIZE variable, in whole megabytes, settable independently of the RAG_MAX_UPLOAD_BYTES expression it passes edge-api and the sidecar in bytes. PR #1426 set that variable's compose default to 25, but the demo box's .env carried an explicit RAG_FILE_MAX_SIZE=100 and an explicit value beats a compose fallback, so the fix never reached the deployment it was written for.","fix":"Deleted RAG_FILE_MAX_SIZE as a settable knob. The open-webui service now takes the same ${RAG_MAX_UPLOAD_BYTES:-26214400} expression as the other two services, and hive_rag_env_config.derived_upload_cap floors it into whole megabytes, rounding down so the chat surface can never accept what the ingest path refuses. The container refuses to start if RAG_FILE_MAX_SIZE is present, and edge-api now fails the boot on a malformed ceiling instead of warning and falling back. A compose guard in scripts/test_owui_rag_env_config.py fails if a second knob is re-introduced.","tags":["config","docker-compose","open-webui","rag","uploads","silent-no-op","deploy-drift"]}
```
sakibsadmanshajib added a commit that referenced this pull request Aug 29, 2026
## Summary

This is the batched buglog follow-up for the pull requests merged to
`main` on 2026-08-29. Its diff is `.wolf/buglog.jsonl` and nothing else.

Per `.claude/rules/openwolf.md`, every fixed bug, error, failed test or
failed build must be logged, but the line may never be appended on a fix
branch. `merge=union` in `.gitattributes` resolves concurrent appends
locally and is ignored by GitHub's server side merge, so two branches
that both appended land in hard conflict there. An unmergeable pull
request gets no `refs/pull/N/merge`, no `pull_request` run and therefore
zero checks, and the required status gate then blocks the merge for a
reason the page never states (issue #873). Each fix accordingly carried
its entry in its own pull request body, and this pull request copies
them onto `main` in one batch, which the protocol explicitly prefers
over one pull request per entry.

## Scope examined

Fifty nine pull requests merged to `main` on 2026-08-29. Forty eight of
them carried at least one entry, for eighty two entries in total. Thirty
two of those were already on `main` and are skipped, leaving fifty
appended here from thirty four pull requests.

The largest block of skips comes from #1342, the equivalent batch for
the 2026-08-28 merges, which merged earlier the same day and already
landed thirty six entries covering #1257, #1268, #1276, #1277, #1287,
#1292, #1293, #1294, #1296, #1301, #1303, #1305, #1313, #1335 and #1337.

## What landed

Fifty entries appended, one JSON object per line, append only. The 232
pre-existing lines are byte identical to `origin/main` (verified by
hashing the first 232 lines of the result against the base file). Every
line in the resulting file parses as JSON and carries `error_message`,
`root_cause`, `fix` and `tags`.

| Source | Entries |
|---|---|
| #1083 | 2 |
| #1277 | 1 |
| #1278 | 1 |
| #1298 | 1 |
| #1334 | 1 |
| #1336 | 3 |
| #1343 | 1 |
| #1346 | 1 |
| #1351 | 1 |
| #1365 | 2 |
| #1368 | 1 |
| #1369 | 1 |
| #1371 | 3 |
| #1375 | 3 |
| #1376 | 1 |
| #1378 | 1 |
| #1379 | 2 |
| #1388 | 5 |
| #1389 | 3 |
| #1390 | 2 |
| #1393 | 1 |
| #1394 | 1 |
| #1410 | 1 |
| #1417 | 1 |
| #1421 | 1 |
| #1423 | 1 |
| #1424 | 1 |
| #1426 | 1 |
| #1429 | 1 |
| #1431 | 1 |
| #1433 | 1 |
| #1434 | 1 |
| #1436 | 1 |
| #1439 | 1 |

Entries are copied verbatim from their source pull request bodies.
Nothing was rewritten, no field was invented, and no field was added. No
JSON needed repair: all eighty two extracted entries parsed on the first
attempt and all four required fields were present on every one.

## Merged pull requests that carried no entry

Eleven of the fifty nine. Recorded here because the gap is itself the
useful signal.

| Pull request | Title | Assessment |
|---|---|---|
| #1013 | chore(deps): bump the go-minor-patch group across 1 directory
with 4 updates | Dependabot bump, no defect fixed, no entry expected |
| #1015 | chore(deps): bump the go-minor-patch group across 1 directory
with 6 updates | Dependabot bump, no entry expected |
| #1016 | chore(deps): bump golang from 1.26-alpine to 1.27-alpine in
/deploy/docker | Dependabot bump, no entry expected |
| #1218 | chore(deps): bump postcss from 8.5.19 to 8.5.26 in
/apps/desktop | Dependabot bump, no entry expected |
| #1219 | chore(deps): bump golang.org/x/crypto from 0.41.0 to 0.52.0 in
/apps/control-plane | Dependabot bump, no entry expected |
| #1342 | chore: batch buglog entries for the 2026-08-28 merges | The
previous batch pull request itself, correctly carries no entry of its
own |
| #1364 | chore: remove four dead skills and record the patterns that
cost time | Protocol gap. The body records patterns that cost time,
which is the shape of a buglog entry, but none was written as one |
| #1383 | test: retire stale expected-failure markers, restore the ones
that are true (#1381, #1382, #1324) | Protocol gap. Stale `it.fails`
markers reading as red is a real defect that was fixed here and should
have carried an entry |
| #1384 | docs: correct D-047, hive-auto reverted to variable pricing
(D-059) | Decision ledger correction, arguably a documentation defect,
no entry written |
| #1387 | chore(deps): bump next from 15.5.23 to 16.3.3 in
/apps/agent-console | Dependabot bump, no entry expected |
| #1398 | docs: rescue the 2026-08-25 parity captures and add the
2026-08-29 QA matrix evidence | Documentation and evidence rescue, no
entry written |

Six of the eleven are Dependabot bumps and one is the previous batch, so
the genuine protocol gaps are #1364, #1383, #1384 and #1398. Of those,
#1383 is the one worth a follow-up: it fixed a real defect class (a
stale expected-failure marker reads as a red "Expect test to fail" and
gets dismissed as pre-existing) and left no record.

## Entries skipped as already present

Thirty two. Thirty of them matched an entry already on `main` on
`error_message`, `id` or `fix`. Two more from #1278 are semantic
duplicates that an exact match would have missed, and were skipped after
reading the landed entries they duplicate:

- #1278's `streaming content_block_start omits text field` entry is
covered by the consolidated
`bug-2026-08-28-anthropic-sdk-wire-conformance` entry landed from #1296,
whose root cause names the same `omitempty` on
`StreamContentBlock.Text`.
- #1278's `GET /v1/models leaked an upstream provider name` entry is
covered by `BUG-1284`, landed from #1300, which names the same
`public.model_aliases.summary` publication path.

#1278's third entry, on `top_k` forwarding producing a 400, is not
covered anywhere on `main` and is appended here. #1342 recorded #1278 as
fully "merged into #1296", which was accurate for two of its three
entries.

## Note on entry quality

One appended entry is thin: #1277's parity re-score record carries
`error_message` of `n/a` and a root cause of "console had no
privacy/data-policy surface at all". It is a parity gap record rather
than a defect record. It is included exactly as written rather than
embellished, per the protocol's preference for the author's own words.

## Test plan

- [x] Branch cut fresh from `origin/main`, diff is `.wolf/buglog.jsonl`
and nothing else
- [x] First 232 lines byte identical to the base file (md5 match)
- [x] All 282 resulting lines parse as JSON and carry `error_message`,
`root_cause`, `fix` and `tags`
- [x] No `.wolf/` telemetry (`anatomy.md`, `memory.md`,
`token-ledger.json`, `hooks/_session.json`, `buglog.json`) in the commit
- [ ] The six required checks report green via the inert path allowlist
in `.github/workflows/ci.yml`

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Chat upload has no size or type limit: a 30 MB file stalls indefinitely with no progress, timeout or error, and a .exe uploads cleanly

1 participant