Skip to content

feat(chat): ship a user-created skills library at its own Hive route - #1388

Merged
sakibsadmanshajib merged 3 commits into
mainfrom
feat/user-created-skills
Aug 29, 2026
Merged

sakibsadmanshajib merged 3 commits into
mainfrom
feat/user-created-skills

Conversation

@sakibsadmanshajib

@sakibsadmanshajib sakibsadmanshajib commented Aug 29, 2026 •

Copy link
Copy Markdown
Owner

What this does

The owner asked for the ability to add new skills. This ships a user-created skills library in the chat shell, at /skills, with its own navigation row.

Everything behind a skill already worked and was never removed:

  • backend/open_webui/models/skills.py and its migration define the skill table.
  • backend/open_webui/routers/skills.py is mounted at /api/v1/skills in main.py:757.
  • backend/open_webui/utils/middleware.py:2509-2556 resolves a selected or mentioned skill, access checks it, and appends its content to the request as a system message shaped <skill name="...">content</skill>.
  • The composer already carried the whole client half: the Skills button, the $ mention picker, the selection modal, skill_ids on the outgoing request, and the store that lists them.

Two things were missing. A route that renders the editor, and the permission that lets an ordinary customer reach the create endpoint.

Which layer this had to change, established before writing anything

Three layers looked like candidates and only one ships.

  1. The exact-literal bundle rewrites under deploy/docker/owui-patches/ run against the pinned upstream image's prebuilt bundle, and Dockerfile.open-webui then does RUN rm -rf /app/build followed by COPY --from=frontend .../build /app/build. That bundle is discarded. The Dockerfile says so itself: those rewrites survive only as a digest drift guard.
  2. deploy/docker/Caddyfile.owui 404s /workspace/skills at the proxy, so nothing served under that path is reachable regardless of the bundle.
  3. vendor/open-webui/src/ is what the frontend stage compiles and what the browser runs.

So the change is in layer 3, and layer 2 is why it is not a restored Workspace tab.

Why /skills and not a restored /workspace/skills

Issue #783 removed the Workspace Skills tab and the proxy still 404s its path. Restoring it would mean reversing a proxy rule to land on a path the target navigation deletes anyway. /skills is unclaimed by upstream and matched by neither Caddy block, so no proxy change is needed, and the surface sits outside routes/(app)/workspace/+layout.svelte's permission guard for the same reason issue #1109 moved Knowledge to /knowledge.

Skills belongs under a Customize destination in the target sidebar grammar. That container does not exist, so the row ships flat with a comment recording where it moves.

Why the permission is newly load bearing

Issue #783 removed the tab on two premises, both since falsified.

Premise one, that it was an empty surface with no Hive product behind it, is answered by the owner's ask. Premise two, that a permission-scoped hide hides nothing because every tenant OWNER is an Open WebUI admin (#748), was ended by supabase/migrations/20260823_03_owui_role_never_admin.sql and owui-patches/tenant_role_from_db.py, which promote only a platform admin. Every ordinary customer, tenant owners included, is Open WebUI user, so workspace.skills now gates a real audience and defaults to false upstream.

Granting it takes more than an environment variable. user.permissions is a single persisted config row holding the whole permission tree, seeded on first boot by Config.seed_defaults, and the database has outranked the environment ever since. That is the same trap hive_rag_env_config.py already exists for, so the grant goes there. It could not ride the existing flat dictionaries: a config row keyed user.permissions.workspace.skills would persist, survive a restart, and be read by nothing, because has_permission walks the tree inside the one row. The module gains a permission seam that reads the tree, merges the leaves the environment names, and writes the tree back.

Proved on a real database rather than argued: two boots against the same volume, the leaf moving False to True on the second, every sibling untouched, and no dotted row created. Transcript in the capture log.

Security

A user-authored artefact that reaches a model prompt is an input-parsing path, so this was checked rather than assumed, and the review found something the first pass had rationalised away.

Fixed here. filter_allowed_access_grants strips public grants and individual user grants from a non-admin write and never inspects group grants at all, so a hand-built payload can grant a skill to any group id on this shared instance, including another tenant's. A skill body reaches a prompt verbatim, so that is prompt injection across a tenant boundary rather than an over-broad share. The first version of this PR argued it was unreachable because GET /api/v1/groups filters to the caller's own memberships. True but not sufficient: that makes the defence secrecy of a UUID rather than an authorization check. owui-patches/apply_skill_group_grants_patch.py now drops group grants naming a group the caller is not a member of, at all three skill write sites, and the image build asserts all four markers landed.

Scoped to the skills router deliberately. The same shared function serves knowledge, models, prompts, tools, notes and folders, and changing it centrally would alter grant behaviour on surfaces this change never exercised.

Left as is. Sharing stays at upstream's defaults, sharing.skills and sharing.public_skills both false, so a skill is private to the account that wrote it.

Filed, not fixed here, because neither is small and neither is introduced by this diff:

The System Prompt question, folded in

Asked alongside the skills work: does the per-user System Prompt control render for an ordinary member, and does a value set there actually reach inference. Both measured live rather than read off the defaults, and the answer to both is yes, so no code changed for it.

Settings > General renders the textarea for a role = user account; chat.controls and chat.system_prompt both default true upstream and are true in this deployment's persisted tree. Setting it to a distinctive marker and asking a fresh question returned an answer beginning with that marker. Chat.svelte:2801-2806 prepends the value to the outgoing message list unconditionally, which is a different shape from #1360 and #1265, where an artefact is mounted or catalogued and then never read. Frame 4 of the proof comment.

Tests

Every new test was verified red first. The three that pass vacuously against today's code were each proved able to fail by a separate one-at-a-time mutation of the implementation:

  • writing a flat dotted permission row instead of the tree, caught by test_reconcile_never_writes_a_flat_dotted_permission_row;
  • writing unconditionally instead of comparing, caught by test_reconcile_skips_the_write_when_the_tree_already_agrees;
  • treating a blank environment variable as a value, caught by the existing test_env_overrides_a_stale_persisted_model.

The group-grant filter got the same treatment: allowing every group grant, and patching only one of the three write sites, each fail scripts/test_owui_skill_group_grants.py, which also carries negative controls asserting the vendored router is still unpatched and still has the hole before the build runs.

The Dockerfile's post-copy assertion block gains a positive check that the built bundle contains the skills route, so a frontend stage that silently built a tree without this change fails the image build instead of shipping green. Confirmed firing on a real build: hive: shell present, removed surfaces absent.

Review

Two streams, per the pipeline. Full text and the inline comments are on this PR.

  • CodeRabbit CLI: SKIPPED. Not a pass. rate_limit / "Rate limit exceeded" repo wide, waitTime: 51 minutes, onDemandReviewAvailable: false. Nothing was reviewed by it.
  • Antigravity (gemini-3.1-pro-high, effort high): RAN. Three findings. Two accepted and fixed in 4391c2e (the group-grant hole above, and a permission guard that sat on the index page where SvelteKit does not inherit it down to /skills/create and /skills/edit). One rejected with evidence: it claimed the reconcile tests call an async def synchronously and therefore cannot fail, which is not so, because this test file has a module-level asyncio.run wrapper the pasted diff did not show.

Recording one thing about that stream for the next person: agy executes in ~/.gemini/antigravity-cli/scratch, not in the checkout it is launched from, measured by asking it to print pwd and git rev-parse --abbrev-ref HEAD. Its first pass reviewed an unrelated diff about credit formatting that exists nowhere on this branch, and was discarded as hallucinated. The findings above come from a second run with the diff pasted inline.

Visual proof

Posted as a release-hosted comment on this PR. The behavioural frame is one chat, one model, the same question twice: the control turn answers in three sentences with no marker, the turn with the skill selected answers in one sentence ending HIVE-SKILL-PROOF-OK-2026-08-29, a string that exists nowhere but the skill body.

Substrate stated plainly, because it is not the demo box: the image built from this branch, run standalone against its own SQLite volume, signed in as an ordinary non-admin account, with inference through an OpenAI-compatible endpoint rather than the Hive gateway. Both Hive keys in this checkout are dead against api-hive.scubed.co (Incorrect API key provided and API key is revoked) and minting one needs Supabase credentials this box does not have, so live-auth.mjs could not run either. The gateway hop is not what this change touches: injection happens inside Open WebUI before a provider is chosen. What this capture does not exercise, and what a post-merge capture on the demo box still should: single sign-on, the Caddy front end, and the gateway hop.

docs/proof/user-created-skills-2026-08-29/log.md carries the full transcript, the permission-row before and after, and one incidental finding not fixed here: the vendor's "Help us translate" link is still in Settings, because the rewrite meant to remove it runs against the bundle the frontend stage discards.

Relationship to the inert-artefact issues

Deliberately not the same defect family as #1360 (agent packs mounted but never read) or #1265 (marketplace skill, rule and prompt_template kinds inert). Those are the agent engine's sandbox packs, a different subsystem, and this change neither closes nor narrows either. What makes this one different is that the last hop already existed: the injection seam in the chat middleware is real and is exercised in the proof.

Deliberately not done

  • No change to the workspace-skills-tab bundle rewrite. It operates on the discarded pinned bundle and its remaining job, digest drift detection, is still correct.
  • No Caddy change. /workspace/skills stays 404.
  • No Customize settings container.

Decision record

Recorded as D-060 in .wolf/decisions.md, a partial reversal of #783: Hive ships a user-created skills surface, and the stock Workspace tab stays removed. The id was minted on this branch; main and the other live branches were checked and none claims it.

Plan and acceptance criteria: plan-2026-08-29-user-created-skills in the project vault.

Buglog entry

To be appended to .wolf/buglog.jsonl on main in a separate buglog-only pull request after this merges, per .claude/rules/openwolf.md.

{"id":"owui-skills-guard-on-page-not-layout","date":"2026-08-29","error_message":"/skills/create and /skills/edit rendered the full skill editor for a session without workspace.skills; the save then failed with 401 from POST /api/v1/skills/create","root_cause":"the permission guard was written in routes/(app)/skills/+page.svelte. A SvelteKit +page.svelte applies to its exact route only and is not inherited by nested routes, unlike +layout.svelte, so the two editor routes had no guard at all","fix":"moved the guard into routes/(app)/skills/+layout.svelte, which covers the index and both editor routes; skills-surface.test.ts now reads the layout rather than the page","tags":["owui","svelte","authz","sveltekit-routing"],"pr":1388}
{"id":"owui-workspace-components-need-workspace-chrome","date":"2026-08-29","error_message":"the skills index rendered underneath the sidebar with the New Skill button pinned to the window corner and the title overlapping the Hive wordmark","root_cause":"Skills.svelte and SkillEditor.svelte are upstream Workspace components authored for routes/(app)/workspace/+layout.svelte's container, which supplies both the md:max-w-[calc(100%-var(--sidebar-width))] constraint and the px-3 md:px-[18px] padding. Mounting them in a bare hv-panel-region flex region supplied neither","fix":"the /skills route layout reproduces that container minus the tab bar; a test pins all three class fragments so the chrome cannot silently go away again","tags":["owui","css","layout","fork"],"pr":1388}
{"id":"owui-group-access-grants-never-filtered","date":"2026-08-29","error_message":"a non-admin could attach an access grant naming any group id on the shared instance, including another tenant's, and have it stored","root_cause":"utils/access_control.filter_allowed_access_grants strips public grants and individual user grants for a non-admin and never inspects group grants at all. For skills that is cross-tenant prompt injection, because a skill body is appended verbatim to the chat request as a system message. Unreachable in practice only because GET /api/v1/groups filters to the caller's own memberships, which is secrecy of a UUID rather than an authorization check","fix":"owui-patches/apply_skill_group_grants_patch.py drops group grants naming a group the caller is not a member of, at all three skill write sites; the image build asserts all four markers landed. The general case across the other routers is filed as #1396","tags":["owui","security","authz","multi-tenancy","prompt-injection"],"pr":1388}
{"id":"agy-runs-outside-the-repo","date":"2026-08-29","error_message":"an Antigravity review returned detailed findings about a credit-formatting diff that does not exist on the branch under review","root_cause":"agy executes in ~/.gemini/antigravity-cli/scratch, not in the directory it is launched from. Measured by asking it to print pwd, git rev-parse --abbrev-ref HEAD and git diff --stat: the first returns the scratch path and both git commands fail with 'not a git repository'. Given no diff to read, it produced a plausible review of something else","fix":"paste the diff and the load-bearing surrounding code inline in the prompt rather than telling it to run git; discard any pass whose findings name files not in that paste","tags":["harness","review-streams","antigravity","false-green"],"pr":1388}
{"id":"shared-scratchpad-filename-collision-crossed-prs","date":"2026-08-29","error_message":"six review threads on PR #1083, another agent's pull request, were resolved by an agent that had never read them","root_cause":"agents in one session share a scratchpad directory. A helper written to the plain name resolve-threads.sh was overwritten by another agent's script of the same name, which targeted pull request 1083, and running it acted on their PR. Same failure class as the known parallel-agent report-path collision, but the blast radius is a GitHub mutation rather than a lost file","fix":"unresolved all six immediately to restore the state found, left a note on #1083 saying what happened and that the restore was blind rather than a judgement, and prefixed every later script with the worktree id","tags":["harness","parallel-agents","scratchpad","github"],"pr":1388}

The owner asked for the ability to add new skills. Everything behind one
already worked and was never removed: the skill table and its migration, the
/api/v1/skills router, and the prompt injection in the chat middleware that
appends a selected or mentioned skill's content as a system message. Only two
things were missing, a route that renders the editor and the permission that
lets an ordinary customer reach the create endpoint.

The surface ships at /skills with its own shell navigation row rather than as
a restored Workspace tab. Issue #783 removed that tab and Caddyfile.owui still
404s its path, so a restored tab would have needed a proxy rule reversed to
land on a path the target navigation deletes anyway. The Hive route needs no
proxy change and sits outside the workspace layout's permission guard, for the
same reason issue #1109 moved Knowledge to /knowledge.

The permission is newly load bearing rather than newly invented. Until
2026-08-23 every tenant owner was promoted to Open WebUI admin and passed
every surrounding role-or-permission gate regardless; since
20260823_03_owui_role_never_admin.sql only a platform admin is, so
workspace.skills now governs every ordinary customer and defaults to false
upstream. Granting it needs more than an environment variable, because
user.permissions is a single persisted config row seeded on first boot: the
database has outranked the environment ever since, exactly the trap
hive_rag_env_config.py already exists for. That module gains a seam for
leaves inside the permission tree, which it reads, merges and writes back
whole. A dotted config key would have written a row nothing reads.

Sharing is deliberately left at upstream's defaults, both false, so a skill is
private to the account that wrote it. Cross-tenant injection was checked
rather than assumed: filter_allowed_access_grants strips public grants from a
non-admin write, and GET /api/v1/groups filters to the caller's own
memberships, so a member can target only their own tenant's group.

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

coderabbitai Bot commented Aug 29, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 31 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: 75c435c3-f617-442d-a98c-5951c35018d8

📥 Commits

Reviewing files that changed from the base of the PR and between c9e1419 and e3be1a0.

📒 Files selected for processing (22)
  • .wolf/decisions.md
  • Makefile
  • deploy/docker/Dockerfile.open-webui
  • deploy/docker/docker-compose.yml
  • deploy/docker/owui-patches/apply_skill_group_grants_patch.py
  • deploy/docker/owui-patches/hive_rag_env_config.py
  • deploy/docker/owui-patches/hive_ui_surfaces.py
  • docs/owui-fork.md
  • docs/proof/user-created-skills-2026-08-29/log.md
  • scripts/test-owui-hive-frontend.sh
  • scripts/test_owui_rag_env_config.py
  • scripts/test_owui_skill_group_grants.py
  • vendor/open-webui/src/lib/components/workspace/Skills.svelte
  • vendor/open-webui/src/lib/components/workspace/Skills/SkillEditor.svelte
  • vendor/open-webui/src/lib/hive/ShellNavIcon.svelte
  • vendor/open-webui/src/lib/hive/nav.test.ts
  • vendor/open-webui/src/lib/hive/nav.ts
  • vendor/open-webui/src/lib/hive/skills-surface.test.ts
  • vendor/open-webui/src/routes/(app)/skills/+layout.svelte
  • vendor/open-webui/src/routes/(app)/skills/+page.svelte
  • vendor/open-webui/src/routes/(app)/skills/create/+page.svelte
  • vendor/open-webui/src/routes/(app)/skills/edit/+page.svelte

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.

…group grants

Two findings from the pre-merge review, plus the layout defect the first proof
capture showed.

The permission guard was on the index page, and SvelteKit does not inherit a
page's script down the tree, so /skills/create and /skills/edit rendered the
full editor for a session that may not write skills. Moving the guard into a
route layout covers all three, and the layout is also where the page chrome
belongs: these are upstream Workspace components and a bare flex region gave
them neither the sidebar-width constraint nor the horizontal padding, so the
list rendered underneath the sidebar and the New Skill button was pinned to the
window corner.

The second finding is a real hole and it is the reason this needs a backend
patch. Open WebUI's shared grant filter strips public grants and individual
user grants from a non-admin write and never inspects group grants at all, so
a hand-built payload can grant a resource to any group id on this shared
instance, including another tenant's. Skills are where that matters most,
because a skill body is appended verbatim to the chat request as a system
message, which makes a cross-tenant grant prompt injection rather than an
over-broad share. It is not reachable today, since a non-admin cannot
enumerate another tenant's group id, but that is secrecy of an identifier
rather than an authorization check, and one future surface that echoes a group
id to a non-member removes it. The build-time patch drops group grants naming
a group the caller is not a member of, at all three skill write sites.

Scoped to the skills router deliberately. The same shared function serves
knowledge, models, prompts, tools, notes and folders, and changing it centrally
would alter grant behaviour on surfaces this change never exercised. Issue
#1396 tracks that general case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WyEwUxZCArdn1ZUDkTvuQ1
@sakibsadmanshajib
sakibsadmanshajib marked this pull request as ready for review August 29, 2026 09:26
@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.

@sakibsadmanshajib sakibsadmanshajib left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pre-merge review, two streams

Stream 1, CodeRabbit CLI: SKIPPED. Not a pass. coderabbit review --agent --base main returned rate_limit / "Rate limit exceeded", repo wide, with waitTime: 51 minutes and onDemandReviewAvailable: false. Nothing was reviewed by it, and its absence must not be read as a clean result.

Stream 2, Antigravity (gemini-3.1-pro-high, effort high): RAN. One correction worth recording for the next person: agy executes in ~/.gemini/antigravity-cli/scratch, not in the checkout it is launched from, measured by asking it to print pwd and git rev-parse --abbrev-ref HEAD. Its first pass reviewed an unrelated diff about credit formatting that exists nowhere on this branch, and was discarded as hallucinated rather than acted on. The findings below come from a second run with the diff and the load-bearing surrounding code pasted inline.

Stream 3, security pass: RAN as part of stream 2's brief, because a user-authored artefact that reaches a model prompt is an input-parsing path. Its finding is the one that changed the diff.

Three findings, two accepted and fixed in 4391c2e, one rejected with evidence. Details inline.

Comment thread deploy/docker/owui-patches/apply_skill_group_grants_patch.py
Comment thread vendor/open-webui/src/routes/(app)/skills/+layout.svelte
Comment thread scripts/test_owui_rag_env_config.py
The screenshots go to the visual-proof release rather than into the tree, per
the merge policy that deletes every branch. This log is the half that has to
be committed, because npm run lint:proof-tokens scans docs/proof/ and nothing
else.

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

Copy link
Copy Markdown
Owner Author

Visual proof

A member creates a skill through the UI, and it changes the model's answer in the very next turn. Substrate: hive-open-webui built from this branch, run standalone, ordinary non-admin account, inference through an OpenAI-compatible endpoint. Frame 1 is the behavioural claim: one chat, one model, the same question twice, control turn three sentences with no marker, skill turn one sentence ending HIVE-SKILL-PROOF-OK-2026-08-29, a string that exists nowhere but the skill body. Frames 2 and 3 are the creation flow. Frame 4 answers the folded-in question: a per-user System Prompt also reaches inference. Full log and the limits of this substrate: docs/proof/user-created-skills-2026-08-29/log.md

pr1388-20260829093759-22896-sk-03-turns.png

pr1388-20260829093801-26519-sk-02-saved.png

pr1388-20260829093804-12683-sk-01-editor.png

pr1388-20260829093807-19713-sk-07-system-prompt.png

@sakibsadmanshajib
sakibsadmanshajib merged commit 22cfbfa into main Aug 29, 2026
30 checks passed
@sakibsadmanshajib
sakibsadmanshajib deleted the feat/user-created-skills branch August 29, 2026 10:00
sakibsadmanshajib added a commit that referenced this pull request Aug 29, 2026
…1405) (#1426)

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

```json
{"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"]}
```
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>
sakibsadmanshajib added a commit that referenced this pull request Aug 29, 2026
…l name/id uniqueness to the tenant (#1397) (#1437)

## Summary

PR #1388 shipped a live, user-facing Skills authoring surface reusing
upstream Open WebUI's `skills.py`, but that router never joined the
#1186 tenant-isolation sweep, and its `skill.name` column (plus the
primary-key `id` slugified from it) is unique instance-wide on a chat
backend shared by every Hive tenant. This PR closes both gaps for
`skills.py`.

**#1186 half.** Extends `apply_router_authz_family_patch.py`, the file
that already fixes this exact bare `user.role == 'admin'` bypass shape
on 11 sibling routers, with `skills.py`'s five unflagged sites: the
by-id read (`GetSkillById`), toggle, update, access-update and delete
gates. The three listing routes (`GET /`, `GET /list`, `GET /export`)
already flag-gated correctly and needed no change. Adds the matching
consolidated drift-guard assertion to `Dockerfile.open-webui`.

**#1397 half**, three new files, following the established
`apply_*_patch.py` / `hive_*.py` shape:
- `hive_skill_tenant_scope_migration.py`: a new Alembic migration that
drops the single-column unique on `name` and adds a nullable
`tenant_group_id` column plus a composite `(tenant_group_id, name)`
unique index. Raw-SQL table rebuild, because SQLite has no `ALTER TABLE
DROP CONSTRAINT` and the live DDL on the demo box confirms `UNIQUE
(name)` is a real table-level constraint, not a droppable named index.
- `apply_skill_tenant_scope_model_patch.py`: teaches
`models/skills.py`'s `insert_new_skill` to persist `tenant_group_id`,
never exposed on any API response (no `SkillModel`/response subclass
declares it).
- `apply_skill_tenant_scope_router_patch.py`: resolves the caller's
create-time **id scope** and prefixes `id` with it. `id` is the primary
key and a bare `slugify(name)`, and the id check runs BEFORE name
uniqueness in `CreateNewSkill`, so it is the one that actually collides
first.

## Who is protected, precisely (corrected after independent security
review)

An earlier revision of this PR overstated what the id-scope logic did.
The security reviewer traced `_hive_resolve_tenant_group_id` and the
id-prefix computation and found that two different ungrouped accounts
both resolved to the same empty prefix, so they still collided on the
bare id and still got 400 `ID_TAKEN`, exactly as before this change. The
PR body's "no error" / "ungoverned personal namespace" claim for that
case was wrong. Fixed, not just reworded: the id scope is now a real
three-case fallback (`_hive_id_scope` in
`apply_skill_tenant_scope_router_patch.py`):

1. **A tenant-grouped ordinary member** scopes to their `tenant_<uuid>`
OWUI group, shared with every other member of that tenant. This is the
case #1397 is actually about, and it is now fully closed: two different
tenants can use the same skill name and neither the id nor the name
collides.
2. **An ungrouped ordinary member** (checked live against the demo box:
this is the COMMON case today, not an edge case -- only one `tenant_*`
group exists on the whole instance, with one member, and neither
`demo@hive-demo.invalid` nor the platform admin account is in any
`tenant_*` group) now falls back to their own `user_id` as the id scope,
instead of getting no scope at all. Two unrelated ungrouped members can
now each have a skill called "Research". This closes #1397 for these
accounts too, not just for the provisioned minority, and reuses
`user_id`, which already exists on every skill row, rather than
inventing a new identity concept.
3. **A platform admin** gets no scope at all, deliberately: an admin is
not a tenant customer, and a shared, flat, platform-wide id namespace
for admin-published skills is the intended behavior (two admin accounts
publishing "Research" SHOULD collide, matching a shared catalogue).

The persisted `tenant_group_id` DB column stays the narrow, honest fact
(the real tenant group, or `NULL`) and is NOT widened to the user-id
fallback: `tenant_group_id` promises a tenant, `user_id` is already a
separate column with that exact meaning, and conflating them would
mislead a future reader. This is safe for the `name` column too, not
just `id`: NULL is never equal to NULL under a UNIQUE index (SQLite,
same as Postgres/MySQL), so two ungrouped members' skills, both
`tenant_group_id = NULL`, already do not collide on `name` either, with
no column change needed. Only `id` (the primary key, whose default was
an unprefixed bare slug shared by everyone with no scope) needed the
fallback.

New regression test added at the reviewer's request
(`scripts/test_owui_skill_tenant_scope.py`): two different ungrouped
members computing a create-time id for the same display name now get two
different ids; the same ungrouped member reusing their own name still
gets the same id (duplicate detection within one account is not
accidentally disabled by the fallback).

## What layer actually denies a cross-tenant read or write

Stated explicitly, per the reviewer's note, so nobody later assumes
database enforcement that does not exist: **pure Python application
logic** in `routers/skills.py` -- ownership (`skill.user_id ==
user.id`), `AccessGrants`, and `BYPASS_ADMIN_ACCESS_CONTROL` being
`false`. This is a plain SQLite file with no row-level-security concept
at all. Issue #896 (a permissive blanket-allow RLS policy) is on a
completely unrelated Supabase Postgres table, `account_memberships`, and
has no bearing on this SQLite skills table.

## Not in scope

#1396 (group access grants not filtered against another tenant's group
id). The skills half of #1396 was already closed by PR #1388's
`apply_skill_group_grants_patch.py`, which this PR reads, tests against
in the exact same production application order, and does not modify. The
general cross-router #1396 issue stays open.

## What the follow-up authoring-UI PR may now rely on

`skill.id` and `skill.name` are unique within the caller's tenant (or,
for an ungrouped account, within that individual account) rather than
instance-wide, so the UI need not pre-check for a cross-tenant name
collision. Every by-id read/update/toggle/delete route consults
`BYPASS_ADMIN_ACCESS_CONTROL` before letting a bare admin role through,
matching every other router in this deployment. Both grouped and
ungrouped accounts now get a working namespace: this closes #1397's
practical blast radius for everyone on the deployment, not only accounts
with a real `tenant_*` group. **This now unblocks that second PR**:
authoring was blocked on scoping actually working, and per the above it
now does, for both the grouped and ungrouped case.

## Verification

**Automated** (`python3 scripts/test_owui_skill_tenant_scope.py`, 33/33
checks green):
- Runs the real migration SQL against a genuine `sqlite3` connection
seeded from the real old-schema DDL. Negative control reproduces the bug
pre-fix. Fix proven post-rebuild. **Mutation test**: a genuine
same-tenant name collision still fails post-fix. A pre-existing row
survives the rebuild untouched. The migration's `down_revision` is
independently recomputed from all 48 vendored migration files on every
test run and asserted to match the actual chain head (a self-review
catch: an earlier draft named a mid-chain revision as the head, which
would have branched Alembic's chain on deploy; fixed, and this assertion
is a permanent regression guard against it recurring on a future vendor
bump).
- Applies both new source patches to the real vendored
`skills.py`/`models.py`, in isolation and idempotently, asserts marker
counts, confirms valid Python.
- Extracts and exercises `_hive_resolve_tenant_group_id` AND the new
`_hive_id_scope` directly against fake group memberships and fake users,
including the reviewer's exact regression case (two ungrouped members,
same name, different resulting ids) and a same-account
duplicate-detection control.
- Separately confirmed the `#1186` family extension, the existing
`#1396` patch, and the `#1397` router patch chain cleanly on the real
vendored `skills.py`, in production Dockerfile order, landing 5 + 4 + 3
markers with no collision in any application order.

**Live, against the real demo box**, fully reversible, zero footprint
after. Used the same server-side token-mint technique
`deploy-demo-box.yml` already uses in production
(`scripts/owui-mint-admin-token.py`'s pattern: a 5-minute JWT signed
with the container's own `WEBUI_SECRET_KEY`, no password, no OAuth
journey, no SOCKS proxy). Two pre-existing e2e fixture accounts, added
to two throwaway `tenant_*` OWUI groups created and deleted by the check
script. Neither `demo@hive-demo.invalid` nor the platform admin account
was touched.

Against the CURRENT, unpatched production code (this PR not yet
deployed):

| Step | HTTP status | Body |
|---|---|---|
| Tenant A creates skill "Research" | 200 | `{"id":"research", ...}` |
| Tenant B (different tenant) creates "Research" | **400** | `Uh-oh!
This id is already registered. Please choose another id string.` |
| Tenant B reads Tenant A's skill by id | 401 | `You do not have
permission...` |
| Tenant B overwrites Tenant A's skill | 401 | `401 Unauthorized` |
| Tenant B deletes Tenant A's skill | 401 | `401 Unauthorized` |

This reproduces the exact #1397 bug live and today. The unpatched code
computes the same bare, unprefixed id for every account regardless of
tenant-group membership, so this same table is also the live "before"
evidence for the ungrouped-account case: two ungrouped accounts collide
via the identical mechanism, which is exactly what the new id-scope
fallback fixes.

A follow-up live re-verification after this PR merges and
`deploy-demo-box.yml` redeploys should confirm both a grouped-tenant
pair AND an ungrouped pair now get 200 with two different final ids,
plus a same-account repeat create still returning `ID_TAKEN`. That is an
orchestrator-owned step (merge triggers deploy in this repo), not a
builder action.

Full plan/spec/verification writeup: vault
`plan-2026-08-29-skills-tenant-scoping.md`.

## Buglog entry

```json
{"date": "2026-08-29", "error_message": "POST /api/v1/skills/create from a second tenant fails with \"Uh-oh! This id is already registered\" when reusing a display name already used by an unrelated tenant on this shared Open WebUI instance", "root_cause": "skill.id (PK, slugified from name) and skill.name were both unique instance-wide (vendor/open-webui/backend/open_webui/models/skills.py), and skills.py never joined the #1186 sweep that flag-gated the bare admin-role bypass on 11 sibling routers", "fix": "Alembic migration drops the single-column unique and adds a composite (tenant_group_id, name) unique index; router patch resolves a three-case id scope (tenant group, else the caller's own user id, else none for admin) and prefixes the create-time id; apply_router_authz_family_patch.py gains skills.py's five #1186 sites", "tags": ["security", "multi-tenant", "owui", "skills", "sqlite-migration"]}
```

## Test plan

- [x] `python3 scripts/test_owui_skill_tenant_scope.py` (33/33 green,
includes the reviewer's exact regression test and two independent
mutation tests)
- [x] All three `skills.py` patches (`#1186`, `#1396`, `#1397`) apply
cleanly to the real vendored source, chained in production Dockerfile
order
- [x] Live two-tenant reproduction against the real demo box (before-fix
baseline, covers both the grouped and ungrouped collision mechanism)
- [x] Independent security review: APPROVE, with the one finding above,
now fixed
- [ ] Post-merge: confirm `deploy-demo-box.yml` succeeds, then re-run
the live two-tenant check (grouped AND ungrouped pairs) to confirm the
after-fix state

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01WyEwUxZCArdn1ZUDkTvuQ1

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
sakibsadmanshajib added a commit that referenced this pull request Aug 29, 2026
…a name collision readable

PR #1388 shipped the user-created skills library at /skills. Two gaps it left,
both found by capturing the surface on the deployed box rather than by reading
the diff.

The surface was swept by nothing. The chat-coverage engine enumerated home, the
sidebar, the model picker, the three composer surfaces, the user menu, search,
workspace, the chat menus and the settings and workspace tabs, and /skills was
in none of them. A frontend regression, a proxy rule that 404s the route, or
user.permissions.workspace.skills reverting to its upstream default of false
would each have emptied the surface with every gate still reporting green. It
is now a swept surface with a presence bar, classified data driven because the
index renders one row per skill the account owns.

The duplicate-name error named a field the author never filled in. skill.id is
unique across the whole instance and the editor slugifies it from the name, so
two accounts naming a skill the same thing collide and upstream answers with
"Uh-oh! This id is already registered. Please choose another id string." The
skill holding that id is almost always one the author cannot read, so they
search their own library, find nothing, and read it as a broken product rather
than a taken name. The create route now explains that the name is taken, that
the holder may be an account they cannot see, and that the id field is the
escape hatch. It names neither the conflicting skill nor its owner: a global
uniqueness check already leaks the bare fact that an id is in use and no
wording takes that back, but naming the holder would turn an unavoidable
existence oracle into a real disclosure. Every other failure passes through
verbatim.

Issue #1397 and PR #1437 scope uniqueness to the tenant, which is the real fix.
This message still matters after that lands, because most accounts on this
deployment have no tenant group at all and share one namespace regardless.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WyEwUxZCArdn1ZUDkTvuQ1
sakibsadmanshajib added a commit that referenced this pull request Aug 29, 2026
…a name collision readable (#1451)

## Why this is not the feature

The task that produced this branch was written to build the user-created
skills surface. It was already built: PR #1388 merged it at
2026-08-29T10:00:09Z, and `deploy-demo-box.yml` run 33261985611 put it
on the box. Rebuilding it would have been waste, so this branch verifies
what shipped on the deployed substrate and fixes the two gaps that
verification found.

Everything in the original brief that was framed as missing is present,
and was confirmed live rather than read off the diff:

- `/skills` returns 200 through Caddy and `/workspace/skills` still
returns 404, so the surface issue #772 removed did not come back with
it.
- `user.permissions.workspace.skills` is `true` in the running
container's own store, with every sibling untouched and no flat dotted
row. That is the #722 persisted-config trap cleared on the real volume,
which is the single claim most able to be true in the repository and
false on the box.
- An ordinary member signed in through the real OIDC hop, opened the
editor, saved a skill, saw it listed, and had it offered by the `$`
mention picker.

PR #1388's own capture log named what it could not exercise, in its own
words: "single sign-on, the Caddy front end, and the gateway hop". Its
proof ran a standalone container against a throwaway volume with
inference pointed at Groq.
`docs/proof/skills-live-postmerge-2026-08-29/log.md` is that missing
capture.

## Gap one: the surface was outside the coverage denominator

`e2e/chat-coverage/surfaces.ts` swept `home`, the sidebar, the model
picker, the three composer surfaces, the user menu, `search`,
`workspace`, the chat menus, and the settings and workspace tabs.
Nothing swept `/skills`, and `discoverWorkspaceTabs` never could: it
enumerates `a[href^="/workspace/"]`, and this surface deliberately does
not live there.

Three separate things would have emptied the surface with every gate
still green: a frontend regression, a proxy rule that 404s the route,
and `workspace.skills` reverting to its upstream default of `false`.
That is the quiet-absence shape this repository keeps paying for, so the
surface is now swept and floored.

The floor is a presence bar of 1, classified `dataDriven`, because the
index renders one row per skill the signed-in account owns and pinning
that count reds the gate whenever a run creates or deletes one. A floor
of 1 still fails when the surface stops rendering, and a floor key a run
never sweeps fails too, so deleting the entry point is caught rather
than silently shrinking the denominator. Measured live rather than
guessed: the index enumerated 47 controls with one skill owned and 42
with none, both far above the bar.

## Gap two: the duplicate-name error named a field the author never
filled in

`skill.id` is unique across the whole instance and the editor slugifies
it from the name, so two accounts naming a skill "Research" collide.
Upstream answers with `ERROR_MESSAGES.ID_TAKEN`: "Uh-oh! This id is
already registered. Please choose another id string."

That misdirects twice. It names a field the author never filled in, and
the skill holding the id is almost always one they cannot read, so they
search their own library, find nothing called Research, and read a taken
name as a broken product.

`lib/hive/skill-save-error.ts` rewrites that one sentence and passes
every other failure through verbatim, because a rewrite that catches
more than it means to is how a permission failure, a network failure and
a validation failure all come to read as the same shrug. The regex is
anchored on the whole upstream sentence, so the sibling `MODEL_ID_TAKEN`
is not swept up with it.

It deliberately names neither the conflicting skill nor its owner. A
global uniqueness check already leaks the bare fact that an id is in
use, and no wording takes that back while the check exists, but naming
the holder would turn an unavoidable existence oracle into a real
disclosure on an instance where reads are otherwise denied cross-account
by ownership plus access grants.

The create route is source-pinned to the helper. A helper nothing calls
passes its own unit tests forever while the author on the box still
reads the upstream sentence, so an unwiring fails here instead of in
front of a customer. Proved able to fail by reverting the wiring: both
pins went red, then green again.

Only the create route is wired. `update_skill_by_id` raises no
`ID_TAKEN`, so the edit route cannot produce this failure and is
untouched.

## Gap three: a save could fail with no message at all

Found while checking whether the rewrite would ever fire, which turned
out to be the load-bearing question in the whole diff.

`createNewSkill` does not reject on every failure. Its own catch does
`error = err.detail` and rethrows only `if (error)`, so when the error
body is not JSON carrying a `detail` key it leaves `error` undefined and
resolves the promise with `null` instead. A network failure, a proxy
error page whose HTML body makes `res.json()` throw, and any backend
error using a different payload shape all take that path.

The route's `.catch` therefore never ran for them, and `if (res)` had no
else arm. The author clicked Save and nothing happened at all: no toast,
no navigation, no indication whether the skill had been created. That is
the same quiet-absence failure the rest of this PR exists to remove,
sitting inside the handler it changes.

The guard is in the route rather than in the shared upstream client,
which every other skills caller uses and which this change exercises
nowhere else.

## Gap four: the live coverage gate could not run at all

Found while running the `surfaces=^skills$` sweep review asked for, and
not scoped to this surface.

The collapsed Hive shell renders **two** controls named "Open Sidebar":
`count()` is 0 while the sidebar is open and 2 the moment it closes.
Playwright's strict mode makes `isVisible()` on a two-element locator
throw, and the `.catch(() => false)` beside it in `ensureSidebar` turned
that throw into "not visible". The function clicked "Close Sidebar",
succeeded, looked for the control proving the new state, got a
strict-mode error instead of a boolean, and threw `the sidebar is not
closed and neither toggle is on screen`.

Every surface that pins the sidebar shut inherited it: `sidebar` and
every `clickTop` surface, so the model picker, the three composer
surfaces, the user menu and search. The live gate could not complete
against the shipped shell, and nothing said so, because `live-sweep`
runs only on `workflow_dispatch` or the `run-chat-coverage` label and a
dispatch dies earlier still.

`.first()` on both locators, which is what every other `getByRole` in
that file already does.

## The sweep, and why CI could not run it

`gh workflow run chat-coverage.yml -f surfaces='^skills$'` (run
33267620156) failed its own input check before reaching Playwright: `the
live sweep needs: HIVE_QA_AGENT_EMAIL SUPABASE_ANON_KEY
SUPABASE_SERVICE_ROLE_KEY`. All three are empty in this repository's
Actions environment, so that stream is **SKIPPED**, not passed. Filed as
#1459.

The sweep was therefore run directly against the deployed box, driving
the real engine rather than a description of it: the same
`STATIC_SURFACES` entry, the same `enumerate` and `SELECTOR`, the same
delta subtraction, the same `isDestructive` and `isStateful` partition,
the same `proveByClick` and the real `valuePass`.

```
baseline sidebar pinned: true
baseline (home, sidebar collapsed): 27 controls
/skills enumerated 13 raw, 3 after the delta subtraction
clickable: 2, stateful: 1

{ "skills": { "total": 3, "proven": 3, "deferred": 0, "unproven": [] } }

PROVEN  Search Skills [input]  proof=value     -> hive-coverage-50095
PROVEN  New Skill [a]          proof=navigate  /skills -> /skills/create
PROVEN  Select view [button]   proof=dom       what is on screen changed
```

Three controls, three proven, none unproven. The 13-to-3 subtraction is
the `delta` fix measured: ten of the thirteen were shell, now attributed
where they belong instead of counted twice. This is the empty-index
case, which is also the weakest case; a populated index adds rows and a
per-row switch, which is what the `dataDriven` classification keeps from
being pinned.

## Merge order, satisfied

**#1437 has merged** as `c205afb45`, closing #1397, and this branch is
rebased onto it. The ordering constraint the earlier revisions of this
description carried is discharged.

That merge changed what a collision means, not only where uniqueness
applies, so the message was corrected rather than left alone.
`apply_skill_tenant_scope_router_patch.py` prefixes the id with a scope,
and that scope is the caller's tenant group, or their own user id when
they have none, or nothing at all for a platform admin. `ID_TAKEN` now
fires only on a genuine duplicate inside the caller's own scope.

For the common ungrouped account on this deployment the colliding skill
is therefore **one of their own**, sitting in their own list. The
earlier wording, "may belong to an account you cannot see", would have
sent them looking away from the one place it actually is. It now reads
"It may be one of your own skills, or one you do not have access to
see", which is true for an ungrouped member colliding with themselves, a
grouped member colliding with a peer, and an admin on the unscoped
namespace.

Dropping the scope claim during review is what made this a wording
change rather than a factual correction: any scope word would have been
wrong for some population of accounts here.

## Security

Skills are user-authored text injected into a model prompt, so this was
checked rather than assumed, including the parts this diff does not
change.

- **No skill field is rendered as markup anywhere.** `{@html` appears in
none of `Skills.svelte`, `Skills/SkillEditor.svelte`,
`SkillsModal.svelte`, `MessageInput/Commands/Skills.svelte`,
`CommandSuggestionList.svelte` or `MentionToken.svelte`. Name,
description and content all go through Svelte's escaping, so there is no
stored-XSS path from a skill field into another viewer's DOM.
- **Prompt injection is real and is self-scoped.** A skill body reaches
the system message verbatim by design. `sharing.skills` and
`sharing.public_skills` are both `false` on the deployed tree, confirmed
in the live permission row, so a skill is private to the account that
wrote it and a hostile body can only steer its own author's model. PR
#1388's `apply_skill_group_grants_patch.py` already closed the
group-grant hole that would have let one reach anyone else; #1396 tracks
the same hole on the surfaces that still have it.
- **The new error message discloses nothing new.** It reveals exactly
what the 400 already revealed, that an id is in use, and withholds the
name and the owner.
- **Enforcement here is application logic, not a database layer.** Issue
#896's permissive row level security is on an unrelated Supabase table
and does not touch the Open WebUI SQLite skills table, so nothing behind
this is backstopped by the database.

## Storage, unchanged and noted

Skills remain rows in the Open WebUI SQLite volume, outside Supabase,
outside every Hive migration and outside row level security. That was
decided deliberately and is not revisited here. The limitation and what
a real fix would cost are filed as **#1454**. Worth recording for the
next reader, because two existing notes are wrong about it: the live
database is `/data/webui.db`, not `/app/backend/data/webui.db`, which
exists at zero bytes and is a decoy, and its `config` table is key/value
rather than the single JSON `data` blob the older notes describe.

## What is not proven

The model's answer on the box. The turn was composed with the skill
attached and sent, `/api/chat/completions` returned 200, and the surface
then rendered "You're out of credits." The account holds no credit, so
no completion was produced. Nothing on the skills path failed.

The injection half is not unproven in general: #1388 proved it against
the identical tree, one chat and one model, the same question twice,
where only the turn carrying the skill ended in a token that exists
nowhere but the skill body. What stays unproven is that hop on this box
with a funded account, and it is recorded as unproven rather than
asserted.

## Tests

- `scripts/test-owui-hive-frontend.sh`: 258 passing, up from 252. The
same files run in place at image build time via `npm run test:frontend`,
so a failure here fails the image build.
- `apps/web-console` unit: 31 passing in `chat-coverage-lib.test.ts`.
- The live sweep above, which is the assurance the floor alone cannot
give.
- Every new test verified red before the implementation, and the two
source pins verified able to fail by reverting the wiring rather than
assumed to be able to.
- `node tools/lint-no-token-in-proof-captures.mjs`: ok, 223 files
scanned.

## Buglog entry

```json
{"id":"skills-surface-outside-coverage-denominator","date":"2026-08-29","error_message":"The /skills surface shipped by PR #1388 was swept by no chat-coverage surface, so a frontend regression, a new proxy 404 rule, or user.permissions.workspace.skills reverting to false would each have emptied it with every gate still reporting green.","root_cause":"surfaces.ts enumerates workspace tabs via a[href^=\"/workspace/\"] and lists every other surface by hand. A Hive route at /skills matches neither, so a newly shipped destination joins the sweep only if someone remembers to add it, and nothing fails when they do not.","fix":"Added a swept `skills` surface to STATIC_SURFACES and a dataDriven presence bar of 1 in surface-floors.json, with unit cover asserting the surface is swept, floored at a presence bar, and that an empty sweep fails the gate. Also rewrote the instance-wide id collision message, which named a field the author never filled in and pointed at a skill they cannot read.","tags":["chat-coverage","owui-fork","skills","silent-absence","observability"]}
```

Follows PR #1388. Blocked on PR #1437. Related: #1396, #1397, #1454,
#1459.

Plan and verification record:
`hive/plan-2026-08-29-user-created-skills-verification.md` in the vault.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
sakibsadmanshajib added a commit that referenced this pull request Aug 29, 2026
…and-written list

The list in this file named the ten routers apply_router_authz_family_patch.py
rewrote when it was written. Main has since added skills.py to that family (PR
#1388), the patch reads every file in its own EXPECTED_MARKERS, and it raises
FileNotFoundError on a missing one, so the check went red on a merge that had
nothing to do with chat deletion.

Copying every router out of the vendored directory removes the second copy of
that list, so there is nothing left to keep in step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WyEwUxZCArdn1ZUDkTvuQ1
sakibsadmanshajib added a commit that referenced this pull request Aug 29, 2026
…claim (issues #916, #848) (#1462)

Closes #916. Addresses the standing half of #848.

## The premise turned out to be wrong, and the wrong premise was the bug

`docs/live-test-auth.md` has said, since it was written, that on the
demo account every chat, task and key is "today undeletable: there is no
chat-delete, task-delete or account-delete route wired up yet".

That is false for chats, and the false half is load bearing. It is the
reason the litter sat there: an agent reading that line concluded there
was nothing to be done, so nobody tried. Documentation that is wrong is
worse than documentation that is missing, because it is believed.

| Surface | Delete route | Reality |
| --- | --- | --- |
| Chats | **Exists, and always did** | `DELETE /api/v1/chats/{id}` is
live, reachable from the sidebar row menu behind a confirm dialog, hard
deletes, and resolves through the user-scoped lookup for every caller on
this deployment. Three conditions attach; see below |
| Agent tasks | **Genuinely absent** | create, list, get, cancel,
events, files, and no DELETE
(`apps/control-plane/internal/agenttask/http.go:51-111`) |
| Accounts | **Genuinely absent, on purpose** |
`tenant_billing_accounts.account_id` references `accounts(id)` `ON
DELETE RESTRICT` so deleting an account still funding a live tenant
fails loudly (#828) |

## So this pull request builds no delete route

Building a second one would be duplicated work. What was actually
missing is enforcement, and that is what this ships.

**1. The demo-account rule is now enforced rather than merely written
down, on both sides of the repository.**

`mintSession` in `live-auth.mjs` is the single door every JavaScript
live session already passes through. It now refuses
`demo@hive-demo.invalid` unless the caller declares the run read only
(`readOnly: true`, or `--read-only` on the CLI).

It was not the single door for the repository, and review was right to
say so. Three Python scripts reach a deployed environment without
importing it. `verify-control-plane.py` carried the rule only as a
sentence in its docstring, with no check at all. `post-deploy-verify.py`
did check, with an exact, case-sensitive `==`, so
`Demo@hive-demo.invalid` or a trailing space walked past it.
`verify-rag-roundtrip.py` was safe by accident of a hardcoded literal.
All three now share one normalised implementation,
`scripts/shared_demo_account.py`, matching the JavaScript guard's
trim-and-lowercase.

Be honest about what that is: a declaration gate, not a write blocker.
It cannot stop a run that has declared itself read only from then
sending a message. What it removes is the silent default, so pointing a
suite at that account becomes a deliberate act visible in a diff. A real
write blocker needs a per-request proxy, which is a great deal of
machinery for a rule one line can state. The code comment says exactly
this rather than overselling it.

No existing caller breaks: nothing in `apps/` or `.github/` mints for
that address today. The module's own usage example did name it, and is
updated.

**2. A structural guard on the authorization boundary, asserted against
the source the image actually runs.**

The first version of `scripts/test_owui_chat_delete_authz.py` read
`vendor/open-webui/backend/...`. Review pointed out that this source
does not ship: `Dockerfile.open-webui` builds only the frontend from
`vendor/open-webui` and keeps the pinned upstream image for the Python
backend, then rewrites `routers/chats.py` inside that image at build
time. A guard reading the pre-patch copy is an upstream-bump detector
wearing an authorization-guard docstring, and it is blind to exactly the
risk it names.

It now runs the real patch scripts, in the Dockerfile's order, against
copies of the vendored source, and asserts on the patched result. That
is the harness `scripts/test_owui_knowledge_authz.py` already uses.

Doing so immediately surfaced something the old check could not see, and
it sharpens the claim in the title. The patch narrows the admin arm to
`if user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS:` (#1186), and
`docker-compose.yml` sets that variable to `"false"`. So on this
deployment nobody reaches the unscoped path and every caller,
administrator included, deletes through `get_chat_by_id_and_user_id`.
Both halves are now pinned, because both are load bearing: upstream's
unguarded admin arm would matter here, since every tenant OWNER holds an
administrator session (#748, #948).

What else the guard pins, all of it new since review:

* The **order** in the non-admin arm, not just the call names:
permission gate, then scoped lookup, then the 404, then the delete. A
bump that keeps both scoped names and drops the `if not chat: raise 404`
between them passed every check in the first version, and it is not a
theoretical shape: three of that model function's writes are keyed on
`chat_id` alone, so the mutant lets a non-owner destroy another user's
`chat_message` rows, `shared_chat` snapshot and `automation_run.chat_id`
while the victim's `Chat` row survives.
* The `chat.delete` **permission gate**. Deletion for an ordinary user
is configuration dependent, and an admin toggle silently returns the
product to the state the old sentence described.
* The **whole write set** of `delete_chat_by_id_and_user_id`, exactly.
It is user scoped in its name and in one of its writes; the guard
records that coupling rather than implying a scoping that is not there,
and goes red if a further write appears or an existing one loses its
key.
* The Caddy check now **evaluates matchers against a concrete request**
(`DELETE /api/v1/chats/<uuid>`) rather than scanning block structure.
The line scanner it replaced had four demonstrated false negatives, and
the worst was the most likely regression path: `@blocked` is a bare
`path_regexp` with no `method` line at all, so a `chats` arm added there
would 404 the delete route for every verb and the old check would stay
green.

**3. The documents are corrected, per surface**, and
`docs/chat-abandoned-completions.md` answers #916.

## Cross-account denial, live

Two independent run-key-scoped fixture identities on the shared chat
instance. Never the demo account.

```
[A create]                        status=200  chatId=e85bb8ac-32f1-4bcb-a5af-2c56060ce571
[B GET    A's chat]               status=401
[B DELETE A's chat]               status=404
[A GET after B's delete attempt]  status=200  survived=true
```

B is refused on both verbs; A's row is intact. A non-owner's id simply
never matches a row, so the refusal is structural rather than a check
that could be forgotten.

## Soft or hard, and why

Deletion is **hard**, and that is the right posture here. An `archived`
column exists and is a separate, deliberate action, so the product
already offers the soft path under its own name. Given buyers in
finance, legal, healthcare and government, both concerns are live at
once: retention obligations and a right to removal. Conflating them
behind one button serves neither. A soft delete masquerading as a delete
would be the worst of both, telling a regulated customer their data was
removed when a row still holds it.

## What deletion does not remove, deliberately

- **No credit ledger row.** The ledger is append only and lives in
Postgres. Chat rows live in Open WebUI's own store. The delete path
references the ledger not at all, so a deletion cannot alter what was
billed.
- **No audit event.** Same separation. An event recording that something
existed survives the thing's deletion, which is the point of an audit
trail.
- The path touches `chat`, `chat_message`, `shared_chat`, and nulls
`automation_run.chat_id`. Nothing else.

## Scope calls

**Task deletion is out, and should be separate.** An agent task is not a
chat: it carries spend, sandbox artefacts and audit events, so "delete a
task" is a retention decision before it is an endpoint.

**Account deletion is out, and should stay out for now.** The `ON DELETE
RESTRICT` is not an oversight to route around; the migration comments it
as deliberate, and #828 already tracks it.

## Proposed demo-account cleanup, for the owner to decide

**Nothing has been deleted.** This is a proposal only, per instruction:
that is the owner's demonstration data and it is not being cleared on an
inference.

`demo@hive-demo.invalid` held **24 conversations** at the time of
measurement, of which **21 had no answer at all**. Every one of the 24
is automation text; none reads as genuine demo content. Earliest
2026-08-23, latest 2026-08-29. **Five were created on 2026-08-29
itself**, so this is still accumulating today, not a historical residue.

Representative titles:

```
Reply with exactly: OK
Reply with exactly: FRONTIER OK
Reply with exactly: QAOK
Count from one to twelve in words.        (x4)
What is 2+2?
Say OK
hello                                     (x3)
QAPROBE-A it's a "test" - ok
Using only the attached file, state the QA inventory code.
Print the word QACOWORK and stop.
New Chat
```

Recommendation: delete all 24. There is nothing among them a prospect
should see. Say the word and it is one pass through the route this pull
request documents.

Worth noting the litter is not confined to that account:
`qa-tester@hive.test` holds 39, `e2e-verified+qafunded-...` 21,
`e2e-verified@scubed.com.bd` 20. Those are test identities and nobody
demos them, so they are lower priority, but they are the same cause, and
closing them is #1476 rather than this branch.

## Review dispositions

Fourteen threads, all worked. Twelve fixed, one partially fixed with the
remainder filed, one measurement redone after the reviewer turned out to
be more right than they claimed.

| Thread | Disposition |
| --- | --- |
| Guard reads source that does not ship | Fixed. Runs the real patch
scripts and asserts on patched output. |
| Checks pin call names, not the refusal | Fixed. The order of
permission gate, lookup, 404 and delete is asserted, with a self-check
that rejects each mutant shape. |
| Caddy scanner has four false negatives | Fixed. Matchers are evaluated
against a concrete request; all four shapes are negative controls in the
self-check. |
| Scoped model function is scoped in one of four writes | Fixed. The
whole write set is pinned and the caller coupling is recorded explicitly
rather than implied away. |
| The 404 is not pinned | Fixed by the same change as the order
assertion above. |
| No test covers the `mintSession` wiring | Fixed. Four tests drive
`mintSession` and `writeStorageState` and assert no socket is opened.
Deleting the guard call turns three of them red and no helper test. |
| Caddy parser is order dependent | Fixed by the same change as the
matcher evaluator above. |
| `stop_item_tasks` runs before authorization | Fixed in the document;
the code fix is #1474. |
| A denylist of one address leaves three accounts open | Partial. The
two Python implementations are deduplicated and normalised here; the
allowlist is #1476, because it would turn two scheduled workflows red
before they have run-key identities. |
| "Single door" is false for the repository | Fixed.
`scripts/shared_demo_account.py`, imported by all three Python scripts,
with `scripts/test_shared_demo_account.py` asserting each one actually
calls it rather than describing it. |
| "Deletable, and always were" omits the permission | Fixed in the
document, and the permission key is pinned in the guard. |
| "Two charges rather than one" is unmeasured | Fixed. The claim is
removed; the measurement is #1475. |
| `chat_message` count has no positive control | Correct, and worse than
reported. See below. |
| "Scoped to the calling user" is untrue of the admin arm | Fixed, with
a correction: on this deployment the admin arm is unreachable, because
`ENABLE_ADMIN_CHAT_ACCESS` is `"false"`. That is now pinned rather than
relied on. |

### On the `chat_message` thread

The reviewer was right, and running the control they asked for proves it
went further than they said. The kept conversation from the original
capture, `63f4ff43-ae8c-4260-9784-c119a0e69233`, holds **zero**
`chat_message` rows, while 138 of the 139 chats in that store hold two
each. The original capture created its conversations through `POST
/api/v1/chats/new` with a synthetic body, which writes no `chat_message`
row, so "deleted chat_message rows: 0" was equally consistent with "no
such row ever existed", and the two documents that escalated it into
"the `chat_message` rows are gone" were resting on nothing.

The capture has been redone with conversations created through the
composer, which is the path that writes those rows, and both counts are
printed with the kept conversation as the control.

## Testing

- `make test-scripts` passes with both new scripts registered.
- `scripts/test_owui_chat_delete_authz.py` was mutation tested end to
end against the real files. Nine mutants, nine red, baseline and restore
green: dropping the 404, dropping the permission gate, swapping the
scoped calls for unscoped ones, widening the patch's admin gate from
`and` to `or`, dropping `user_id` from the Chat delete, adding a `chats`
arm to `@blocked`, a path-then-method mutation block, an
enclosing-`handle` DELETE block, and flipping `ENABLE_ADMIN_CHAT_ACCESS`
back on.
- The JavaScript choke point was mutation tested the same way: deleting
`assertNotSharedDemoAccount(email, { readOnly })` from `mintSession`
takes the suite from 12 passed to 3 failed, and the three are the new
wiring tests. Restoring it returns 12 passed.
- Web console type check and build pass in the documented Docker
substrate (`docker compose run --build --no-deps web-console sh -c "npm
run build && npm run test:unit"`), which is the check that was failing.
Precisely, since this pull request is about claims that are nearly true:
the unit suite reported 1033 passed and 11 failed on that local run.
Nine are `Test timed out in 5000ms` React render timeouts under local
load and two are the documented `ENOENT /app/.github/workflows/ci.yml`,
which only pass in CI because the web-console image does not copy
`.github`. None of the nine failing files imports anything this branch
changes, and `live-auth-demo-guard.test.ts` passes 12 of 12. CI is the
arbiter for those.
- One defect in this branch was found by CI rather than by me, after
review: `test_owui_chat_delete_authz.py` hardcoded the ten routers the
family patch rewrote when it was written, and main has since added
`skills.py` (PR #1388), so the check went red on a merge unrelated to
chat deletion. Fixed at the root by copying every router out of the
vendored directory, which removes the second copy of that list entirely.

No test in this change reports a state it does not have. Every guard
added here was run against a mutant that breaks the behaviour it names,
and the mutants are listed above rather than asserted. The one check in
the previous revision that could not go red, the `chat_message` count,
is named in the thread above rather than quietly re-run.

## Files touched in the vendored front end

None. `vendor/open-webui` is read only in this change.

## Buglog entry

```json
{"id":"bug-2026-08-29-chat-delete-doc-false","date":"2026-08-29","title":"Repository documentation claimed chat deletion did not exist, so demo-account litter was never cleaned up, and the first guard written for it asserted on source that does not ship","error_message":"docs/live-test-auth.md: 'today undeletable: there is no chat-delete, task-delete or account-delete route wired up yet (issues #828, #848)'","root_cause":"The claim was true for agent tasks and accounts and false for chats. DELETE /api/v1/chats/{id} has always existed, hard deletes, and resolves through the user-scoped lookup. The false third of the sentence was never checked against the running stack and became received wisdom: agents reading it concluded the 24 automation-litter conversations on demo@hive-demo.invalid could not be removed, so none tried. Compounding it, the rule the same document states, that a write-capable suite must never authenticate as the demo account, was enforced by nothing in JavaScript, by a case-sensitive exact string in one Python script and by a docstring sentence in another, so runs kept adding rows. The first structural guard written for the delete boundary read vendor/open-webui/backend/routers/chats.py, which Dockerfile.open-webui does not ship: only the frontend is built from vendor, the Python backend comes from the pinned upstream image, and owui-patches rewrites chats.py inside it, so the guard could not see the patch layer it existed to protect.","fix":"Corrected the document per surface after measuring live, including the chat.delete permission condition, the ENABLE_ADMIN_CHAT_ACCESS gate that keeps the admin arm unreachable on this deployment, and the pre-authorisation stop_item_tasks call. Added assertNotSharedDemoAccount to mintSession in apps/web-console/tests/e2e/support/live-auth.mjs and scripts/shared_demo_account.py to verify-control-plane.py, post-deploy-verify.py and verify-rag-roundtrip.py, with tests that assert the wiring rather than the helper. Rewrote scripts/test_owui_chat_delete_authz.py to run the real owui-patches against copies of the vendored source and assert on the patched result, to pin the ownership decision's ORDER rather than the call names, to pin the full write set of delete_chat_by_id_and_user_id, and to evaluate Caddy matchers against a concrete DELETE request instead of scanning block structure. Both registered in make test-scripts and mutation tested.","tags":["docs","authorization","open-webui","e2e","demo-readiness","chat","issue-916","issue-848"]}
```

---------

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.

1 participant