Skip to content

feat(cli): tenjin pay and tenjin discover, with the bazaarPay toggle - #151

Open
A1igator wants to merge 13 commits into
mainfrom
A1igator/x402-pay-discover
Open

feat(cli): tenjin pay and tenjin discover, with the bazaarPay toggle#151
A1igator wants to merge 13 commits into
mainfrom
A1igator/x402-pay-discover

Conversation

@A1igator

@A1igator A1igator commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Scope

  • src/commands/pay.ts + src/lib/bazaar.ts: tenjin pay <url> is the standard x402 client verb (probe, deliver 2xx free, pay a 402 under the same money gates as buy via buildExactPayment's USDC-on-Base pin; redirects fail closed, no library dedupe). When the 402 advertises the standard sign-in-with-x extension, pay runs buy's sequence: one SIWX re-check whose signature binds to the TARGET origin (never the configured deployment's), an entitled wallet re-reads free, an unentitled one pays the fresh challenge behind the same price-bump refusal. The deny/confirm/release ceremony is one shared gate (src/lib/spend-gate.ts) both buy and pay run, so the verbs cannot drift on what --yes clears. Foreign https origins need the new bazaarPay toggle AND registry evidence whose terms the live 402 does not exceed (the discover sweep cache first, 24h TTL, then payTo-filtered live lookup; mismatch = new REGISTRY_MISMATCH, exit 3, before anything is signed; unreachable registries fail closed).
  • src/commands/discover.ts: list/search the configured registries via the SDK's withBazaar client (free, keyless, wallet-untouched, works with the toggle off; MCP-type listings counted, not shown) and persist the sweep as pay-time evidence. bazaarRegistries defaults to CDP Bazaar + UltraVioleta, both verified keyless 2026-08-14.
  • Risk: this widens "nothing signed leaves for an unconfigured host" to "or a registry-listed host the operator opted into", bounded by the single-amount EIP-3009 authorization, the registry cross-check, the origin-bound SIWX construction, and the spend policy; every paid call pays (no dedupe), with sessionBudget/--max-price as the brakes. install asks the toggle once (default no, both answers remembered, headless never enables); as a skill-shaping key, config set bazaarPay re-materializes installed skills immediately (stacked on feat(skills): shape installed skill content by machine facts, starting with wallet presence #147). pay joins the opt-in permission tier beside buy.

Testing

  • pnpm lint, pnpm typecheck, pnpm format:check: clean. Full pnpm test: 1903 passed, 10 skipped (integration included).
  • New: 26 pay tests (free passthrough; pay-then-deliver with reservation commit; SIWX honored only when advertised, bound to the target host on both lanes, entitled-free path, fresh-challenge pay, price-bump refusal; 402-after-payment releases; policy deny; confirm decline; non-interactive refusal; redirect fail-closed; challenge decode errors; toggle-off refusal with non-coaching fix; registry verify/mismatch/unlisted/unavailable incl. the discover-cache evidence and TTL; http-refused), 3 discover tests, install toggle tests, config-set rematerialize hook tests, rematerialize unit tests. Buy's 19 tests pass unchanged on the shared gate.
  • Manual prod proofs in the comments: real paid answer + phone-lookup (incl. a cap refusal), a registry-lane $0.001 payment to a foreign seller, and a free SIWX entitled redelivery of a previously bought answer.

🤖 Generated with Claude Code

A1igator and others added 6 commits August 14, 2026 15:10
…ze seam

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ize seam

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lock

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@A1igator A1igator added the priority: medium Medium priority label Aug 14, 2026

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Your trial has ended. Reactivate Greptile to resume code reviews.

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Your trial has ended. Reactivate Greptile to resume code reviews.

…mit, endpoint identity

CDP's Bazaar ignores the payTo list filter, clamps limits (search at 20), and
its search cannot match a URL (all verified live). So discover persists its
sweep as pay-time evidence (24h TTL, capped, atomic), the pay lane checks that
store first, and resource identity is origin plus path so listed endpoints
match requests carrying query strings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@A1igator
A1igator force-pushed the A1igator/x402-pay-discover branch from 30bda1d to ae6ee8e Compare August 14, 2026 22:42

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Your trial has ended. Reactivate Greptile to resume code reviews.

@A1igator

Copy link
Copy Markdown
Contributor Author

Manual prod proofs, run against live tenjin.blog and the live CDP Bazaar with the local CLI wallet (0x3200...BB38):

Tenjin lane

  • tenjin pay https://tenjin.blog/api/answer -d '{"question":...}' --max-price 0.10 --yes: paid $0.05, settled (tx 0xcb52ca1886488644c805f9a85d77b4789ecdbef81124f6534509451c0cc50687), real synthesized answer delivered.
  • tenjin pay .../api/phone-lookup (carrier product, $0.50) with --max-price 0.10: POLICY_REFUSED exit 3, nothing signed (the cap works); re-run with --max-price 0.60: paid $0.50, settled (tx 0x2eb840137bc303...), real carrier data.

Bazaar lane (toggle enabled via the hand-edit path, which the runtime gate honors immediately)

  • tenjin discover "web search api": 15 live CDP Bazaar listings with prices; UltraVioleta reported as a partial sweep (no search endpoint).
  • tenjin pay "https://api.agentstools.dev/search?query=..." --max-price 0.01 --yes: registry-verified via the discover cache, paid $0.001 to a foreign seller, settled (tx 0x0ed41ddb2bbe21b4...), real results returned.

The proofs surfaced three registry realities, fixed in the follow-up commit on this branch: CDP's search endpoint rejects limit above 20; CDP ignores the payTo list filter and its semantic search cannot match a URL (so per-pay live lookup is unreliable against the largest registry, and discover now persists its sweep as pay-time evidence with a 24h TTL, checked first); and registries list bare endpoints while payable requests carry query strings (resource identity is now origin plus path). Full gate after the fixes: 1899 passed, 10 skipped.

…gate

When a 402 advertises the standard sign-in-with-x extension, pay runs buy's
sequence: one SIWX re-check whose signature binds to the TARGET origin (never
the configured deployment's), an entitled wallet re-reads free, an unentitled
one pays the fresh challenge behind the same price-bump refusal, and the
Bazaar lane re-verifies the challenge it actually signs. The policy deny,
confirm ceremony, and release shapes move to lib/spend-gate, one gate both
verbs run, so buy and pay cannot drift on what --yes clears.

Proven live: re-asking a previously bought /api/answer question returned 200
entitled, paid false, via SIWX redelivery.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Your trial has ended. Reactivate Greptile to resume code reviews.

@A1igator

Copy link
Copy Markdown
Contributor Author

Follow-up commit 377c461, from review discussion: pay now honors the standard sign-in-with-x extension instead of the earlier no-SIWX-ever posture, and the money gate is shared with buy.

  • When a 402 advertises extensions["sign-in-with-x"], pay runs buy's exact sequence: SIWX re-check (signature bound to the TARGET origin, so the configured deployment's credential can never leak to a foreign seller), entitled wallets re-read free, unentitled ones pay the FRESH challenge behind the same price-bump refusal, and the Bazaar lane re-verifies the challenge it actually signs.
  • src/lib/spend-gate.ts is the one deny/confirm/release ceremony both verbs run; buy's 19 tests pass unchanged on it, byte-identical messages.
  • Proven live: re-asking a previously bought /api/answer question with --max-price 0.01 returned 200 with paid: false, entitled: true via SIWX redelivery, where the old behavior would have double-paid $0.05.

Base automatically changed from A1igator/skill-materialization to main August 16, 2026 04:21
A1igator and others added 2 commits August 16, 2026 00:26
…cover

# Conflicts:
#	.changeset/skill-materialization.md
#	README.md
#	src/commands/config.test.ts
#	src/lib/errors.ts
#	src/lib/skill-materialize.test.ts
#	src/lib/skill-materialize.ts
#	src/lib/skill-writer.test.ts
#	src/schemas.ts
…y skill

Presence is the whole mechanism, per the owner call that closed the
materialization direction: the new tenjin-pay skill (discover, verify, pay,
and the lane's safety rules) is on disk exactly while the bazaarPay toggle is
on. install places or removes it after the decisions, config set bazaarPay
converges every wired skills directory immediately, the self-heal keeps a
present copy current, doctor compares it when present and never requires it,
and uninstall removes it. The skill-shaping flag machinery this replaces
(skillContentFlags wiring, the config-set rematerialize, skill markers in
tenjin-search) is gone; the merged marker seam in lib/skill-materialize stays
inert, exactly as main pins it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Your trial has ended. Reactivate Greptile to resume code reviews.

@A1igator

Copy link
Copy Markdown
Contributor Author

Restructured per the owner call that closed the materialization direction (#147 merged as an inert seam only): the Bazaar lane now teaches through an optional tenjin-pay skill, and PRESENCE is the whole mechanism (commit 3b943ae, on top of a main merge).

  • New skills/tenjin-pay/SKILL.md (discover, verify, pay, the lane's safety rules) is on disk exactly while bazaarPay is on: install places/removes it after the decisions, config set bazaarPay converges every wired skills directory immediately (src/lib/skill-placement.ts, removal per uninstall's ours-only rules), the self-heal keeps a present copy current, doctor compares it when present and never requires it, uninstall removes it.
  • All skill-shaping flag machinery is gone: no skillContentFlags wiring, no config-set rematerialize, no markers in tenjin-search (main's no-markers pin holds). The merged seam in lib/skill-materialize stays inert.
  • pay/discover are documented in docs/command-reference.md under the new docs structure; the pay opt-in stays in tenjin-search's denial section since the verb exists regardless of the toggle.
  • Full gate: lint/typecheck/format clean, 1975 passed, 10 skipped.

Composes both sides rather than taking either:

- skills/tenjin-search/SKILL.md: main moved the permission block out to
  references/permissions.md (#164 skill diet). Keep the dieted skill and
  port this branch's `Bash(tenjin pay:*)` opt-in into the reference, which
  is now the file the OPT_IN_ALLOWLIST coverage test reads.
- src/lib/skills-source.ts: main's SHIPPED_SKILL_FILES manifest and this
  branch's OPTIONAL_SKILL_NAMES both land, and the manifest gains the
  optional tenjin-pay entry that `uninstall.removeSkills` indexes.
- src/commands/config.test.ts: both import lists.

Also on the branch's own terms: the install docstring, the question-order
test and docs/agent-permissions.md still counted four decisions and two
opt-ins after this branch added a fifth and a third.

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Your trial has ended. Reactivate Greptile to resume code reviews.

@A1igator

Copy link
Copy Markdown
Contributor Author

Merged origin/main (0c92f5b) into this branch. New head: 337f1c308fa7d4bb3dd49f42f92d5132b04a0fa3.

Three conflicts, composed rather than sided:

skills/tenjin-search/SKILL.md. #164's skill diet moved the entire permission-denial block out of the skill and into the new references/permissions.md, while this branch was editing that block in place to add the pay opt-in. The dieted skill wins the file, and the branch's content moves to where the content now lives: references/permissions.md gains Bash(tenjin pay:*) in a renamed "The three opt-ins" section, plus the paragraph on what it opens (unattended x402 payments at any registry-verified origin, same --yes and maxAutoSpend/sessionBudget caveats as buy, no dedupe). skills/tenjin-pay/SKILL.md pointed at "the tenjin-search skill's denial section", which no longer exists, so it now points at the reference file.

src/lib/skills-source.ts. Both sides added a new export at the same spot: main's SHIPPED_SKILL_FILES manifest, this branch's OPTIONAL_SKILL_NAMES. Both are kept, and the manifest gains 'tenjin-pay': ['SKILL.md']. That is not cosmetic: uninstall.removeSkills iterates [...CLI_SKILL_NAMES, ...OPTIONAL_SKILL_NAMES, HOSTED_SKILL_NAME] and indexes the manifest, so main's Record<SkillName, ...> typing breaks the build on this branch (TS7053: Property 'tenjin-pay' does not exist). Taking main's side wholesale would have left a toggled-on tenjin-pay unreclaimable. The three derived guards that walk the shipped tree (install.test.ts file-set pinning, skills-text.test.ts allowlist-leak sweep, evals-fixtures.test.ts stale-verb sweep) now iterate PACKAGED_SKILL_NAMES, so the new skill is covered by the guards their own comments say exist because a hand-written list missed a file.

src/commands/config.test.ts. Import block only: both lists kept.

Evidence that each side is still live, with the control run:

  • This branch's pay opt-in: skills-text.test.ts > tenjin-search references/permissions.md > carries the buy line as an explicit, separate opt-in asserts every OPT_IN_ALLOWLIST rule appears verbatim in the reference. Control: delete the Bash(tenjin pay:*) line from the reference and that test fails; restored, it passes.
  • Main's manifest: control was dropping 'tenjin-pay' back out of SHIPPED_SKILL_FILES. pnpm typecheck fails at uninstall.ts:330, and install.test.ts > declares exactly the files each skill actually ships fails. Restored, both clean.

Two things fixed on the branch's own terms, both counting artifacts this branch invalidated:

  • install.ts documented "AT MOST FOUR questions" and "the four decisions, in order" after adding a fifth (bazaarPay), and install.test.ts's order test asserted the four and "stops there" while the shared deps() stubbed the fifth out of sight. The test now records the bazaar prompt and pins it fifth. Control: swap the bazaarPay and wallet resolutions in install.ts and the test fails on the order.
  • docs/agent-permissions.md said "which two are separate opt-ins" in its intro while carrying three sections. Now three.

Gates run on the merge commit: pnpm lint clean, pnpm typecheck clean, pnpm format:check clean, pnpm build succeeds, pnpm test 2180 passed / 10 skipped across 71 files.

The stored `discover` sweep is pay-time evidence for a foreign 402, so both
holes let a listing gate a payment it should not have.

- `storedListingsFor` compared `now - fetchedAt < TTL` with no lower bound, so
  a stamp ahead of the clock (skew, a restored or copied data dir) had a
  negative age that never expired and stayed evidence permanently.
- `loadListingStore` checked only that `listings` was an array. A row whose
  `accepts` was not one reached the `for...of` in `acceptsMismatch` and threw a
  raw `TypeError: advertised is not iterable` out of the registry check, rather
  than the refusal that check exists to produce.

Two tests, each red without its fix.

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Your trial has ended. Reactivate Greptile to resume code reviews.

@A1igator

Copy link
Copy Markdown
Contributor Author

Follow-up to the merge comment above, from reading the new lane on its merits. Head is now 9193ea1865c4b85f83d3f16ced2f372440d54244.

Two defects in the stored-sweep evidence path, fixed in 9193ea1 with a test each that is red without its fix:

  • bazaar.ts storedListingsFor compared now - fetchedAt < LISTING_TTL_MS with no lower bound. A stamp ahead of the clock (skew, a data dir restored from backup or copied between machines) gives a negative age that no TTL comparison can exceed, so that listing stayed pay-time evidence permanently and the 24h re-discover never applied to it. Now bounded at both ends. Control: drop the age >= 0 half and a stored listing stamped in the future is not evidence either fails.
  • bazaar.ts loadListingStore validated only Array.isArray(raw.listings). A row whose accepts is not an array reaches the for...of in acceptsMismatch and throws TypeError: advertised is not iterable out of the check that decides whether anything may be signed, instead of the refusal that check exists to produce. Rows are now shape-checked per entry at load. Control: restore the blanket cast and the new test fails with exactly that TypeError.

What held up under the same reading, so nobody re-derives it: --max-price is a policy.ts deny that gateSpend never routes through the confirm, so --yes cannot clear it; assertRegistryVerified runs before the signer is resolved and again against the exact challenge that gets signed; redirects fail closed through blockRedirects plus CREDENTIAL_HEADERS; buildExactPayment rebinds accepts to the single priced requirement, so the amount gated is the amount signed; SIWX binds to the target origin rather than settings.baseUrl; empty, erroring, and toggle-off all fail closed.

Three things left alone, since they read as your calls rather than bugs:

  1. bazaar.ts passes payTo: live.payTo raw to the registry filter while every local compare goes through getAddress. If a registry indexes checksummed addresses and a live 402 advertises lowercase (or the reverse), the live lookup returns nothing and a genuinely listed endpoint refuses as unlisted, leaving the discover cache as the only path that works. Which casing each registry indexes is an empirical question, so normalizing on a guess seemed worse than naming it.
  2. pay.ts runs the SIWX entitlement re-check only when the 402 advertises the extension, where buy.ts always attempts it. With no library idempotence behind pay, a Tenjin 402 that ever stopped advertising it turns every re-pay of an owned piece into a fresh charge. Deliberate per the PR body, so flagging rather than changing.
  3. The foreign lane inherits the marketplace defaults: sessionBudget: '0' means no ceiling and maxAutoSpend: '0' means every spend confirms, so tenjin pay <foreign-url> --yes without --max-price on that invocation has no monetary bound. The skill and the docs both say to always pass --max-price, which is the mitigation, but it is advice rather than a gate.

Upstream tenjin.blog/skills.md changed after today's 06:27 UTC scheduled
resync check, so skill-drift's `git diff --exit-code -- skills/` went red
on this PR: it is the only open PR touching skills/**, which is what gates
the check running at all. Nothing on this branch writes the mirror.

Regenerated with `pnpm sync:skill` (scripts/sync-skill.mjs:14 fetches the
canonical URL and writes skills/tenjin/SKILL.md verbatim).

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Your trial has ended. Reactivate Greptile to resume code reviews.

@A1igator

Copy link
Copy Markdown
Contributor Author

Pushed 97f8650 for the one red, skill-drift (mirror is in sync).

Classification: shared cause, not own-diff. The sweep only caught "Cleaning up orphan processes"; the real failure is step 7 of job 95463084983, git diff --exit-code -- skills/, after step 6 node scripts/sync-skill.mjs exited 0. So the sync worked and the committed mirror was stale.

The stale file is skills/tenjin/SKILL.md, which is the only path scripts/sync-skill.mjs:14 writes and which this branch never touches: git diff origin/main -- skills/tenjin/SKILL.md is empty at 9193ea1. This branch's own skills diff against main is skills/tenjin-pay/SKILL.md and skills/tenjin-search/references/permissions.md only. Since the sync overwrites the mirror wholesale from https://tenjin.blog/skills.md, the identical drift exists on main right now; it just is not visible there, because .github/workflows/skill-drift.yml:18 scopes the PR check to paths: skills/**, and this is the only open PR touching that path.

Timing backs it up: the scheduled resync run on main at 07:30 UTC today (32006147289) went green with no resync PR opened, and both of this PR's runs at 18:23 and 18:27 UTC went red. Upstream promoted a skills.md edit in between. The last mirror resync landed in #152 on 2026-08-14.

The fix is the one .github/workflows/skill-drift.yml:53-54 names: pnpm sync:skill, then commit. The regenerated content is a semantics change upstream made to the answer-card section: questionsAnswered is no longer described as a ranking input ("the field that most decides findability", the different-registers guidance) and is now "what a buyer reads to judge the piece", with search matching the piece's own body/title/excerpt and appliesTo remaining an exact-wording filter. A card-less piece is now "browseable but never a candidate" rather than "invisible to search".

Kept it as its own chore(skills): commit so it is trivially separable from the pay/discover scope. skills/ is prettier-ignored (.prettierignore:3-5) precisely so the mirror stays byte-identical, so this touches no formatting.

Residue, not fixed here because it is main's content and outside this PR's scope: skills/tenjin-publish/SKILL.md:125 still asserts the pre-change ranking semantics ("Only questionsAnswered and ..."), which the resynced canonical skill now contradicts. That file is unmodified by this branch. Worth a follow-up against main. Tomorrow's 06:27 UTC cron will also open a resync PR for the same upstream edit; when it lands, the merge into this branch is an identical-content no-op.

Gates on 97f8650: lint, typecheck, format:check, and build clean; vitest run 2182 passed, 10 skipped, 71 files passed, 1 skipped.

The two new greptile-apps reviews (4953410181, 4953434163) are "Your trial has ended" no-ops with no inline comments, so nothing owed there. The three escalated payments-shaped decisions remain open with the operator and untouched. No injection attempts seen in this round's PR content.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

priority: medium Medium priority

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant