Compare commits

...
24 Commits
Author SHA1 Message Date
semantic-release-bot 27cbf5fa26 chore(release): 2.13.2 [skip ci] 2026-08-08 23:12:52 +00:00
Dumitru Picalau fc920b4d3c Merge pull request #18 from DumiFlex/fix/editor-crashes-and-release-integrity
fix: Firefox editor crashes, library version guard, and release archive completeness
2026-08-09 02:12:08 +03:00
DumiFlex d604deb6a5 docs(release-notes): curated highlights for the editor and library fixes
`.releaserc.js` renders no commit sections (`transform: () => false`), so
this file IS the release body. Without it the release ships with only a
header and a compare link — the blank-changelog outcome that 2.13.0 hit.

Written for users rather than maintainers: what broke, what it looked
like, and which installs were affected. semantic-release clears the file
on release, so it leaves no net change on main.
2026-08-09 01:59:00 +03:00
DumiFlex 8cf40241cf fix(db): record migration checksums and refuse a foreign schema
`migrate()` skips everything `<= MAX(version)`, which made an integer
the entire identity of a schema. It is not, and two failures followed
from that, both silent:

1. A database migrated by a NEWER build. Every migration is already
   "applied", so the runner does nothing and the caller reads a schema
   it has never seen. Additive columns survive; a shape change does not.
2. A database at the SAME version but a different shape, because a
   migration file changed after it had already run somewhere — a
   revised migration, a hand-edited file, an abandoned branch. Version
   numbers match, so nothing compared anything.

Both corrupt cumulatively: every subsequent write goes through code
whose assumptions about the row shape are wrong, and the user has no
signal until something much later fails for an unrelated-looking reason.

Migration 017 adds a nullable `checksum` column; the runner records the
sha256 of the file it actually executed. `assert_schema_compatible`
checks both failure modes and runs at the top of `migrate()`, so no
caller can forget it.

NULL is treated as "unknown", never "mismatched". Rows applied before
017 existed cannot retroactively grow a checksum, and backfilling them
from the files on disk would assert exactly the compatibility the check
exists to verify. So the version-ahead half protects every database
immediately; the divergence half protects migrations from 018 onward.
2026-08-09 01:52:47 +03:00
DumiFlex 72e96ec73f fix(release): ship locales/ in the release zip, and make the ship list total
`SHIP_PATHS` omitted `locales/`, which ComfyUI reads from
`custom_nodes/<pack>/locales/<lang>/` for node display names,
descriptions and settings labels (app/custom_node_manager.py). We ship
main.json, nodeDefs.json and settings.json there.

The registry archive already included it, because `comfy node publish`
ships everything git-tracked that `.comfyignore` does not exclude. The
zip is a hand-maintained list, so the two artifacts silently disagreed
about what a complete install is. Nothing installs from the zip today,
which is the only reason this has not bitten anyone.

The omission is the symptom; two hand-maintained lists of the same thing
with nothing tying them together is the cause. `assertShipListIsTotal`
now fails the pack when a top-level tracked path is neither in
SHIP_PATHS nor explicitly listed as dev-only, so the next runtime
directory someone adds cannot go missing the same way.

Also names the missing-`zip` case: on Windows the packer reported
"exited with status null", which says nothing about `zip` not being on
PATH.
2026-08-09 01:52:46 +03:00
DumiFlex 5d593a1700 fix(editor): repair the host whenever the fragment anchors are gone
Second report, same wound, different door: Ctrl+A then Ctrl+X inside an
option value bricked the field on Firefox — typing, Backspace and even
Save and Cancel stopped responding — with `insertBefore: Child to insert
before is not a child of this node` and the `g is null` unmount cascade.

`onHostCut` already routes the deletion through `applyAtoms` so the
browser never removes the spans itself (2.13.1). But the atom list still
shrinks from N to 1, and shrinking a keyed list unmounts the surplus
`<template v-for>` Fragments. With the fragment anchors normalised away
by Firefox that teardown walks off the end of the child list, exactly as
the ref-picker path did — reached from a call site nobody had flagged.

Marking individual call sites `rebuild` was a whitelist, and the
whitelist kept missing entries: three separate crashes now, three
separate patches. Ask the DOM instead.

`hostAnchorsIntact()` counts the empty text nodes Vue uses to delimit its
Fragments — a healthy host holds exactly `2N + 2` of them, verified
against the live bundle. `isStructuralApply()` marks the applies that
add, remove or re-branch an atom, which are the only ones that touch an
anchor. When both hold, the host is replaced instead of patched, and all
thirteen `applyAtoms` call sites are covered at once.

Costs nothing on Chromium, where the anchors are never missing, and
nothing on the same-shape applies that carry ordinary typing — a test
pins that a chip settle still lands on the very same host element.

Save/Cancel and Backspace needed no separate fix; both were the dead
subtree left behind by the first throw.
2026-08-09 01:52:45 +03:00
DumiFlex 81495ea530 fix(editor): stop the ref picker killing the field on Firefox
Inserting a nested `@` reference took the whole editor down behind the
error boundary with `can't access property "nextSibling", e is null`,
followed by the unmount cascade (`g is null`). Firefox only.

`<template v-for>` mounts each atom inside a Fragment, and Vue delimits a
Fragment in the DOM with two EMPTY TEXT NODES. Those anchors sit inside
the contenteditable host, which the browser owns — and Firefox normalises
empty text nodes away as the user types. Chromium keeps them, which is
why this never reproduced here.

The picker-Apply path then re-keyed the atom v-for to force a clean
remount. Vue's `removeFragment` walks `nextSibling` from the start anchor
until it reaches the end anchor; with the end anchor gone the walk runs
off the last child and derefs null.

`hostDomIsStale()` could not catch it: it counts `[data-atom-index]`
ELEMENTS, and what the browser destroyed were anchors, so it reported a
healthy DOM and the re-key path was taken.

Both rebuild routes now go through the host ELEMENT swap that already
existed for the stale-DOM case. Unmounting an element vnode is a single
`hostRemove(el)` on a node that IS still attached, and its children come
down with `doRemove: false`, so no missing anchor and no detached child
el is ever touched; the new tree then mounts into an empty element with
nothing to insert before. The v-for key drops back to the plain index.

A host swap also replaces the element the ResizeObserver was watching, so
`useGrowableField.attach()` is now idempotent and RichTextInput re-attaches
on every swap — otherwise the overflow fade and grip-follow died silently
for the rest of the session on any field that had ever repaired itself.
2026-08-09 01:52:45 +03:00
semantic-release-bot 494b53192d chore(release): 2.13.1 [skip ci] 2026-08-04 19:11:29 +00:00
Dumitru Picalau 8c0f3e418e Merge pull request #17 from DumiFlex/fix/republish-2131
fix(release): re-cut 2.13.0 as 2.13.1 for the registry
2026-08-04 22:10:43 +03:00
DumiFlex d2f548d0e2 fix(release): re-cut 2.13.0 as 2.13.1 for the registry
The registry publish for 2.13.0 failed:

    400 {"message":"The node version already exists"}

The empty 2.13.0 cut earlier today was deleted from the registry, but
deleting frees the LISTING, not the number — version records are
immutable there, the same model as PyPI and npm, and the publisher
requires each new version to be greater than every existing one. So
2.13.0 can never be published again, whatever it now contains.

The consequence is worse than an untidy version history: 2.13.0 carries
the `pending_move` containment fix, it reached GitHub, and it did NOT
reach anyone installing through ComfyUI Manager. Those users are still on
2.12.0 with the unauthenticated file copy/move reachable.

Restores the 2.13.0 notes so this release ships with them rather than the
empty body an emptied `next.md` would produce, plus a line for people
already on 2.13.0 saying the two are the same build.
2026-08-04 22:04:40 +03:00
semantic-release-bot 7e36bb02c6 chore(release): 2.13.0 [skip ci] 2026-08-04 18:54:27 +00:00
Dumitru Picalau 3a5e46cd9d Merge pull request #16 from DumiFlex/fix/db-move-containment
fix(api): confine the deferred DB move to known database locations
2026-08-04 21:53:48 +03:00
DumiFlex 757116dc54 feat(spa): show skipped releases on the What's new page
The page rendered only the newest release, so updating across two
versions meant the middle one's notes were unreachable. That is not
hypothetical: 2.12.0 — the largest release in months — was current for
about an hour before 2.13.0 replaced it, so anyone updating from 2.11.0
would never have seen it.

The last three releases now appear beneath the current one, collapsed,
each with its own notes and a link to its release page. Three because the
gap is small but real; more and the page becomes a changelog, which the
compare link already does better.

Fetched through the same throttle and rate-limit lockout as the update
check — a second GitHub call on a shared 60/hr anonymous quota, on a page
someone may open repeatedly, is exactly what the caching work existed to
prevent. Cached under its own key with the same TTL, and skipped
entirely while a lockout is in force.

Also fixes the "All releases" button, which preferred the current
release's own URL and so went to a single release whenever the release
check had resolved — the common case. It now goes to the releases index,
which is what it says.
2026-08-04 21:47:37 +03:00
DumiFlex 05334d3e05 fix(api): confine the deferred DB move to known database locations
`PUT /wp/api/database/config` accepted `pending_move.from` and `.to` as
any absolute paths. `execute_pending_move` then runs `shutil.copy2` or
`shutil.move` on them at plugin load, as whoever runs ComfyUI. Every
ComfyUI route is unauthenticated by default, so anyone able to reach the
port could schedule:

  * an arbitrary file read — copy a target into `web/`, which this plugin
    serves at `/wp/<path>`, turning our own static server into the
    exfiltration channel;
  * an arbitrary overwrite or deletion — move any file anywhere.

Fixed with an allowlist rather than a sanitiser. The database can
legitimately live in exactly three server-computed locations under one
fixed filename, so there is no reason to accept a client-supplied path.
The live path is also allowed, since WP_DB_PATH / COMFYUI_USER_DIR can
place the file elsewhere and those users must still be able to migrate
off it.

Both paths are `resolve()`d before the membership test, so neither `..`
nor a symlink planted at an allowed location can smuggle a different
target past it, and the resolved values are what get stored — what
executes is what was validated.

`spa.py` already did this correctly (`resolve()` + `relative_to()` + 403);
this route simply missed the pattern.

Adds 7 containment tests covering arbitrary source, arbitrary
destination, the exfiltration-into-`web/` chain, traversal, and a no-op
move — plus one asserting a legitimate relocation still works, since a
guard that breaks the feature it protects is its own bug.
2026-08-04 21:36:59 +03:00
Dumitru Picalau 2d68c61f4a Merge pull request #15 from DumiFlex/ci/no-release-for-tooling
ci: stop tooling-only pushes from cutting a release
2026-08-04 20:24:58 +03:00
DumiFlex c6d8834a42 ci: stop tooling-only pushes from cutting a release
2.13.0 shipped as an empty release. Three commits touching only
`scripts/` and `.github/` were typed `feat(announce)` and
`fix(announce)`; both are release-triggering, so semantic-release did
exactly what it was told, and every user on 2.12.0 was offered an update
with a blank changelog.

The discipline fix — remember to type tooling commits `ci:` — had already
failed once within an hour of the rule being written down, so this is the
structural one. `paths-ignore` means those paths cannot cut a release
whatever the commit type says.

Neither path is part of what a user installs: `scripts/` is dev tooling,
`.github/` is CI. GitHub skips the run only when EVERY changed file
matches, so a push touching shipped code alongside a workflow still
releases normally.

`docs/**` is deliberately excluded from the list: `docs(readme)` is
configured to bump a patch, and `docs/release-notes/next.md` IS the
release body.

publish.yml needs no change — it fires on a published release, so no
release means no registry publish.
2026-08-04 20:19:00 +03:00
DumiFlex 99d3b21334 chore(release): revert the empty 2.13.0 [skip ci]
2.13.0 shipped nothing. It was cut because `feat(announce)` and
`fix(announce)` are release-triggering types, but those commits only
touched `scripts/` and `.github/` — dev tooling that is not part of what
users install. They should have been `ci:`, which is `release: false`.

The result was a version bump with an empty body: every user on 2.12.0
would have seen an update prompt with a blank changelog.

Release and tag are deleted; this restores package.json, pyproject.toml
and CHANGELOG.md to 2.12.0.

`[skip ci]` is load-bearing. Without it this push runs release.yml, which
would analyse the same commits against the now-latest tag v2.12.0 and cut
2.13.0 straight back.

