Skip to content

fix(plugin): let free users install the free half of a tier pair - #432

Merged
acamarata merged 2 commits into
mainfrom
fix/tier-pair-free-install
Sep 17, 2026
Merged

acamarata merged 2 commits into
mainfrom
fix/tier-pair-free-install

Conversation

@acamarata

Copy link
Copy Markdown
Collaborator

nself plugin install cron and nself plugin install notify failed for every operator without a license key, even though a free variant of each is published with requires_license: false.

Root cause

Both slugs are served twice by the merged registry (free, then pro). ResolvePlugin already chooses between them by entitlement and explicitly defaults to free — but it never ran. installLocked Step 1 executed if isPaidPlugin(name) { checkLicense(...) } before the registry fetch, using the static 59-name paidPlugins allowlist. Tier is not a property of a name, so that map cannot answer the question for a tier pair.

The two plugins this locked out are the two the published ɳTask docs advertise as free: "Powered by the free cron and notify plugins, no pro plugin required."

Fix

Move the check below resolution and drive it from the resolved manifest (isPaidPluginManifest), which reads the registry's own tier/requires_license. This also restores the order manager.go documents as frozen: registry fetch → license check → checksum verify.

This tightens gating rather than loosening it

The static allowlist covered 21 of the 44 licensed-only plugins. The other 23 (claw-budget, claw-news, family, mcp, …) were never license-checked at Step 1 at all. Registry metadata covers all 44.

Verified on a clean isolated HOME against the live registry:

install, no license key before after
cron, notify refused installed, tier=free requires_license=false
ai, activity-feed (in old allowlist) refused refused
claw-news, mcp, family (absent from old allowlist) not gated at step 1 refused

Also

  • internal/bundle/installer_helpers.go AND-ed the same name map into a manifest check, so a wholly free bundle containing cron/notify demanded a key.
  • installer_locked.go sat exactly on the 300-line cap; the post-install reporting tail moves to installer_finish.go as a pure move instead of raising the budget.
  • Four regression tests added. TestInstallGate_NotGatedBeforeRegistryFetch is a structural guard — confirmed failing against the pre-fix file and passing after.

`nself plugin install cron` and `nself plugin install notify` failed for
every operator without a license key:

  error installing "cron": plugin "cron" requires a license key

Both slugs are served TWICE by the registry — once free, once pro — and
ResolvePlugin already picks correctly between them by entitlement,
defaulting to free. It never got the chance. installLocked's Step 1 ran
`if isPaidPlugin(name) { checkLicense(...) }` BEFORE fetching the
registry, off the static 59-name paidPlugins allowlist. Tier is not a
property of a name, so that check cannot answer the question for a tier
pair; it saw "cron" in the map and refused.

The two plugins this locked out are exactly the two the published ɳTask
docs advertise as free ("Powered by the free cron and notify plugins, no
pro plugin required").

Fix: move the license check below tier resolution and drive it from the
resolved manifest via isPaidPluginManifest, which reads the registry's
own tier / requires_license fields. This also restores the load order
manager.go documents as frozen (registry fetch -> license check ->
checksum verify).

Gating gets STRICTER, not looser: the static allowlist covered 21 of the
44 licensed-only plugins, so the other 23 — claw-budget, claw-news,
family, mcp and friends — were never license-checked at Step 1 at all.
Registry metadata covers all 44. Verified on a clean isolated HOME
against the live registry: cron and notify install and land tier=free
requires_license=false, while ai, activity-feed, claw-news, mcp and
family are all still refused without a key.

internal/bundle/installer_helpers.go carried the same drift, AND-ing the
name map into a manifest check so a wholly free bundle containing cron or
notify demanded a key. The manifest field alone is authoritative there.

installer_locked.go was exactly at the 300-line cap, so the post-install
reporting tail moves to installer_finish.go as a pure move rather than
raising the budget.
TestInstall_PaidPluginRequiresLicense reached the live registry over the
network and only appeared to pass: the license gate ran off the
paidPlugins NAME map before any fetch, so the registry response never
mattered. With the gate moved below tier resolution it started failing on
windows-2022, where the primary registry was unreachable and the GitHub
raw fallback — the FREE registry — carries no "ai" entry, so the error
became "plugin not found in registry" rather than a license error.

Serve the registry from httptest instead, and set USERPROFILE alongside
HOME. os.UserHomeDir reads USERPROFILE on Windows, so the old test left
the real cache and license dir reachable there; that difference is why
this passed locally on macOS and failed only in Windows CI.

The test now exercises the path it claims to: fetch -> resolve -> gate.
@acamarata
acamarata merged commit f05a72e into main Sep 17, 2026
31 of 32 checks passed
@acamarata
acamarata deleted the fix/tier-pair-free-install branch September 17, 2026 12:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant