scaffold+broker: fix provisioned-app balance 403, cooldown-gated reads, path drift (review fixes for #73)#92
Open
TeoSlayer wants to merge 3 commits into
Open
scaffold+broker: fix provisioned-app balance 403, cooldown-gated reads, path drift (review fixes for #73)#92TeoSlayer wants to merge 3 commits into
TeoSlayer wants to merge 3 commits into
Conversation
Managed (credit-metered) apps could meter a per-user $-budget and surface it on the X-Pilot-Credits-Remaining header, but a keyless adapter only returns the response body — so an agent could never actually read its balance, and there was no first-class way to check funds before a spend op. This wires a dedicated balance method, end to end: - Broker answers a canonical /_pilot/balance for any credit app from its own per-caller ledger — no upstream call, no master key, no debit, never a 402, scoped to the caller (the pooled account balance is never disclosed). Reuses the existing serveCreditBalance (which also backs the optional creditBalancePath partner-endpoint shadow); the canonical path is always available alongside it. - Scaffolder auto-injects `<ns>.balance` into every `auth: managed` app (no submission field), a GET to that path — flowing through registration, the manifest `exposes` list, and `<ns>.help` like any authored method. - scaffold.BalanceMetaPath == broker.pilotBalancePath keeps the two ends in sync. - Tests: broker (canonical path seeds, reflects spend, free, never forwards) and scaffold (injected for managed only, generated + compiles + in the manifest). - Docs: MANAGED-KEY.md and the publishing playbook document the pattern for all broker apps that meter a budget.
…s, path drift Fixes from PR #73 review: 1. Scaffold's `if c.Managed()` injection guard fired for BOTH auth:managed and auth:provisioned (Managed() is true for both), but the broker only ever answers the canonical /_pilot/balance route for classic managed apps — provisioned apps route through serveProvisioned, which only recognizes ProvisionSpec.BalancePath (default /_balance) and 403s on anything else. Provisioned apps that don't hand-author their own `.balance` method (unlike io.pilot.smol) shipped one that always 403s. Gated the injection to `c.Managed() && !c.Provisioned()`. 2. serveCreditBalance seeded via seedCaller -> ProvisionStore.Provision on every hit, including repeats, and MemStore.Provision's mint_cooldown_ms check fires on every repeat touch — so a second `.balance` call inside the cooldown window 429'd with "re-provision cooldown" instead of returning the balance. Now an already-seeded caller is answered from ProvisionStore.Get (no cooldown check, no LastMint touch); only a genuine first-ever touch goes through the seed path, which can never itself hit the cooldown. Since the balance routes have no Store.Admit/Quota gate, added a light, generous per-(app,caller) token bucket (allowBalanceRead, burst 20 / refill 5/s) so the now-cooldown- exempt route isn't literally unbounded, applied to both serveCreditBalance and the provisioned serveBalance. 3. Extracted the duplicated "/_pilot/balance" literal (scaffold.BalanceMetaPath + broker.pilotBalancePath) into one shared constant, internal/pilotpath.Balance, so the two can no longer drift. 4. Added a cross-caller isolation test: two distinct signed identities against the same app each see only their own balance. Balance routes remain pure reads: no debit, no forward to upstream, no master-key path touched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements the review fixes requested on #73 ("scaffold+broker: dedicated
<ns>.balancemethod for managed budget apps"). #73's head branch lives on a fork, so per review instructions this is a fresh branch/PR offmain(rebased) rather than a push to the fork.Closes/supersedes the balance-method work in #73 — same feature, plus the four fixes below.
Fixes
1. Functional bug — provisioned apps shipped a broken always-403
.balancemethod.Scaffold's injection guard
if c.Managed()(internal/scaffold/config.go) is true for bothauth:"managed"andauth:"provisioned", but the broker only ever serves the canonical/_pilot/balanceroute for classic managed apps — provisioned apps route throughserveProvisioned(internal/broker/identity.go/provision.go), which recognizes onlyProvisionSpec.BalancePath(default/_balance) and 403s on anything else. A provisioned app that doesn't hand-author its own.balance(unlikeio.pilot.smol, which does) got one auto-injected that always 403s.Fix: gated the injection to
c.Managed() && !c.Provisioned()— scaffold has no visibility into a provisioned app's actual (registry-only)BalancePath, so a provisioned app must author its own.balancepointed at the real path, same asio.pilot.smol'ssmol.balance -> /_balance.Test:
TestBalanceMethod_NotInjectedForProvisioned(internal/scaffold/zz_balance_method_test.go) — asserts a provisioned spec does NOT get an auto-injected<ns>.balance.2. Rate-limit / cooldown interaction.
serveCreditBalanceseeded viaseedCaller -> ProvisionStore.Provisionon every hit, including repeats, andMemStore.Provision'smint_cooldown_mscheck fires on every repeat touch — so a second.balancecall inside the cooldown window 429'd with"re-provision cooldown"instead of returning the balance.Fix: an already-seeded caller is now answered straight from
ProvisionStore.Get(no cooldown check, noLastMinttouch, no re-seed); only a caller's genuine first-ever touch goes through the seed path (still enforcing the per-IP grant cap), and a first touch can never itself hit the cooldown. Since the balance routes have noStore.Admit/Quota gate at all, added a light, generous per-(app,caller)token bucket (allowBalanceRead, burst 20 / refill 5/s) so the now-cooldown-exempt route isn't literally unbounded — applied to bothserveCreditBalance(classic managed) andserveBalance(provisioned), which had the same "served before any Admit/Quota gate" shape.Tests:
TestBalanceMeta_ReadIgnoresMintCooldown(repeat.balancecalls return the balance, not 429, withmint_cooldown_ms: 60000; a real metered call once time actually elapses is still correctly cooldown-gated) andTestBalanceRateLimit_BurstThenSheds(burst always succeeds, a tight loop past it sheds, and it recovers after a refill interval).3. Duplicated route literal.
Extracted
scaffold.BalanceMetaPath+broker.pilotBalancePath("/_pilot/balance") into one shared constant,internal/pilotpath.Balance(new package, since scaffold and broker don't otherwise import each other). Both constants now derive from it.Test:
TestBalancePath_SharedConstant_NoDrift— assertsbroker.pilotBalancePath == pilotpath.Balance, so a future hardcoded-literal regression is caught immediately.4. Cross-caller isolation.
TestBalanceMeta_CrossCallerIsolation— two distinct signed identities against the same credit app; each.balancecall returns only the calling identity's own numbers (seed/spend on one caller never leaks into or is affected by the other's reads).Verification
main(git merge origin/main) — merged cleanly, no conflicts, no lockfile drift (go.mod/go.sumunchanged,go mod tidyis a no-op).go build ./...clean.go vet ./...clean.go test ./...fully green (all packages).gofmt -l .clean.Safety
The balance routes remain pure, caller-scoped reads: no
Debit/Refund/Settlecall was added anywhere on this path, nothing touches the master key, and no upstream/partner forward was introduced.serveCreditBalancereads viaProvisionStore.Get/Creditonly.🤖 Generated with Claude Code