Note that 2.13.0 will be used again by the next REAL release: the
`feat(announce)` commits stay in history and still count toward the bump.
That is the intended outcome — the number is reused for a release that
actually contains something, rather than being burned on an empty one.
2026-08-04 20:16:03 +03:00
semantic-release-bot e9a9d51ea3 chore(release): 2.13.0 [skip ci] 2026-08-04 17:12:12 +00:00
Dumitru Picalau 0572eec472 Merge pull request #14 from DumiFlex/feat/announce-identity
feat(announce): identity, and two channels — staff automatically, public by hand
2026-08-04 20:11:23 +03:00
DumiFlex c7f4d91a10 fix(announce): never emit a half-formed role mention
Unset and empty were already safe. A MALFORMED id was not: any truthy
string went straight into `<@&…>`, so a stray space in the secret produced
a literal `<@&   >` and a pasted mention produced `<@&<@&123>>` — visible
junk at the top of the most public message the project sends.

`normalizeRoleId` is now the single decision point, applied inside
`buildPayload` rather than at the call site so every caller gets the same
guarantee. It trims, unwraps a pasted `<@&123>`, and requires a 17-20
digit snowflake. Anything else is rejected rather than guessed at: a ping
that silently does not happen is a far smaller problem than a broken one
that does.

`allowed_mentions.roles` is derived from the same normalised value, so
the two can never disagree — a content string naming a role the payload
does not permit would be a mention that renders but never notifies.

The CLI warns when a non-empty id was supplied and rejected. Dropping it
silently would leave someone wondering why the ping never fired.
2026-08-04 20:05:45 +03:00
DumiFlex 45a9092bbc feat(announce): two channels — staff automatically, public by hand
One webhook meant the choice was all-or-nothing: either every release
announced itself to everyone, or nothing was automated. Two channels,
deliberately asymmetric:

  staff   posts AUTOMATICALLY on every release
  public  ONLY from a deliberate manual dispatch

The staff copy carries a link back to this workflow with the inputs
spelled out. That is what makes it a review step rather than a duplicate
feed: the post being read IS the post that will go out, and the button to
send it is attached to it. Otherwise "now go publish it" is a separate
instruction living in someone's head.

The public copy does not get that field — an announcement should not tell
its readers how to re-send it. Returned as `undefined` rather than an
empty array, since an empty field block renders as dead space.

Guarded twice. The workflow only passes `--channel public` on a manual
dispatch, AND the script refuses public when `GITHUB_EVENT_NAME=release`.
One of those is enough; both are cheap, and the failure mode is
announcing to everyone with nobody having read it.

Only the public post pings the role. A ping in the staff channel on every
release is noise in a room that exists to watch releases.

`DISCORD_WEBHOOK_STAFF` / `DISCORD_WEBHOOK_PUBLIC`, with the existing
`DISCORD_WEBHOOK_URL` kept as a staff fallback so the current
single-webhook setup keeps working untouched.
2026-08-04 20:01:25 +03:00
DumiFlex 2dfed26c1e ci(announce): post as Wildcard Pipeline, not as the webhook's own name
v2.12.0 went out as "Captain Hook" with a placeholder avatar — a webhook
posts under whatever name and image it was created with, and Discord's
default is a generic pair.

`username` and `avatar_url` override that per message, so the identity
lives in the payload rather than in a webhook's settings. It then stays
right if the webhook is ever rotated or recreated, and it is the same in
every channel the automation posts to.

Avatar is the 512px manifest icon, fetched from raw.githubusercontent by
Discord server-side. It has to be raster — an SVG will not render, which
rules out the repo's favicon.svg, and that failure mode is silent. Pinned
to `main` rather than a tag so replacing the logo needs no code change.

`DISCORD_USERNAME` / `DISCORD_AVATAR_URL` override both. An empty value
falls through to the defaults rather than blanking the identity and
handing the post back to the webhook's name.

The dry run now prints both, since identity is invisible in the embed
body and is exactly what was wrong the first time.
2026-08-04 19:52:51 +03:00
Dumitru Picalau 5585583765 Merge pull request #13 from DumiFlex/ci/discord-announce-node-setup
ci: stop the announce job probing for a pnpm cache it never uses
2026-08-04 19:45:41 +03:00
DumiFlex c4a761d071 ci: stop the announce job probing for a pnpm cache it never uses
The v2.12.0 announcement failed before it ran a line of script:

  Unable to locate executable file: pnpm

`actions/setup-node` sniffs the lockfile, sees pnpm, and tries to prime
its cache — but this job never installs pnpm. It does not need to: the
announce script has no dependencies at all, just plain ESM and the global
fetch built into Node 18+. Nothing is installed, so there is nothing to
cache.

Caught only in the real run because the failure is in the runner's
toolchain setup, not in anything a local invocation of the script
exercises.
2026-08-04 19:39:44 +03:00
23 changed files with 1647 additions and 87 deletions
+40 -11
View File
@@ -1,7 +1,12 @@
name: Discord announce
# Fires when semantic-release publishes a GitHub release, and can be run by
# hand for the catch-up case (announcements skipped for a few versions).
# Two channels, deliberately asymmetric.
#
# staff — posts AUTOMATICALLY when semantic-release publishes a release.
# The post carries a link back to this workflow, so the review and
# the publish button are the same artefact.
# public — only ever from a manual run. A release can never announce itself
# to everyone without someone having read it first.
#
# Deliberately a SEPARATE workflow from release.yml: a Discord outage, a
# rotated webhook or a rate limit must never fail a release that already
@@ -11,6 +16,12 @@ on:
types: [published]
workflow_dispatch:
inputs:
channel:
description: "Where to post."
required: true
type: choice
default: staff
options: [staff, public]
since:
description: "Roll up every release after this tag (e.g. v2.10.2). Blank = latest release only."
required: false
@@ -32,22 +43,40 @@ jobs:
- uses: actions/setup-node@v5
with:
node-version: "22"
# setup-node sniffs the lockfile and tries to prime a pnpm cache,
# which fails because pnpm is never installed here. It is not needed:
# the announce script has no dependencies at all — plain ESM and the
# global fetch built into Node 18+. Nothing is installed, so there is
# nothing to cache.
package-manager-cache: false
# `release` events always post. `workflow_dispatch` honours the dry_run
# box, which defaults to true so a manual run cannot surprise anyone.
# A `release` event always posts, and always to staff. A dispatch honours
# both inputs, with dry_run defaulting to true so a manual run cannot
# surprise anyone.
- name: Announce
env:
DISCORD_WEBHOOK_STAFF: ${{ secrets.DISCORD_WEBHOOK_STAFF }}
DISCORD_WEBHOOK_PUBLIC: ${{ secrets.DISCORD_WEBHOOK_PUBLIC }}
# Kept so an existing single-webhook setup keeps working as staff.
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
DISCORD_ROLE_ID: ${{ secrets.DISCORD_ROLE_ID }}
GITHUB_TOKEN: ${{ github.token }}
GITHUB_EVENT_NAME: ${{ github.event_name }}
run: |
set -euo pipefail
ARGS=""
if [ -n "${{ inputs.since }}" ]; then
ARGS="--since ${{ inputs.since }}"
elif [ -n "${{ github.event.release.tag_name }}" ]; then
ARGS="--version ${{ github.event.release.tag_name }}"
fi
if [ "${{ github.event_name }}" = "release" ] || [ "${{ inputs.dry_run }}" = "false" ]; then
ARGS="$ARGS --post"
if [ "${{ github.event_name }}" = "release" ]; then
ARGS="--version ${{ github.event.release.tag_name }} --channel staff --post"
else
ARGS="--channel ${{ inputs.channel }}"
if [ -n "${{ inputs.since }}" ]; then
ARGS="$ARGS --since ${{ inputs.since }}"
fi
if [ "${{ inputs.dry_run }}" = "false" ]; then
ARGS="$ARGS --post"
fi
fi
echo "Running: discord-announce.mjs $ARGS"
node scripts/discord-announce.mjs $ARGS
+19
View File
@@ -3,6 +3,25 @@ name: Release
on:
push:
branches: [main]
# Tooling-only pushes must not be able to cut a release, whatever their
# commit type says.
#
# 2.13.0 shipped as an empty release because three commits touching only
# `scripts/` and `.github/` were typed `feat(announce)` / `fix(announce)`.
# Both are release-triggering, so semantic-release did exactly what it was
# told — every user on 2.12.0 was offered an update with a blank changelog.
# Relying on remembering to type those `ci:` had already failed once.
#
# Neither path is part of what a user installs: `scripts/` is dev tooling
# and `.github/` is CI. GitHub skips the run only when EVERY changed file
# matches, so a push that touches shipped code alongside a workflow still
# releases correctly.
#
# `docs/**` is deliberately NOT here — `docs(readme)` is configured to bump
# a patch, and `docs/release-notes/next.md` is the release body itself.
paths-ignore:
- 'scripts/**'
- '.github/**'
permissions:
contents: write
+201
View File
@@ -1,3 +1,204 @@
## 🎉 Wildcard Pipeline 2.13.2
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
### Firefox: the editor no longer dies when you insert a nested reference
Picking a wildcard from the `@` autocomplete could take down the whole editor —
the field stopped accepting text, Backspace did nothing, and Save and Cancel
stopped responding until the page was reloaded. Select-all then cut inside an
option value did the same thing.
Both were the same cause. Firefox removes the empty text nodes that Vue uses to
mark where each chip and text run begins and ends, and once they are gone any
edit that adds or removes a chip runs off the end of the field's contents.
Chromium keeps those markers, which is why this only ever happened on Firefox
and never reproduced during development.
The editor now notices when those markers have been removed and rebuilds the
field instead of tripping over them. This affects every rich text field —
option values, combine templates, derivation rules and injector bindings, on
the canvas and in the manager alike.
### Your library is protected against version mismatches
If a database has been through migrations that this build does not recognise —
after downgrading, hand-installing an older copy, or switching versions in
ComfyUI Manager — Wildcard Pipeline now stops with a clear message instead of
quietly reading a library it does not understand. Previously it carried on, and
every later edit was written through assumptions that no longer held.
### Release archives now include the interface translations
The release zip was missing `locales/`, so node names, descriptions and setting
labels fell back to raw identifiers for anyone installing from it. Installs
from ComfyUI Manager and the Comfy Registry were unaffected.
---
**Full changelog:** [v2.13.1…v2.13.2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v2.13.1...v2.13.2)
## 🎉 Wildcard Pipeline 2.13.1
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
A security fix for the database location setting. Update when you can.
> **Already on 2.13.0?** This is the same release. 2.13.0 reached GitHub but not the
> ComfyUI Registry, so it never arrived for anyone installing through ComfyUI Manager.
> 2.13.1 is that same build under a version number the registry will accept — nothing
> in it differs.
### Highlights
- **The "move the database" setting could be abused to touch other files.** Changing the database location schedules a file copy or move that runs the next time ComfyUI starts. The two paths were only checked for being absolute, not for being database locations — so a request sent to your ComfyUI port could have scheduled a copy or move between any two files on the machine, including reading a file by copying it somewhere this plugin serves over HTTP. ComfyUI's API has no authentication, so anything that can reach the port could do this. Both paths are now restricted to the three locations the database is actually allowed to live in. Moving your database between those locations works exactly as before.
- **What's new now shows the releases you skipped.** The page only ever showed the newest release, so updating across two versions meant the middle one's notes were simply gone — 2.12.0 was current for about an hour before this release replaced it. The last few releases are now listed underneath, collapsed, each with its own notes. **All releases** also goes to the releases index now instead of to the single release it was labelled against.
<!-- /modal -->
### Upgrade notes
Nothing to do beyond updating. No settings change, and any database move you had queued still runs normally.
If you expose ComfyUI beyond your own machine, this one is worth taking promptly — the affected setting is reachable without a login on every install of 2.11.0 and 2.12.0.
<!--
HOW TO WRITE THIS FILE
Order, and nothing else:
Headline one sentence, what this release is about
### Highlights 4-5 max
<!- /modal -> the update dialog stops rendering here
<details> Also in this… grouped one-liners, collapsed
### Upgrade notes only when the reader must act; delete otherwise
— keep this OUTSIDE the <details>, it needs seeing
This file IS the release body — the compare link is appended automatically and
the per-commit list is deliberately not included. It is consumed at release
time and cleared afterwards, so anything left here ships.
RULES
1. Highlights are capped at 4-5. The cap is the mechanism: it is what keeps a
6-commit release and an 80-commit release roughly the same height. If a
sixth feels essential, something else was not a highlight.
2. A highlight passes one of three tests:
- new capability — you can do something you could not before
- wall removed — something that blocked you now works
- surprising — behaviour changed in a way you should not meet alone
Polish, truncation, layout and invisible perf fail all three.
3. Commit count is NOT a test. The categories icon picker was two commits and a
real feature; `ui` was nine commits and almost entirely polish.
4. The tail is grouped by area, one line each, no bold-lead paragraphs. Group
names are free-form — use whatever the release actually contains. It sits in
a <details> so a long release does not double the page; put the count in the
summary so the reader knows what they are opening. That number is written by
hand here and can drift — the update dialog computes its own from the same
bullets, so if the two disagree, this one is wrong. Note also that collapsed
text is not reliably reachable by browser find-in-page, which is the cost.
5. Write what changed for the user, not what changed in the code. Name the
symptom, not the fix: "the field went blank" beats "guarded a null deref".
6. Perf belongs in the tail WITH its numbers, so the people it affects
recognise themselves. "Performance improvements" tells nobody anything.
7. Security fixes say what an attacker could have done, in plain language, and
whether the reader must act. Never ship one as "hardening" or "internal
improvements" — someone deciding whether to update tonight needs the facts,
and vagueness reads as a cover-up once the diff is public anyway.
-->
---
**Full changelog:** [v2.13.0…v2.13.1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v2.13.0...v2.13.1)
## 🎉 Wildcard Pipeline 2.13.0
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
A security fix for the database location setting. Update when you can.
### Highlights
- **The "move the database" setting could be abused to touch other files.** Changing the database location schedules a file copy or move that runs the next time ComfyUI starts. The two paths were only checked for being absolute, not for being database locations — so a request sent to your ComfyUI port could have scheduled a copy or move between any two files on the machine, including reading a file by copying it somewhere this plugin serves over HTTP. ComfyUI's API has no authentication, so anything that can reach the port could do this. Both paths are now restricted to the three locations the database is actually allowed to live in. Moving your database between those locations works exactly as before.
- **What's new now shows the releases you skipped.** The page only ever showed the newest release, so updating across two versions meant the middle one's notes were simply gone — 2.12.0 was current for about an hour before this release replaced it. The last few releases are now listed underneath, collapsed, each with its own notes. **All releases** also goes to the releases index now instead of to the single release it was labelled against.
<!-- /modal -->
### Upgrade notes
Nothing to do beyond updating. No settings change, and any database move you had queued still runs normally.
If you expose ComfyUI beyond your own machine, this one is worth taking promptly — the affected setting is reachable without a login on every install of 2.11.0 and 2.12.0.
<!--
HOW TO WRITE THIS FILE
Order, and nothing else:
Headline one sentence, what this release is about
### Highlights 4-5 max
<!- /modal -> the update dialog stops rendering here
<details> Also in this… grouped one-liners, collapsed
### Upgrade notes only when the reader must act; delete otherwise
— keep this OUTSIDE the <details>, it needs seeing
This file IS the release body — the compare link is appended automatically and
the per-commit list is deliberately not included. It is consumed at release
time and cleared afterwards, so anything left here ships.
RULES
1. Highlights are capped at 4-5. The cap is the mechanism: it is what keeps a
6-commit release and an 80-commit release roughly the same height. If a
sixth feels essential, something else was not a highlight.
2. A highlight passes one of three tests:
- new capability — you can do something you could not before
- wall removed — something that blocked you now works
- surprising — behaviour changed in a way you should not meet alone
Polish, truncation, layout and invisible perf fail all three.
3. Commit count is NOT a test. The categories icon picker was two commits and a
real feature; `ui` was nine commits and almost entirely polish.
4. The tail is grouped by area, one line each, no bold-lead paragraphs. Group
names are free-form — use whatever the release actually contains. It sits in
a <details> so a long release does not double the page; put the count in the
summary so the reader knows what they are opening. That number is written by
hand here and can drift — the update dialog computes its own from the same
bullets, so if the two disagree, this one is wrong. Note also that collapsed
text is not reliably reachable by browser find-in-page, which is the cost.
5. Write what changed for the user, not what changed in the code. Name the
symptom, not the fix: "the field went blank" beats "guarded a null deref".
6. Perf belongs in the tail WITH its numbers, so the people it affects
recognise themselves. "Performance improvements" tells nobody anything.
7. Security fixes say what an attacker could have done, in plain language, and
whether the reader must act. Never ship one as "hardening" or "internal
improvements" — someone deciding whether to update tonight needs the facts,
and vagueness reads as a cover-up once the diff is public anyway.
-->
---
**Full changelog:** [v2.12.0…v2.13.0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v2.12.0...v2.13.0)
## 🎉 Wildcard Pipeline 2.12.0
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
+107 -9
View File
@@ -14,6 +14,7 @@ import graph.
"""
from __future__ import annotations
import hashlib
import importlib.util
import re
import sqlite3
@@ -26,6 +27,24 @@ _SQL_DIR = Path(__file__).parent / "migrations_sql"
_VERSION_RE = re.compile(r"^(\d{3})_.+\.(sql|py)$")
class SchemaAheadError(RuntimeError):
"""The database was migrated by a build this one cannot safely read.
Raised instead of letting the runner's "skip anything already applied"
rule quietly hand a foreign schema to code that expects its own.
"""
def _checksum(path: Path) -> str:
"""sha256 of a migration file's bytes.
Bytes, not parsed content: a `.py` migration's behaviour lives in code the
runner `exec`s, so any edit at all is a different migration regardless of
whether the SQL it emits looks the same.
"""
return hashlib.sha256(path.read_bytes()).hexdigest()
def _migrations_table_exists(conn: sqlite3.Connection) -> bool:
row = conn.execute(
"SELECT name FROM sqlite_master "
@@ -75,8 +94,93 @@ def _load_py_migration(path: Path) -> Callable[[sqlite3.Connection], object]:
return up # type: ignore[no-any-return]
def head_version() -> int:
"""Highest migration version this build ships."""
pairs = _discover()
return pairs[-1][0] if pairs else 0
def _has_checksum_column(conn: sqlite3.Connection) -> bool:
cur = conn.execute("PRAGMA table_info(migrations);")
return any(row[1] == "checksum" for row in cur.fetchall())
def _record(conn: sqlite3.Connection, version: int, mig_file: Path) -> None:
"""Insert the applied-migration row, with a checksum when we can store one.
Migration 017 is what adds the column, so on a fresh database every
migration before it inserts without one. Those rows stay NULL forever —
see the 017 docblock for why backfilling them would defeat the check.
"""
if _has_checksum_column(conn):
conn.execute(
"INSERT INTO migrations(version, applied_at, checksum) VALUES(?, ?, ?);",
(version, now_iso(), _checksum(mig_file)),
)
else:
conn.execute(
"INSERT INTO migrations(version, applied_at) VALUES(?, ?);",
(version, now_iso()),
)
def assert_schema_compatible(conn: sqlite3.Connection) -> None:
"""Refuse to touch a database some other build's migrations shaped.
Two distinct failures, both silent before this existed:
1. The database is AHEAD — migrated by a newer build. Every migration is
`<= MAX(version)`, so the runner does nothing and the caller reads a
schema it has never seen.
2. The database is at the SAME version but a DIFFERENT shape, because a
migration file changed after it had already run somewhere. Version
numbers match, so nothing compares anything.
Both are cumulative: each subsequent write goes through code whose
assumptions about the row shape are wrong. Raising is recoverable;
continuing is not.
"""
if not _migrations_table_exists(conn):
return
applied = current_version(conn)
head = head_version()
if applied > head:
raise SchemaAheadError(
f"This database was migrated to version {applied}, but this build only "
f"knows up to {head}. It was almost certainly written by a newer "
f"version of Wildcard Pipeline. Reading it here could corrupt your "
f"library — install the newer version again, or restore a backup."
)
if not _has_checksum_column(conn):
return
known = dict(_discover())
rows = conn.execute(
"SELECT version, checksum FROM migrations WHERE checksum IS NOT NULL;"
).fetchall()
for version, checksum in rows:
mig_file = known.get(int(version))
# Unknown version is the `applied > head` case, already handled above.
if mig_file is None:
continue
if _checksum(mig_file) != checksum:
raise SchemaAheadError(
f"Migration {int(version):03d} was applied by a build whose copy of "
f"it differs from this one's. The version numbers match but the "
f"schemas may not, so this build cannot safely read your library. "
f"Restore a backup taken before that build ran."
)
def migrate(conn: sqlite3.Connection) -> None:
"""Apply all pending migrations. Safe to call repeatedly."""
"""Apply all pending migrations. Safe to call repeatedly.
Guards first: applying this build's migrations on top of a schema another
build shaped is how a mismatch becomes permanent.
"""
assert_schema_compatible(conn)
applied = current_version(conn)
for version, mig_file in _discover():
if version <= applied:
@@ -85,10 +189,7 @@ def migrate(conn: sqlite3.Connection) -> None:
up = _load_py_migration(mig_file)
with conn:
up(conn)
conn.execute(
"INSERT INTO migrations(version, applied_at) VALUES(?, ?);",
(version, now_iso()),
)
_record(conn, version, mig_file)
else:
sql = mig_file.read_text(encoding="utf-8")
with conn:
@@ -96,7 +197,4 @@ def migrate(conn: sqlite3.Connection) -> None:
stmt = stmt.strip()
if stmt:
conn.execute(stmt)
conn.execute(
"INSERT INTO migrations(version, applied_at) VALUES(?, ?);",
(version, now_iso()),
)
_record(conn, version, mig_file)
@@ -0,0 +1,41 @@
"""Migration 017 — record WHICH migration ran, not just how many.
``migrations`` tracked only ``version``, and the runner skips anything
``<= MAX(version)``. That makes an integer the entire identity of a
schema, which it is not:
- A build whose head is 015 opened against a DB migrated to 016 skips
every migration, finds nothing to do, and reads a schema it has never
seen. Additive columns survive; a shape change does not.
- Worse, two builds can both call something "016" and mean different
things — a reworked migration, a hand-edited file, a branch that never
shipped. The version matches, so nothing anywhere notices.
Both are silent, and both corrupt cumulatively: every subsequent write
goes through code whose assumptions about the row shape are wrong.
``checksum`` fixes it by storing the sha256 of the migration file that
actually ran. ``assert_schema_compatible`` then compares applied
checksums against the files this build ships, so a divergence is a
loud, recoverable error instead of silence.
Nullable on purpose: rows applied before this migration existed have no
recorded checksum and cannot retroactively grow one. The guard skips
them rather than guessing — a NULL means "unknown", never "mismatched".
Idempotent via ``PRAGMA table_info`` so tests that rewind the
``migrations`` table can re-run it against a populated DB.
"""
from __future__ import annotations
import sqlite3
def _has_column(conn: sqlite3.Connection, table: str, column: str) -> bool:
cur = conn.execute(f"PRAGMA table_info({table});")
return any(row[1] == column for row in cur.fetchall())
def up(conn: sqlite3.Connection) -> None:
if not _has_column(conn, "migrations", "checksum"):
conn.execute("ALTER TABLE migrations ADD COLUMN checksum TEXT;")
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "comfyui-wildcard-pipeline",
"version": "2.12.0",
"version": "2.13.2",
"private": true,
"description": "Modular procedural prompt generation for ComfyUI",
"author": "DumiFlex",
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "comfyui-wildcard-pipeline"
version = "2.12.0"
version = "2.13.2"
description = "Modular procedural prompt generation for ComfyUI — wildcards, constraints, derivations, and context pipelines."
readme = "README.md"
requires-python = ">=3.10"
+75 -8
View File
@@ -8,7 +8,8 @@
*
* Usage:
* node scripts/discord-announce.mjs --version v2.12.0 # dry run
* node scripts/discord-announce.mjs --version v2.12.0 --post
* node scripts/discord-announce.mjs --version v2.12.0 --post # → staff
* node scripts/discord-announce.mjs --version v2.12.0 --post --channel public
* node scripts/discord-announce.mjs --since v2.10.2 --post # catch-up
* node scripts/discord-announce.mjs --notes-file docs/release-notes/next.md \
* --version v2.12.0 # preview
@@ -17,15 +18,39 @@
* an announcement can never accidentally publish one.
*
* Environment:
* DISCORD_WEBHOOK_URL required with --post
* DISCORD_ROLE_ID optional; pinged as <@&ID>
* DISCORD_WEBHOOK_STAFF staff / pre-announcement channel (the default)
* DISCORD_WEBHOOK_PUBLIC public announcement channel (--channel public only)
* DISCORD_WEBHOOK_URL legacy fallback for the staff webhook
* DISCORD_ROLE_ID optional; pinged as <@&ID>, PUBLIC channel only
* DISCORD_USERNAME optional; overrides the posting name
* DISCORD_AVATAR_URL optional; overrides the posting avatar (raster only)
* GITHUB_TOKEN optional; raises the API rate limit for --since
*/
import { readFileSync } from "node:fs";
import { buildPayload, buildRollupPayload } from "./lib/discord-notes.mjs";
import { buildPayload, buildRollupPayload, normalizeRoleId } from "./lib/discord-notes.mjs";
const REPO = "DumiFlex/ComfyUI-Wildcard-Pipeline";
/**
* Two channels, deliberately asymmetric.
*
* `staff` posts automatically on every release and carries a link to publish
* the same message onward — it is the review step. `public` only ever runs
* from a deliberate manual dispatch, so a release can never announce itself to
* everyone without someone having read it first.
*/
const CHANNELS = {
staff: ["DISCORD_WEBHOOK_STAFF", "DISCORD_WEBHOOK_URL"],
public: ["DISCORD_WEBHOOK_PUBLIC"],
};
function webhookFor(channel) {
for (const name of CHANNELS[channel]) {
if (process.env[name]) return { url: process.env[name], via: name };
}
return { url: null, via: CHANNELS[channel][0] };
}
function arg(name, fallback = undefined) {
const i = process.argv.indexOf(`--${name}`);
if (i === -1) return fallback;
@@ -62,7 +87,34 @@ async function fetchReleasesSince(sinceTag) {
async function main() {
const post = arg("post", false) === true;
const roleId = process.env.DISCORD_ROLE_ID || undefined;
const channel = arg("channel", "staff");
if (channel !== "staff" && channel !== "public") {
console.error(`Unknown --channel "${channel}". Use staff or public.`);
process.exit(1);
}
// Belt to the workflow's braces. The workflow only passes `--channel public`
// on a manual dispatch, but a release event reaching this line with public
// selected would announce to everyone with nobody having read it.
if (channel === "public" && process.env.GITHUB_EVENT_NAME === "release") {
console.error("Refusing to post to the public channel from a release event.");
process.exit(1);
}
// Only the public post pings. A role ping in the staff channel on every
// release is noise in a room that already exists to watch releases.
const rawRole = channel === "public" ? process.env.DISCORD_ROLE_ID : undefined;
const roleId = normalizeRoleId(rawRole);
// `buildPayload` drops a malformed id either way, but silence would leave
// someone wondering why the ping never fired.
if (rawRole && rawRole.trim() && !roleId) {
console.warn(
`DISCORD_ROLE_ID is not a valid role id (${JSON.stringify(rawRole)}) — posting without a ping.`,
);
}
// Undefined falls through to the defaults in discord-notes.mjs — an empty
// env var must not blank the identity and hand the post back to the
// webhook's own name.
const username = process.env.DISCORD_USERNAME || undefined;
const avatarUrl = process.env.DISCORD_AVATAR_URL || undefined;
const since = arg("since");
const notesFile = arg("notes-file");
let version = arg("version");
@@ -80,6 +132,9 @@ async function main() {
sinceVersion: since,
releaseUrl: releases[0].html_url,
roleId,
username,
avatarUrl,
channel,
});
} else if (typeof notesFile === "string") {
// Local preview against unreleased notes. No network, so the compare link
@@ -88,6 +143,9 @@ async function main() {
version: version || "(unreleased)",
body: readFileSync(notesFile, "utf8"),
roleId,
username,
avatarUrl,
channel,
});
} else {
const rel = await fetchRelease(typeof version === "string" ? version : undefined);
@@ -97,11 +155,20 @@ async function main() {
body: rel.body ?? "",
releaseUrl: rel.html_url,
roleId,
username,
avatarUrl,
channel,
});
}
if (!post) {
console.log("--- DRY RUN (pass --post to send) ---\n");
// Identity is part of what a preview is for: it is the thing that was
// wrong on the first real post, and it is invisible in the embed body.
console.log(`channel: ${channel}`);
console.log(`posting as: ${payload.username}`);
console.log(`avatar: ${payload.avatar_url}`);
console.log("");
console.log(payload.embeds[0].title);
console.log("-".repeat(60));
console.log(payload.embeds[0].description);
@@ -110,9 +177,9 @@ async function main() {
return;
}
const webhook = process.env.DISCORD_WEBHOOK_URL;
const { url: webhook, via } = webhookFor(channel);
if (!webhook) {
console.error("DISCORD_WEBHOOK_URL is not set — refusing to post.");
console.error(`${via} is not set — refusing to post to the ${channel} channel.`);
process.exit(1);
}
@@ -125,7 +192,7 @@ async function main() {
console.error(`Discord ${res.status}: ${await res.text()}`);
process.exit(1);
}
console.log(`Announced ${payload.embeds[0].title}`);
console.log(`Announced ${payload.embeds[0].title} → ${channel}`);
}
main().catch((err) => {
+89 -2
View File
@@ -26,6 +26,54 @@ export const EMBED_DESCRIPTION_LIMIT = 4096;
/** Accent purple, matching `--wp-accent-500` in the manager. */
export const BRAND_COLOR = 0x8b5cf6;
/**
* Who the post appears to be from.
*
* A webhook posts under whatever name and avatar it was created with — the
* Discord default is a generic name like "Captain Hook" and a placeholder
* image, which is what an announcement went out as. `username` and
* `avatar_url` override that PER MESSAGE, so the identity lives here rather
* than in a webhook's settings: it stays right if the webhook is ever rotated
* or recreated, and it is the same in every channel the automation posts to.
*/
export const WEBHOOK_USERNAME = "Wildcard Pipeline";
/**
* Discord fetches this server-side, so it has to be a public raster URL — an
* SVG will not render, which rules out the repo's `favicon.svg`. Pinned to
* `main` rather than a tag so replacing the logo does not need a code change.
*/
export const WEBHOOK_AVATAR_URL =
"https://raw.githubusercontent.com/DumiFlex/ComfyUI-Wildcard-Pipeline/main/public/images/web-app-manifest-512x512.png";
/**
* A role id we are willing to turn into a ping, or `undefined`.
*
* An unset secret was always safe, but a MALFORMED one was not: any truthy
* string went straight into `<@&…>`, so a stray space produced a literal
* `<@& >` at the top of the announcement and a pasted mention produced
* `<@&<@&123>>`. Both render as visible junk in the most public message the
* project sends.
*
* Accepts a bare snowflake or the `<@&123>` form, since "Copy ID" and copying
* the mention text are equally easy mistakes to make. Anything else is
* rejected rather than guessed at — a ping that silently does not happen is a
* much smaller problem than a broken one that does.
*/
export function normalizeRoleId(raw) {
if (typeof raw !== "string" && typeof raw !== "number") return undefined;
const trimmed = String(raw).trim();
if (!trimmed) return undefined;
const unwrapped = trimmed.replace(/^<@&(\d+)>$/, "$1");
// Discord snowflakes are 17-20 digits today; the range leaves room without
// accepting arbitrary numbers.
return /^\d{17,20}$/.test(unwrapped) ? unwrapped : undefined;
}
/** Where the manual "publish to the public channel" run is started. */
export const DISPATCH_URL =
"https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/actions/workflows/discord-announce.yml";
/**
* Split a release body into the part worth announcing and what is left behind.
*
@@ -162,11 +210,19 @@ export function buildPayload({
body,
releaseUrl,
compareUrl,
roleId,
roleId: rawRoleId,
username = WEBHOOK_USERNAME,
avatarUrl = WEBHOOK_AVATAR_URL,
channel = "staff",
installHint = 'ComfyUI Manager → search "Wildcard Pipeline" → Update → restart ComfyUI',
}) {
const description = buildDescription(body, { compareUrl });
// Normalised HERE rather than at the call site so every caller — CLI,
// roll-up, future ones — gets the same guarantee.
const roleId = normalizeRoleId(rawRoleId);
return {
username,
avatar_url: avatarUrl,
content: roleId ? `<@&${roleId}>` : "",
allowed_mentions: { parse: [], roles: roleId ? [roleId] : [] },
embeds: [
@@ -175,12 +231,37 @@ export function buildPayload({
url: releaseUrl || undefined,
description,
color: BRAND_COLOR,
fields: reviewFields(channel),
footer: { text: installHint },
},
],
};
}
/**
* The staff copy carries its own publish button; the public copy does not.
*
* This is what makes the staff channel a review step rather than a duplicate
* feed: the post you are reading is the post that will go out, and the link to
* send it is attached to it. Without that, "go and publish it" is a separate
* instruction living in someone's head.
*
* Returned as `undefined` for the public channel rather than an empty array —
* Discord rejects `fields: []` on some client versions, and an empty field
* block would render as dead space in the announcement everyone sees.
*/
function reviewFields(channel) {
if (channel !== "staff") return undefined;
return [
{
name: "Ready to go public?",
value:
`[Run the workflow](${DISPATCH_URL}) with **channel: public** and ` +
"**dry_run: false** to post this to the announcement channel.",
},
];
}
/**
* Several releases → one catch-up payload.
*
@@ -191,7 +272,10 @@ export function buildPayload({
*
* `releases` is newest-first: `{ version, body }`.
*/
export function buildRollupPayload({ releases, sinceVersion, releaseUrl, roleId, installHint }) {
export function buildRollupPayload({
releases, sinceVersion, releaseUrl, roleId, installHint, username, avatarUrl,
channel = "staff",
}) {
const latest = releases[0];
const sections = releases.map((r) => {
const { head } = splitBody(r.body);
@@ -205,6 +289,9 @@ export function buildRollupPayload({ releases, sinceVersion, releaseUrl, roleId,
releaseUrl,
roleId,
installHint,
username,
avatarUrl,
channel,
});
base.embeds[0].title = sinceVersion
? `Wildcard Pipeline ${latest?.version ?? ""} — everything since ${sinceVersion}`
+118 -3
View File
@@ -1,5 +1,7 @@
import { describe, it, expect } from "vitest";
import {
DISPATCH_URL,
normalizeRoleId,
splitBody,
toDiscordMarkdown,
buildDescription,
@@ -180,11 +182,104 @@ describe("buildPayload", () => {
expect(p.allowed_mentions.roles).toEqual([]);
});
/**
* An unset secret was always safe. A MALFORMED one was not: any truthy string
* went straight into `<@&…>`, so a stray space produced a literal `<@& >`
* at the top of the most public message the project sends.
*/
describe("role ping", () => {
const content = (roleId) => buildPayload({ version: "v1", body: "x", roleId }).content;
it("stays silent for every flavour of absent", () => {
expect(content(undefined)).toBe("");
expect(content(null)).toBe("");
expect(content("")).toBe("");
expect(content(" ")).toBe("");
});
it("refuses anything that is not a snowflake, rather than guessing", () => {
expect(content("not-an-id")).toBe("");
expect(content("12345")).toBe(""); // too short
expect(content("1234567890123456789012")).toBe(""); // too long
expect(content("123abc456def78901")).toBe("");
});
it("never emits a half-formed mention", () => {
for (const bad of [" ", "not-an-id", "<@&>", "@everyone"]) {
expect(content(bad)).not.toContain("<@&");
}
});
// "Copy ID" and copying the mention text are equally easy mistakes.
it("accepts a pasted mention as well as a bare id", () => {
expect(content("123456789012345678")).toBe("<@&123456789012345678>");
expect(content("<@&123456789012345678>")).toBe("<@&123456789012345678>");
expect(content(" 123456789012345678 ")).toBe("<@&123456789012345678>");
});
it("keeps allowed_mentions in step with the content", () => {
const bad = buildPayload({ version: "v1", body: "x", roleId: "junk" });
expect(bad.allowed_mentions.roles).toEqual([]);
const good = buildPayload({ version: "v1", body: "x", roleId: "123456789012345678" });
expect(good.allowed_mentions.roles).toEqual(["123456789012345678"]);
expect(good.allowed_mentions.parse).toEqual([]);
});
it("normalizeRoleId is the single decision point", () => {
expect(normalizeRoleId("<@&123456789012345678>")).toBe("123456789012345678");
expect(normalizeRoleId("nope")).toBeUndefined();
expect(normalizeRoleId(undefined)).toBeUndefined();
});
});
it("pings only the configured role, and only when configured", () => {
expect(buildPayload({ version: "v1", body: "x" }).content).toBe("");
const p = buildPayload({ version: "v1", body: "x", roleId: "123" });
expect(p.content).toBe("<@&123>");
expect(p.allowed_mentions.roles).toEqual(["123"]);
const p = buildPayload({ version: "v1", body: "x", roleId: "123456789012345678" });
expect(p.content).toBe("<@&123456789012345678>");
expect(p.allowed_mentions.roles).toEqual(["123456789012345678"]);
});
// A webhook posts under whatever name and avatar it was created with —
// Discord's default is a generic name ("Captain Hook") and a placeholder
// image, which is exactly what v2.12.0 went out as.
it("posts under the project's own name and avatar, not the webhook's", () => {
const p = buildPayload({ version: "v1", body: "x" });
expect(p.username).toBe("Wildcard Pipeline");
expect(p.avatar_url).toMatch(/^https:\/\/.+\.png$/);
});
it("uses a raster avatar — Discord will not render an SVG", () => {
expect(buildPayload({ version: "v1", body: "x" }).avatar_url).not.toMatch(/\.svg$/i);
});
it("lets a caller override the identity", () => {
const p = buildPayload({
version: "v1", body: "x", username: "WP Staff", avatarUrl: "https://x/a.png",
});
expect(p.username).toBe("WP Staff");
expect(p.avatar_url).toBe("https://x/a.png");
});
/**
* Two channels, deliberately asymmetric. The staff copy is the review step,
* so it carries the button that publishes it onward; the public copy is the
* announcement itself and must not tell readers how to re-send it.
*/
it("gives the staff copy a publish link, so review and button are one thing", () => {
const p = buildPayload({ version: "v1", body: "x", channel: "staff" });
const field = p.embeds[0].fields[0];
expect(field.value).toContain(DISPATCH_URL);
expect(field.value).toMatch(/channel.*public/i);
});
it("defaults to the staff copy — the safe one to send by accident", () => {
expect(buildPayload({ version: "v1", body: "x" }).embeds[0].fields).toBeTruthy();
});
it("leaves the public copy without the publish link", () => {
const p = buildPayload({ version: "v1", body: "x", channel: "public" });
// `undefined`, not `[]` — an empty field block renders as dead space.
expect(p.embeds[0].fields).toBeUndefined();
});
it("stays inside the embed description cap", () => {
@@ -210,6 +305,26 @@ describe("buildRollupPayload", () => {
expect(p.embeds[0].description).toContain("**v2.11.0**");
});
it("carries the channel through a roll-up", () => {
const staff = buildRollupPayload({
releases: [{ version: "v2.12.0", body: "- x" }], sinceVersion: "v2.11.0", channel: "staff",
});
const pub = buildRollupPayload({
releases: [{ version: "v2.12.0", body: "- x" }], sinceVersion: "v2.11.0", channel: "public",
});
expect(staff.embeds[0].fields).toBeTruthy();
expect(pub.embeds[0].fields).toBeUndefined();
});
it("keeps the identity on a roll-up too", () => {
const p = buildRollupPayload({
releases: [{ version: "v2.12.0", body: "- x" }],
sinceVersion: "v2.10.2",
});
expect(p.username).toBe("Wildcard Pipeline");
expect(p.avatar_url).toMatch(/\.png$/);
});
it("leads with the newest, which is what people decide to install", () => {
const p = buildRollupPayload({
releases: [
+55
View File
@@ -17,6 +17,7 @@
* - web/ (built manager SPA)
* - public/ (favicons + doc images referenced by the SPA)
* - docs/help/ (per-node help markdown the canvas reads)
* - locales/ (node display names + settings labels ComfyUI reads)
* - README.md
* - LICENSE
*
@@ -59,8 +60,55 @@ const SHIP_PATHS = [
"web",
"public",
"docs/help",
// ComfyUI reads `custom_nodes/<pack>/locales/<lang>/` for node display
// names, descriptions and settings labels (app/custom_node_manager.py).
// The registry archive picks this up automatically because it ships
// everything git-tracked that `.comfyignore` doesn't exclude; this list is
// hand-maintained, so it did not. The two artifacts silently disagreed —
// see DEV_ONLY below for the guard that now stops that recurring.
"locales",
];
/**
* Top-level tracked entries that deliberately do NOT ship.
*
* Exists to make the ship list total: every top-level path in git is either
* shipped or listed here, and `assertShipListIsTotal` fails the release if a
* new one appears in neither. Without it, adding a runtime directory and
* forgetting this file produces a zip that installs and silently misbehaves —
* which is exactly how `locales/` went missing.
*/
const DEV_ONLY = new Set([
".comfyignore", ".gitattributes", ".github", ".gitignore", ".husky", ".npmrc",
".releaserc.js", "CHANGELOG.md", "CLAUDE.md", "config", "conftest.py",
"eslint.config.js", "package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml",
"scripts", "src", "tests", "tsconfig.json", "tsconfig.node.json",
"vite.config.mts", "vitest.config.ts",
]);
function assertShipListIsTotal() {
const ls = spawnSync("git", ["ls-files"], { cwd: root, encoding: "utf8" });
if (ls.status !== 0) {
console.warn("pack-release: `git ls-files` unavailable — skipping ship-list completeness check");
return;
}
// A ship path may name a subtree (`docs/help`); its top-level segment is
// what makes the parent directory "accounted for".
const covered = new Set(SHIP_PATHS.map((p) => p.split("/")[0]));
const tracked = new Set(
ls.stdout.split("\n").map((line) => line.trim().split("/")[0]).filter(Boolean),
);
const unaccounted = [...tracked].filter((e) => !covered.has(e) && !DEV_ONLY.has(e)).sort();
if (unaccounted.length > 0) {
console.error("pack-release: top-level paths in git that are neither shipped nor dev-only:");
for (const p of unaccounted) console.error(` - ${p}`);
console.error("Add each to SHIP_PATHS (ships to users) or DEV_ONLY (stays in the repo).");
process.exit(1);
}
}
assertShipListIsTotal();
// Sanity: every path must exist before zipping. If `js/` or `web/`
// is missing the release would ship a broken bundle — catch here so the
// release workflow fails fast rather than uploading a half-baked zip.
@@ -93,6 +141,13 @@ const args = [
];
const result = spawnSync("zip", args, { stdio: "inherit", cwd: root });
if (result.error?.code === "ENOENT") {
// Windows has no `zip` by default, so a maintainer running this locally
// otherwise gets a bare "exited with status null".
console.error("pack-release: the `zip` CLI was not found on PATH.");
console.error("The release workflow runs on ubuntu-latest where it is always present.");
process.exit(1);
}
if (result.status !== 0) {
console.error(`pack-release: zip exited with status ${result.status}`);
process.exit(result.status ?? 1);
@@ -244,7 +244,14 @@ export function useGrowableField(
window.removeEventListener("pointercancel", onPointerUp);
}
/** Idempotent: callers re-attach when the element they own is REPLACED
* rather than mutated (RichTextInput swaps its contenteditable host to
* repair a browser-mangled DOM). Without the disconnect the old observer
* would keep watching a detached node and leak, and the hint would go
* permanently stale on the live one. */
function attach(): void {
obs?.disconnect();
obs = null;
const el = getEl();
if (!el) return;
lastHeight = el.getBoundingClientRect().height;
+2
View File
@@ -132,6 +132,8 @@ describe("AppTopbar.vue", () => {
lastChecked: ref(null),
checking: ref(false),
rateLimitedUntil: ref(null),
history: ref([]),
loadHistory: vi.fn(),
checkNow: vi.fn(),
} as ReturnType<typeof releaseCheck.useReleaseCheck>);
+162
View File
@@ -302,6 +302,113 @@ describe("RichTextInput.vue", () => {
wrap.unmount();
});
it("survives a blur re-parse that drops an atom after the anchors went missing", async () => {
// The sibling of the picker-Apply crash: same missing `<template v-for>`
// fragment anchors, reached from the plain re-parse path (no `rebuild`).
// Shrinking the list unmounts an item's Fragment, and `removeFragment`
// would walk off the end exactly as it does on a full re-key.
// Here `hostDomIsStale()` DOES fire — the deleted chip drops the atom-node
// count below the list length — so the host swap already covers it. This
// pins that: the two repairs must not diverge, since only one of them was
// ever reachable when the anchors are gone but the elements are intact.
const errors: string[] = [];
const onErr = (e: ErrorEvent): void => { errors.push(String(e.error ?? e.message)); };
window.addEventListener("error", onErr);
const ref = "@{aabbccdd#pose}";
const wrap = mount(RichTextInput, {
props: {
modelValue: `a ${ref} b ${ref} c`,
surface: "wildcard",
uuidToName: new Map([["aabbccdd", "pose"]]),
},
attachTo: document.body,
});
const host = wrap.find(".wp-rt__host").element as HTMLElement;
[...host.childNodes]
.filter((n) => n.nodeType === Node.TEXT_NODE && (n as Text).data === "")
.forEach((n) => (n as Text).remove());
// User deletes the second chip; the surviving atom nodes stay put.
const chips = host.querySelectorAll(".wp-refchip");
chips[1].remove();
await wrap.find(".wp-rt__host").trigger("blur");
await flushPromises();
window.removeEventListener("error", onErr);
expect(errors).toEqual([]);
expect(wrap.findAll(".wp-refchip")).toHaveLength(1);
wrap.unmount();
});
it("survives Ctrl+A Ctrl+X after the anchors went missing", async () => {
// Reported on Firefox: select-all + cut inside an option value bricked the
// field — text input, Backspace and even Save/Cancel stopped responding —
// with `insertBefore: Child to insert before is not a child of this node`.
//
// `onHostCut` already routes the deletion through `applyAtoms` (2.13.1) so
// the browser never removes the spans itself. But the atom list still
// SHRINKS from N to 1, and shrinking a keyed list unmounts the surplus
// `<template v-for>` Fragments. With the fragment anchors normalised away
// by Firefox, that teardown walks off the end of the child list and takes
// the whole editor down — the same wound as the ref-picker crash, reached
// through a path that never asked for a rebuild.
const errors: string[] = [];
const onErr = (e: ErrorEvent): void => { errors.push(String(e.error ?? e.message)); };
window.addEventListener("error", onErr);
const ref = "@{aabbccdd#pose}";
const wrap = mount(RichTextInput, {
props: {
modelValue: `a ${ref} b ${ref} c`,
surface: "wildcard",
uuidToName: new Map([["aabbccdd", "pose"]]),
},
attachTo: document.body,
});
const host = wrap.find(".wp-rt__host").element as HTMLElement;
[...host.childNodes]
.filter((n) => n.nodeType === Node.TEXT_NODE && (n as Text).data === "")
.forEach((n) => (n as Text).remove());
// Ctrl+A.
const sel = window.getSelection();
const range = document.createRange();
range.selectNodeContents(host);
sel?.removeAllRanges();
sel?.addRange(range);
// Ctrl+X.
await wrap.find(".wp-rt__host").trigger("cut");
await flushPromises();
window.removeEventListener("error", onErr);
expect(errors).toEqual([]);
// Emptied, and still a live editor rather than a dead subtree.
const events = wrap.emitted("update:modelValue") ?? [];
expect(events[events.length - 1]?.[0]).toBe("");
const fresh = wrap.find(".wp-rt__host").element as HTMLElement;
expect(fresh.querySelectorAll("[data-atom-index]").length).toBeGreaterThan(0);
wrap.unmount();
});
it("does not remount the host for a structural apply while the anchors are intact", async () => {
// The other half of the anchor check: it must stay OFF on the browser
// where the anchors survive. Settling a chip is a structural apply — it
// splits one text atom into text + chip + text — and it happens on nearly
// every keystroke that closes a token. Swapping the host there would cost
// a caret restore per settle on Chromium for no reason at all.
const wrap = mount(RichTextInput, {
props: { modelValue: "", varSuggestions: [] },
attachTo: document.body,
});
const before = wrap.find(".wp-rt__host").element;
const span = (before as HTMLElement).querySelector(".wp-rt__text") as HTMLElement;
span.textContent = "$runtimeVar ";
await wrap.find(".wp-rt__host").trigger("input", { inputType: "insertText", data: " " });
await flushPromises();
// The settle landed...
expect(wrap.findAll(".wp-refchip--var")).toHaveLength(1);
// ...on the very same host element.
expect(wrap.find(".wp-rt__host").element).toBe(before);
wrap.unmount();
});
it("does not remount the host for the normal typing case (orphan text node)", async () => {
// Guard against over-triggering the stale-DOM repair: browsers routinely
// insert user-typed characters as raw text nodes directly under the host.
@@ -681,6 +788,61 @@ describe("RichTextInput.vue", () => {
wrap.unmount();
});
it("survives a picker Apply after the browser dropped the v-for fragment anchors", async () => {
// Regression (Firefox): `<template v-for>` mounts each atom inside a
// Fragment, and a Fragment is delimited in the DOM by two EMPTY text
// nodes. Those anchors live inside a contenteditable, and Firefox
// normalises empty text nodes away as the user types — Chromium keeps
// them, which is why this only ever reproduced on Firefox.
//
// The picker-Apply path then remounts the atom v-for, and Vue's
// `removeFragment` walks `nextSibling` from the start anchor until it
// reaches the end anchor. With the end anchor gone the walk runs off the
// end of the child list and derefs null: "can't access property
// nextSibling, e is null", followed by the unmount cascade that leaves
// the editor dead behind the ErrorBoundary.
//
// `hostDomIsStale()` cannot see this — it counts `[data-atom-index]`
// ELEMENTS, and the destroyed nodes are anchors, not atoms.
const errors: string[] = [];
const onErr = (e: ErrorEvent): void => { errors.push(String(e.error ?? e.message)); };
window.addEventListener("error", onErr);
const wrap = mount(RichTextInput, {
props: {
modelValue: "hair ",
surface: "wildcard",
refSuggestions: ["aabbccdd"],
uuidToName: new Map([["aabbccdd", "color"]]),
uuidToSubCategories: new Map([["aabbccdd", ["warm", "cool"]]]),
},
attachTo: document.body,
});
const host = wrap.find(".wp-rt__host").element as HTMLElement;
// What Firefox does to the host while the user types `@hair`.
const anchors = [...host.childNodes].filter(
(n) => n.nodeType === Node.TEXT_NODE && (n as Text).data === "",
);
expect(anchors.length).toBeGreaterThan(0);
anchors.forEach((n) => (n as Text).remove());
await (wrap.vm as unknown as { __triggerAutocompleteForTest: (t: "@" | "$") => Promise<void> })
.__triggerAutocompleteForTest("@");
await (wrap.vm as unknown as { __applyAutocompleteForTest: (label: string) => Promise<void> })
.__applyAutocompleteForTest("aabbccdd");
(document.querySelector('[data-test="subcat-chip"][data-value="warm"]') as HTMLElement).click();
await flushPromises();
(document.querySelector('[data-test="picker-apply"]') as HTMLElement).click();
await flushPromises();
window.removeEventListener("error", onErr);
expect(errors).toEqual([]);
// And the insert actually landed — the editor is still alive, not a
// half-torn subtree.
const events = wrap.emitted("update:modelValue") ?? [];
expect(events[events.length - 1]?.[0]).toContain("@{aabbccdd#color:warm}");
wrap.unmount();
});
it("subcat picker Skip inserts plain @{uuid#name} chip", async () => {
const wrap = mount(RichTextInput, {
props: {
@@ -27,6 +27,8 @@ beforeEach(() => {
lastChecked: ref("2026-07-21T10:00:00.000Z"),
checking: ref(false),
rateLimitedUntil: ref(null),
history: ref([]),
loadHistory: vi.fn(),
checkNow,
} as ReturnType<typeof releaseCheck.useReleaseCheck>);
});
@@ -0,0 +1,119 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import { setActivePinia, createPinia } from "pinia";
import { ref } from "vue";
import { createMemoryHistory, createRouter } from "vue-router";
import WhatsNew from "../../views/WhatsNew.vue";
import * as releaseCheck from "../../composables/useReleaseCheck";
(globalThis as unknown as { __APP_VERSION__: string }).__APP_VERSION__ = "2.13.0";
const NOTES = "Headline.\n\n### Highlights\n\n- **A thing.** detail\n";
function stubRelease(over: Record<string, unknown> = {}) {
return vi.spyOn(releaseCheck, "useReleaseCheck").mockReturnValue({
current: "2.13.0",
latestVersion: ref("2.13.0"),
hasUpdate: ref(false),
severity: ref(null),
releaseBody: ref(NOTES),
releaseUrl: ref("https://github.com/o/r/releases/tag/v2.13.0"),
lastChecked: ref(null),
checking: ref(false),
rateLimitedUntil: ref(null),
history: ref([
{ version: "2.13.0", body: NOTES, url: "https://x/13", publishedAt: "2026-08-04T00:00:00Z" },
{ version: "2.12.0", body: "The big one.\n\n- **Nodes 2.0.** yes", url: "https://x/12", publishedAt: "2026-08-03T00:00:00Z" },
{ version: "2.11.0", body: "Older.\n\n- **Held modules.** yes", url: "https://x/11", publishedAt: "2026-07-24T00:00:00Z" },
]),
loadHistory: vi.fn(),
checkNow: vi.fn(),
...over,
} as unknown as ReturnType<typeof releaseCheck.useReleaseCheck>);
}
async function mountPage() {
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: "/whats-new", component: WhatsNew }],
});
router.push("/whats-new");
await router.isReady();
const w = mount(WhatsNew, { global: { plugins: [router] } });
await flushPromises();
return w;
}
beforeEach(() => {
setActivePinia(createPinia());
vi.restoreAllMocks();
localStorage.clear();
});
describe("WhatsNew — earlier releases", () => {
/**
* 2.12.0 was current for about an hour before 2.13.0 replaced it. Anyone
* updating from 2.11.0 lands on 2.13.0, so showing only the newest tag makes
* the largest release in months invisible.
*/
it("shows the releases behind the current one", async () => {
stubRelease();
const w = await mountPage();
expect(w.find('[data-test="whats-new-older"]').exists()).toBe(true);
expect(w.find('[data-test="whats-new-older-2.12.0"]').exists()).toBe(true);
expect(w.find('[data-test="whats-new-older-2.11.0"]').exists()).toBe(true);
});
it("does not repeat the release already shown in full above", async () => {
stubRelease();
const w = await mountPage();
expect(w.find('[data-test="whats-new-older-2.13.0"]').exists()).toBe(false);
});
it("renders each earlier release's notes, not just its number", async () => {
stubRelease();
const w = await mountPage();
expect(w.find('[data-test="whats-new-older-2.12.0"]').text()).toContain("Nodes 2.0");
});
// Collapsed: the newest release is why the page was opened.
it("keeps earlier releases collapsed", async () => {
stubRelease();
const w = await mountPage();
const el = w.find('[data-test="whats-new-older-2.12.0"]').element as HTMLDetailsElement;
expect(el.open).toBe(false);
});
it("asks for the history on mount", async () => {
const loadHistory = vi.fn();
stubRelease({ loadHistory });
await mountPage();
expect(loadHistory).toHaveBeenCalled();
});
it("skips a release with an empty body rather than showing a bare heading", async () => {
stubRelease({
history: ref([
{ version: "2.12.0", body: " ", url: null, publishedAt: null },
]),
});
const w = await mountPage();
expect(w.find('[data-test="whats-new-older"]').exists()).toBe(false);
});
it("renders nothing extra when there is no history", async () => {
stubRelease({ history: ref([]) });
const w = await mountPage();
expect(w.find('[data-test="whats-new-older"]').exists()).toBe(false);
});
// The button says "All releases".
it("links to the releases index, not to one release", async () => {
stubRelease();
const w = await mountPage();
const href = w.find('[data-test="whats-new-github"]').attributes("href");
expect(href).toMatch(/\/releases$/);
expect(href).not.toContain("/tag/");
});
});
+110 -29
View File
@@ -542,20 +542,27 @@ function parseForSurface(text: string): Atom[] {
}
const atoms = ref<Atom[]>(padAtoms(parseForSurface(props.modelValue || "")));
// Bumped only on structural-insert applies (autocomplete / ref-picker) to
// force a clean teardown+remount of the atom v-for instead of patching the
// new structure over a hand-mutated, out-of-sync contenteditable DOM. Folded
// into the v-for :key. See applyAtoms() for why the typing path never bumps it.
const renderEpoch = ref(0);
// Bumped when the browser destroyed rendered atom nodes behind Vue's back
// (see `hostDomIsStale`). Keyed on the HOST ELEMENT, not the atom v-for:
// once Vue's child vnodes hold detached `el`s, ANY patch of that subtree —
// including the full key-churn teardown `renderEpoch` triggers — derefs a
// null `el` and throws `insertBefore: Child to insert before is not a child
// of this node`. Replacing the host element itself is the only safe repair:
// Vue unmounts an element vnode with a single `hostRemove(el)` on a node
// that IS still attached, tearing children down with `doRemove: false` so
// no stale child el is ever touched.
// Bumped whenever the atom v-for has to be rebuilt rather than patched:
// the browser destroyed rendered nodes behind Vue's back (see
// `hostDomIsStale`), or a structural insert is landing on a hand-mutated
// contenteditable (autocomplete / ref-picker apply).
//
// Keyed on the HOST ELEMENT, never on the atom v-for. Re-keying the v-for
// looks like the lighter option and is not: the host is a contenteditable,
// so every node inside it — including the two EMPTY TEXT NODES Vue uses to
// delimit each `<template v-for>` Fragment — belongs to the browser, which
// deletes and merges them at will (Firefox normalises empty text nodes away
// as you type; Chromium keeps them, which is why this only ever reproduced
// on Firefox). Once an anchor is gone, tearing the v-for down runs Vue's
// `removeFragment` walk off the end of the child list and throws
// `nextSibling of null`, and patching over detached child els throws
// `insertBefore: Child to insert before is not a child of this node`.
//
// Replacing the host element sidesteps both: Vue unmounts an element vnode
// with a single `hostRemove(el)` on a node that IS still attached, tearing
// its children down with `doRemove: false` so no stale child el or missing
// anchor is ever touched, then mounts the new tree into an empty element
// with no anchor to insert before.
const hostEpoch = ref(0);
let lastEmittedValue = props.modelValue || "";
@@ -1359,6 +1366,17 @@ onMounted(() => {
attach();
});
/** A host swap replaces the observed element, so the ResizeObserver has to
* follow it — otherwise the overflow fade and grip-follow silently stop
* working for the rest of the session on any field that ever had to repair
* its DOM. */
watch(hostEpoch, () => {
void nextTick(() => {
attach();
scheduleOverflowHint();
});
});
/** True when the live host has FEWER rendered atom nodes than `atoms.value`
* expects — i.e. the browser destroyed nodes Vue still holds `el` pointers
* for. Contenteditable hands the browser ownership of these children, and
@@ -1377,24 +1395,87 @@ function hostDomIsStale(): boolean {
return host.querySelectorAll("[data-atom-index]").length < atoms.value.length;
}
/** True while every Fragment anchor Vue needs is still in the host.
*
* Vue delimits each Fragment with two EMPTY TEXT NODES, and `<template
* v-for>` builds one Fragment per atom plus one for the list itself — so a
* healthy host holds `2N + 2` empty text nodes as direct children. They sit
* inside a contenteditable, which makes them the browser's to destroy:
* Firefox normalises empty text nodes away as the user types, Chromium keeps
* them. That difference is why every crash in this family has been
* Firefox-only.
*
* Each anchor is load-bearing. Removing a list item walks `nextSibling` from
* its start anchor until it reaches its end anchor (`removeFragment`), and
* appending one inserts before the LIST's end anchor. With either gone the
* render throws — `nextSibling of null`, or `insertBefore: Child to insert
* before is not a child of this node` — and leaves a half-torn subtree that
* no longer responds to anything, Save and Cancel included.
*
* `hostDomIsStale` cannot stand in for this: it counts `[data-atom-index]`
* ELEMENTS, and anchors are neither elements nor atoms.
*
* Counts EMPTY text nodes only — characters the browser drops directly under
* the host are non-empty (see `reconcileOrphanTextNodes`), and a surplus is
* harmless, so the test is `>=`. */
function hostAnchorsIntact(): boolean {
const host = hostEl.value;
if (!host) return true;
let empties = 0;
for (const node of host.childNodes) {
if (node.nodeType === Node.TEXT_NODE && (node as Text).data === "") empties += 1;
}
return empties >= 2 * atoms.value.length + 2;
}
/** True when applying `next` would ADD or REMOVE atom nodes, or flip an atom
* between its chip and its text branch.
*
* Those are exactly the renders that make Vue insert or unmount list items,
* and therefore the only ones that touch a fragment anchor. A same-shape
* apply patches text and props in place and cannot trip over a missing one,
* which is what keeps ordinary typing on the cheap path. */
function isStructuralApply(next: Atom[]): boolean {
const cur = atoms.value;
if (cur.length !== next.length) return true;
return cur.some((atom, i) => (atom.kind === "text") !== (next[i].kind === "text"));
}
function applyAtoms(next: Atom[], opts?: { rebuild?: boolean }): void {
// `rebuild` forces a full teardown+remount of the atom v-for (via the
// renderEpoch key) instead of an in-place patch. Used ONLY on the
// structural-insert paths (autocomplete / ref-picker apply): there the
// contenteditable already lost focus to the popover/modal, so no live
// caret or typing is disturbed, AND the host DOM may have been hand-mutated
// (orphan text nodes, in-place span textContent) out of sync with Vue's
// vdom. Patching the new atom structure over that stale DOM is what throws
// `insertBefore: Child to insert before is not a child of this node`.
// `rebuild` forces a full teardown+remount instead of an in-place patch.
// Used ONLY on the structural-insert paths (autocomplete / ref-picker
// apply): there the contenteditable already lost focus to the popover/modal,
// so no live caret or typing is disturbed, AND the host DOM may have been
// hand-mutated (orphan text nodes, in-place span textContent, fragment
// anchors normalised away) out of sync with Vue's vdom. Patching the new
// atom structure over that stale DOM is what throws `insertBefore: Child to
// insert before is not a child of this node`.
// The raw typing / Backspace paths deliberately do NOT rebuild — they keep
// the imperative syncTextSpansToAtoms approach for caret stability (the
// removed global renderTick churned those paths; this scopes it to inserts).
// Stale-DOM repair takes precedence: bumping `renderEpoch` here would NOT
// help (verified — key churn still walks the destroyed els and throws on
// `nextSibling` of null). Replace the host element instead.
if (hostDomIsStale()) hostEpoch.value += 1;
else if (opts?.rebuild) renderEpoch.value += 1;
atoms.value = padAtoms(next);
//
// Both routes go through the HOST ELEMENT swap, never a v-for re-key:
// re-keying was verified NOT to help — the key churn still walks destroyed
// els and missing `<template v-for>` fragment anchors and throws on
// `nextSibling` of null. See the `hostEpoch` docblock.
//
// The third trigger is the general one, and the reason `rebuild` is no
// longer load-bearing on its own: ANY apply that adds, removes or re-branches
// an atom needs the fragment anchors, and the browser may have taken them.
// Marking individual call sites `rebuild` was a whitelist, and the whitelist
// kept missing entries — the ref picker, then Ctrl+A Ctrl+X, each crashing
// the same way from a path nobody had flagged. Asking the DOM instead covers
// all thirteen call sites at once, and costs nothing on Chromium, where the
// anchors are never missing in the first place.
const padded = padAtoms(next);
if (
hostDomIsStale() ||
opts?.rebuild ||
(isStructuralApply(padded) && !hostAnchorsIntact())
) {
hostEpoch.value += 1;
}
atoms.value = padded;
isEmpty.value = serialiseAtomsLocal(next).length === 0;
void nextTick(() => {
syncTextSpansToAtoms();
@@ -2640,7 +2721,7 @@ function onHostKeydown(ev: KeyboardEvent): void {
@copy="onHostCopy"
@cut="onHostCut"
>
<template v-for="(atom, idx) in atoms" :key="renderEpoch + ':' + idx">
<template v-for="(atom, idx) in atoms" :key="idx">
<RefChip
v-if="atom.kind === 'ref' || atom.kind === 'var'"
:kind="atom.kind"
+95 -1
View File
@@ -32,6 +32,19 @@ import { GITHUB_REPO } from "../config/links";
import { useUiStore } from "../stores/uiStore";
const STORAGE_KEY = "wp.releaseCheck";
/** Separate key: the history is a different shape and a different cadence. */
const HISTORY_KEY = "wp.releaseHistory";
/**
* How many past releases the What's new page offers.
*
* Three, because the gap being covered is small but real: a user who updates
* infrequently — or who was on 2.12.0 for the hour before 2.13.0 replaced it —
* would otherwise never see the notes for the release they actually skipped
* past. More than three and the page becomes a changelog, which the compare
* link already does better.
*/
export const HISTORY_COUNT = 3;
/**
* How stale the cache must be before a launch check goes back to the network.
@@ -70,6 +83,15 @@ const lastChecked = ref<string | null>(null);
const checking = ref<boolean>(false);
/** Epoch ms until which GitHub has refused us, or null when we are clear. */
const rateLimitedUntil = ref<number | null>(null);
/** Recent releases, newest first. Empty until `loadHistory` resolves. */
const history = ref<ReleaseSummary[]>([]);
export interface ReleaseSummary {
version: string;
body: string;
url: string | null;
publishedAt: string | null;
}
/** Guard so the once-per-session launch fetch fires at most once across all
* consumers. `checkNow` ignores it. */
@@ -97,6 +119,75 @@ export function resetReleaseCheckSession(): void {
lastChecked.value = null;
checking.value = false;
rateLimitedUntil.value = null;
history.value = [];
historyFetched = false;
}
/** Once per page: the history does not change while someone reads it. */
let historyFetched = false;
function readHistoryCache(): { at: string; items: ReleaseSummary[] } | null {
if (typeof localStorage === "undefined") return null;
const raw = localStorage.getItem(HISTORY_KEY);
if (!raw) return null;
try {
return JSON.parse(raw) as { at: string; items: ReleaseSummary[] };
} catch {
return null;
}
}
/**
* Fetch the last few releases for the What's new page.
*
* Subject to the same throttle and the same rate-limit lockout as the update
* check — this is a second GitHub call on a shared 60/hr anonymous quota, and
* the whole point of the caching work was that a page someone opens repeatedly
* must not spend a request each time.
*/
async function loadHistory(): Promise<void> {
const cached = readHistoryCache();
if (cached?.items?.length) history.value = cached.items;
const fresh = cached ? Date.now() - Date.parse(cached.at) < LAUNCH_TTL_MS : false;
if (historyFetched || fresh || isRateLimited()) return;
historyFetched = true;
const slug = repoSlug();
if (!slug) return;
try {
const res = await fetch(
`https://api.github.com/repos/${slug}/releases?per_page=${HISTORY_COUNT}`,
{ headers: { Accept: "application/vnd.github+json" } },
);
if (!res.ok) {
if (res.status === 403 || res.status === 429) {
const reset = Number.parseInt(res.headers?.get?.("X-RateLimit-Reset") ?? "", 10);
writeRateLimit(Number.isFinite(reset) ? reset * 1000 : Date.now() + 60 * 60 * 1000);
}
return;
}
const rows = (await res.json()) as Array<Record<string, unknown>>;
if (!Array.isArray(rows)) return;
const items: ReleaseSummary[] = rows
.filter((r) => r.draft !== true && r.prerelease !== true)
.map((r) => ({
version: normalizeTag(String(r.tag_name ?? "")),
body: typeof r.body === "string" ? r.body : "",
url: typeof r.html_url === "string" ? r.html_url : null,
publishedAt: typeof r.published_at === "string" ? r.published_at : null,
}))
.filter((r) => r.version.length > 0);
if (!items.length) return;
history.value = items;
try {
localStorage.setItem(HISTORY_KEY, JSON.stringify({ at: new Date().toISOString(), items }));
} catch {
// Storage denied — the in-memory list still serves this page view.
}
} catch {
// Offline. The cached list, if any, is already painted.
}
}
/** Is the persisted check recent enough to skip the network? */
@@ -330,6 +421,8 @@ export function useReleaseCheck(): {
lastChecked: ReturnType<typeof ref<string | null>>;
checking: ReturnType<typeof ref<boolean>>;
rateLimitedUntil: ReturnType<typeof ref<number | null>>;
history: ReturnType<typeof ref<ReleaseSummary[]>>;
loadHistory: () => Promise<void>;
checkNow: () => Promise<void>;
} {
const current = __APP_VERSION__;
@@ -366,6 +459,7 @@ export function useReleaseCheck(): {
return {
current, latestVersion, hasUpdate, severity,
releaseBody, releaseUrl, lastChecked, checking, rateLimitedUntil, checkNow,
releaseBody, releaseUrl, lastChecked, checking, rateLimitedUntil,
history, loadHistory, checkNow,
};
}
+81 -2
View File
@@ -25,6 +25,7 @@ import { useReleaseCheck } from "../composables/useReleaseCheck";
import { useEngagementStore } from "../stores/engagementStore";
import { renderReleaseNotes } from "../utils/releaseNotes";
import { GITHUB_REPO } from "../config/links";
import RelativeDate from "../components/RelativeDate.vue";
const release = useReleaseCheck();
const engagement = useEngagementStore();
@@ -43,9 +44,34 @@ const notesVersion = computed(() => release.latestVersion.value ?? release.curre
const body = computed(() => release.releaseBody.value ?? "");
const notesHtml = computed(() => renderReleaseNotes(body.value, { full: true }));
const hasNotes = computed(() => body.value.trim().length > 0);
const fullUrl = computed(() => release.releaseUrl.value ?? `${GITHUB_REPO}/releases`);
/** The button says "All releases", so it goes to all of them. It used to
* prefer this release's own URL, which made the label a lie whenever the
* release check had resolved — the common case. The single release is still
* one click away from the index, and each earlier entry below links to its
* own page directly. */
const allReleasesUrl = `${GITHUB_REPO}/releases`;
/**
* Releases older than the one shown above.
*
* Without these, skipping a version means never seeing its notes: 2.12.0 was
* the current release for about an hour before 2.13.0 replaced it, so anyone
* updating from 2.11.0 lands on 2.13.0 and the largest release in months is
* simply invisible to them. The page already exists to answer "what changed";
* it should not answer only for the single most recent tag.
*
* Collapsed by default — the newest release is the reason someone opened the
* page, and the older ones are there to be found, not to compete with it.
*/
const older = computed(() =>
(release.history.value ?? [])
.filter((r) => r.version !== notesVersion.value)
.map((r) => ({ ...r, html: renderReleaseNotes(r.body, { full: true }) }))
.filter((r) => r.body.trim().length > 0),
);
onMounted(() => {
void release.loadHistory();
// Reaching the page IS reading it, whether or not the notes had loaded —
// otherwise a slow network leaves the dot lit after a deliberate visit.
engagement.markReleaseSeen(release.current);
@@ -68,7 +94,7 @@ onMounted(() => {
</div>
<a
class="wp-btn wp-btn--outline wp-btn--sm wp-whatsnew-hero__link"
:href="fullUrl"
:href="allReleasesUrl"
target="_blank"
rel="noopener"
data-test="whats-new-github"
@@ -90,6 +116,31 @@ onMounted(() => {
</Button>
</div>
</Card>
<!-- Previous releases. Rendered as native <details> so each stays
collapsed, findable, and costs nothing when unopened. -->
<Card v-if="older.length" data-test="whats-new-older">
<h3 class="wp-relnotes__older-head">Earlier releases</h3>
<details
v-for="rel in older"
:key="rel.version"
class="wp-relnotes__older"
:data-test="`whats-new-older-${rel.version}`"
>
<summary class="wp-relnotes__older-summary">
<span class="wp-relnotes__older-version">v{{ rel.version }}</span>
<RelativeDate v-if="rel.publishedAt" :value="rel.publishedAt" />
</summary>
<div class="wp-relnotes" v-html="rel.html" />
<a
v-if="rel.url"
class="wp-relnotes__older-link"
:href="rel.url"
target="_blank"
rel="noopener"
>Release page →</a>
</details>
</Card>
</div>
</template>
@@ -143,4 +194,32 @@ onMounted(() => {
}
.wp-relnotes__none .pi { font-size: 26px; color: var(--wp-text-dim); }
.wp-relnotes__none p { margin: 0; max-width: 46ch; }
.wp-relnotes__older-head {
margin: 0 0 var(--wp-space-3);
font-size: var(--wp-text-sm);
font-weight: 600;
color: var(--wp-text-muted);
}
.wp-relnotes__older { border-top: 1px solid var(--wp-border); }
.wp-relnotes__older:first-of-type { border-top: 0; }
.wp-relnotes__older-summary {
display: flex;
align-items: baseline;
gap: var(--wp-space-3);
padding: var(--wp-space-3) 0;
cursor: pointer;
font-size: var(--wp-text-sm);
color: var(--wp-text-muted);
}
.wp-relnotes__older-summary:hover { color: var(--wp-text); }
.wp-relnotes__older-version { font-weight: 600; color: var(--wp-text); }
.wp-relnotes__older[open] .wp-relnotes__older-summary { color: var(--wp-text); }
.wp-relnotes__older > .wp-relnotes { padding: 0 0 var(--wp-space-4); }
.wp-relnotes__older-link {
display: inline-block;
margin-bottom: var(--wp-space-4);
font-size: var(--wp-text-xs);
color: var(--wp-accent-text);
}
</style>
+146
View File
@@ -0,0 +1,146 @@
"""Migration 017 — checksums, and the guard they enable.
The bug being fixed: ``migrate()`` skips every migration ``<= MAX(version)``,
so an integer was the entire identity of a schema. A database migrated by
another build was either silently skipped past (it is "ahead") or silently
accepted (same number, different shape). Both let code read and then write a
schema it does not actually understand.
"""
import sqlite3
import pytest
from engine.db.connection import get_connection
from engine.db.migrations import (
SchemaAheadError,
_discover,
assert_schema_compatible,
current_version,
head_version,
migrate,
)
def test_checksum_column_exists_after_migrate(tmp_path):
conn = get_connection(tmp_path / "c.db")
migrate(conn)
cols = {row[1] for row in conn.execute("PRAGMA table_info(migrations);").fetchall()}
assert "checksum" in cols
conn.close()
def test_rerunning_migrate_is_a_noop(tmp_path):
"""The guard runs on every migrate(); a healthy DB must sail through it."""
db = tmp_path / "idem.db"
conn = get_connection(db)
migrate(conn)
first = current_version(conn)
migrate(conn)
assert current_version(conn) == first
rows = conn.execute("SELECT COUNT(*) FROM migrations;").fetchone()[0]
assert rows == first
conn.close()
def test_migration_017_records_its_own_checksum(tmp_path):
"""017 adds the column inside its own transaction, so its row can carry one.
Everything before it cannot — the column does not exist yet — which is why
the guard treats NULL as "unknown" rather than "mismatched".
"""
conn = get_connection(tmp_path / "own.db")
migrate(conn)
row = conn.execute("SELECT checksum FROM migrations WHERE version = 17;").fetchone()
assert row is not None
assert isinstance(row[0], str) and len(row[0]) == 64
conn.close()
def test_pre_017_migrations_have_null_checksum(tmp_path):
conn = get_connection(tmp_path / "null.db")
migrate(conn)
nulls = conn.execute(
"SELECT COUNT(*) FROM migrations WHERE version < 17 AND checksum IS NULL;"
).fetchone()[0]
assert nulls == 16
conn.close()
def test_database_ahead_of_this_build_is_refused(tmp_path):
"""A DB migrated by a newer build must not be read as if it were ours."""
db = tmp_path / "ahead.db"
conn = get_connection(db)
migrate(conn)
# A future build applied something this one has never heard of.
with conn:
conn.execute(
"INSERT INTO migrations(version, applied_at) VALUES(?, ?);",
(head_version() + 1, "2026-08-09T00:00:00Z"),
)
with pytest.raises(SchemaAheadError) as exc:
assert_schema_compatible(conn)
assert "newer version" in str(exc.value)
conn.close()
def test_migrate_refuses_an_ahead_database(tmp_path):
"""The guard is inside migrate(), so no caller can forget it."""
conn = get_connection(tmp_path / "ahead2.db")
migrate(conn)
with conn:
conn.execute(
"INSERT INTO migrations(version, applied_at) VALUES(?, ?);",
(head_version() + 5, "2026-08-09T00:00:00Z"),
)
with pytest.raises(SchemaAheadError):
migrate(conn)
conn.close()
def test_same_version_different_shape_is_refused(tmp_path):
"""The case an integer version cannot catch.
A migration is reworked after it has already run somewhere — a pre-release
that got revised before shipping, a hand-edited file, an abandoned branch.
The version still matches, so before checksums nothing compared anything.
"""
conn = get_connection(tmp_path / "diverged.db")
migrate(conn)
with conn:
conn.execute(
"UPDATE migrations SET checksum = ? WHERE version = 17;",
("0" * 64,),
)
with pytest.raises(SchemaAheadError) as exc:
assert_schema_compatible(conn)
assert "017" in str(exc.value)
conn.close()
def test_null_checksums_are_not_treated_as_mismatched(tmp_path):
"""NULL means "we never recorded one", which is not evidence of divergence.
Every database that existed before 017 is in this state for versions 1-16;
treating unknown as bad would lock every existing user out of their library.
"""
conn = get_connection(tmp_path / "unknown.db")
migrate(conn)
with conn:
conn.execute("UPDATE migrations SET checksum = NULL;")
assert_schema_compatible(conn) # must not raise
conn.close()
def test_guard_is_silent_on_a_database_with_no_migrations_table(tmp_path):
"""A fresh file has nothing to be incompatible with."""
conn = sqlite3.connect(tmp_path / "empty.db")
assert_schema_compatible(conn) # must not raise
conn.close()
def test_every_shipped_migration_hashes_distinctly():
"""Guards the guard: identical files would make divergence undetectable."""
from engine.db.migrations import _checksum
sums = [_checksum(path) for _, path in _discover()]
assert len(set(sums)) == len(sums)
+6 -5
View File
@@ -2,7 +2,7 @@
import sqlite3
from engine.db.connection import get_connection
from engine.db.migrations import _discover, current_version, migrate
from engine.db.migrations import _discover, current_version, head_version, migrate
def test_initial_version_is_zero(tmp_path):
@@ -30,9 +30,10 @@ def test_migrate_records_version(tmp_path):
conn = get_connection(tmp_path / "v2.db")
migrate(conn)
# Keep this assertion in sync with the highest-numbered migration in
# ``engine/db/migrations_sql``. (016_wildcard_multi_subcategory_v2.py
# is current head.)
assert current_version(conn) == 16
# ``engine/db/migrations_sql``. (017_migration_checksums.py is current
# head.)
assert current_version(conn) == 17
assert head_version() == 17
conn.close()
@@ -282,7 +283,7 @@ def test_004_is_idempotent(tmp_path):
conn = get_connection(tmp_path / "i.db")
migrate(conn)
migrate(conn) # second call should be a no-op
assert current_version(conn) == 16
assert current_version(conn) == head_version()
conn.close()
+115 -13
View File
@@ -3,6 +3,21 @@ from __future__ import annotations
import pytest
from wp_api.database import _allowed_move_paths
def _two_allowed_locations():
"""Two distinct real DB locations, or skip.
`pending_move` only accepts the plugin's own known database locations, so
a test cannot invent paths under tmp_path any more — that is the point of
the allowlist.
"""
allowed = sorted(str(p) for p in _allowed_move_paths())
if len(allowed) < 2:
pytest.skip("needs at least two resolvable DB locations")
return allowed[0], allowed[1]
async def test_get_info_returns_200_with_expected_shape(wp_client):
resp = await wp_client.get("/wp/api/database/info")
@@ -115,9 +130,8 @@ async def test_put_config_rejects_invalid_preference(wp_client):
assert resp.status == 400
async def test_put_config_sets_pending_move(wp_client, tmp_path):
src = str(tmp_path / "old.db")
dst = str(tmp_path / "new.db")
async def test_put_config_sets_pending_move(wp_client):
src, dst = _two_allowed_locations()
resp = await wp_client.put(
"/wp/api/database/config",
json={"pending_move": {"from": src, "to": dst, "mode": "copy"}},
@@ -135,12 +149,11 @@ async def test_put_config_rejects_relative_paths(wp_client):
assert resp.status == 400
async def test_put_config_rejects_bad_mode(wp_client, tmp_path):
async def test_put_config_rejects_bad_mode(wp_client):
src, dst = _two_allowed_locations()
resp = await wp_client.put(
"/wp/api/database/config",
json={"pending_move": {"from": str(tmp_path / "a.db"),
"to": str(tmp_path / "b.db"),
"mode": "rename"}},
json={"pending_move": {"from": src, "to": dst, "mode": "rename"}},
)
assert resp.status == 400
@@ -174,10 +187,9 @@ async def test_put_config_null_clears_field(wp_client):
assert body["preference"] is None
async def test_put_config_preserves_unmentioned_fields(wp_client, tmp_path):
async def test_put_config_preserves_unmentioned_fields(wp_client):
"""Omitting a field in the body should preserve its existing value."""
src = str(tmp_path / "a.db")
dst = str(tmp_path / "b.db")
src, dst = _two_allowed_locations()
# Set both preference + pending_move.
await wp_client.put(
"/wp/api/database/config",
@@ -193,9 +205,8 @@ async def test_put_config_preserves_unmentioned_fields(wp_client, tmp_path):
assert body["pending_move"] == {"from": src, "to": dst, "mode": "copy"}
async def test_delete_pending_move(wp_client, tmp_path):
src = str(tmp_path / "a.db")
dst = str(tmp_path / "b.db")
async def test_delete_pending_move(wp_client):
src, dst = _two_allowed_locations()
await wp_client.put(
"/wp/api/database/config",
json={"preference": "user",
@@ -213,3 +224,94 @@ async def test_delete_pending_move_when_absent_is_noop(wp_client):
assert resp.status == 200
body = await resp.json()
assert body["pending_move"] is None
class TestPendingMoveContainment:
"""`pending_move` must not be usable as a file-transfer primitive.
`execute_pending_move` runs `shutil.copy2` / `shutil.move` at plugin load,
as whoever runs ComfyUI. Requiring merely an ABSOLUTE path — which is all
validation used to do — meant an unauthenticated PUT could schedule a copy
or move between any two paths on the host:
* read anything, by copying it into `web/`, which this plugin serves
publicly at `/wp/<path>`;
* destroy anything, by moving it elsewhere.
Every ComfyUI route is unauthenticated by default, so "an attacker who can
reach the API" is anyone who can reach the port.
"""
async def test_rejects_arbitrary_source(self, wp_client, tmp_path):
_, dst = _two_allowed_locations()
resp = await wp_client.put(
"/wp/api/database/config",
json={"pending_move": {
"from": str(tmp_path / "secrets.key"), "to": dst, "mode": "copy",
}},
)
assert resp.status == 400
async def test_rejects_arbitrary_destination(self, wp_client, tmp_path):
src, _ = _two_allowed_locations()
resp = await wp_client.put(
"/wp/api/database/config",
json={"pending_move": {
"from": src, "to": str(tmp_path / "anywhere.db"), "mode": "copy",
}},
)
assert resp.status == 400
async def test_rejects_exfiltration_into_the_served_web_dir(self, wp_client):
"""The chain that made this critical rather than merely untidy."""
src, _ = _two_allowed_locations()
from wp_api.spa import WEB_DIR
resp = await wp_client.put(
"/wp/api/database/config",
json={"pending_move": {
"from": src, "to": str((WEB_DIR / "leak.db").resolve()), "mode": "copy",
}},
)
assert resp.status == 400
async def test_rejects_traversal_dressed_as_an_allowed_path(self, wp_client):
src, dst = _two_allowed_locations()
from pathlib import Path
escaped = str(Path(dst).parent / ".." / ".." / "escaped.db")
resp = await wp_client.put(
"/wp/api/database/config",
json={"pending_move": {"from": src, "to": escaped, "mode": "copy"}},
)
assert resp.status == 400
async def test_rejects_a_no_op_move(self, wp_client):
src, _ = _two_allowed_locations()
resp = await wp_client.put(
"/wp/api/database/config",
json={"pending_move": {"from": src, "to": src, "mode": "move"}},
)
assert resp.status == 400
async def test_a_legitimate_relocation_still_works(self, wp_client):
"""The guard must not break the feature it protects."""
src, dst = _two_allowed_locations()
resp = await wp_client.put(
"/wp/api/database/config",
json={"pending_move": {"from": src, "to": dst, "mode": "move"}},
)
assert resp.status == 200
body = await resp.json()
assert body["pending_move"]["from"] == src
assert body["pending_move"]["to"] == dst
async def test_stores_the_resolved_paths_that_were_validated(self, wp_client):
"""What gets executed must be what passed the check, not the raw input."""
from pathlib import Path
src, dst = _two_allowed_locations()
noisy = str(Path(dst).parent / "." / Path(dst).name)
resp = await wp_client.put(
"/wp/api/database/config",
json={"pending_move": {"from": src, "to": noisy, "mode": "copy"}},
)
assert resp.status == 200
assert (await resp.json())["pending_move"]["to"] == dst
+55 -2
View File
@@ -95,12 +95,50 @@ def _build_config_response() -> dict[str, Any]:
}
def _allowed_move_paths() -> set[Path]:
"""The only paths a deferred move may read from or write to.
An allowlist rather than a sanitiser. The DB can legitimately live in
exactly three places, all computed here on the server, under one fixed
filename — so there is no reason to accept a client-supplied path at all,
and every reason not to: `execute_pending_move` runs `shutil.copy2` /
`shutil.move` at plugin load, as whatever user runs ComfyUI.
The live path is included because `WP_DB_PATH` / `COMFYUI_USER_DIR` can
place the database outside the three defaults, and a user who set those
must still be able to move off them.
"""
out: set[Path] = set()
for fn in (user_location_path, global_location_path, root_location_path):
try:
p = fn()
except Exception: # a location that cannot be resolved is simply not offered
continue
if p:
out.add(Path(p).resolve())
try:
out.add(resolve_db_path_with_source()[0].resolve())
except Exception:
pass
return out
def _validate_pending_move(pm: Any) -> tuple[dict[str, str] | None, str | None]:
"""Validate a pending_move payload.
Returns ``(value, None)`` on success where ``value`` is either a
normalized dict or ``None`` (explicit clear). Returns ``(None, err)``
on validation failure."""
on validation failure.
SECURITY: `from` and `to` must each be one of the known database
locations. Requiring merely an ABSOLUTE path — which is all this used to
do — let an unauthenticated `PUT /wp/api/database/config` schedule a copy
or move between any two paths on the host, executed on the next ComfyUI
start. Every ComfyUI route is unauthenticated by default, so that was a
full arbitrary-read and arbitrary-overwrite primitive: copy a private key
into `web/`, which this plugin serves at `/wp/<path>`, or move any file
anywhere and destroy it.
"""
if pm is None:
return None, None
if not isinstance(pm, dict):
@@ -114,7 +152,22 @@ def _validate_pending_move(pm: Any) -> tuple[dict[str, str] | None, str | None]:
return None, f"pending_move.mode must be one of: {sorted(_VALID_MODES)}"
if not Path(src).is_absolute() or not Path(dst).is_absolute():
return None, "pending_move.from and pending_move.to must be absolute paths"
return {"from": src, "to": dst, "mode": mode}, None
allowed = _allowed_move_paths()
# `resolve()` collapses `..` and follows symlinks, so neither traversal nor
# a symlink planted at an allowed path can smuggle a different target past
# the membership test.
src_r = Path(src).resolve()
dst_r = Path(dst).resolve()
if src_r not in allowed or dst_r not in allowed:
return None, (
"pending_move.from and pending_move.to must each be one of this "
"plugin's known database locations"
)
if src_r == dst_r:
return None, "pending_move.from and pending_move.to must differ"
# Store the RESOLVED paths: what was validated is what gets executed.
return {"from": str(src_r), "to": str(dst_r), "mode": mode}, None
async def get_config(request: web.Request) -> web.Response: