Skip to content

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

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

feat(cli): tenjin pay and tenjin discover, with the bazaarPay toggle#151
A1igator wants to merge 17 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) + PayAI's facilitator (verified keyless 2026-08-18 with the sweep's own client: 200, Bazaar envelope, 26,478 listings; no /discovery/search like UV, ignores payTo filters like CDP).
  • 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.

@A1igator

Copy link
Copy Markdown
Contributor Author

Merge of origin/main conflicts in src/commands/config.test.ts (humanLines count, 14 vs 15) intersecting this PR's payments-adjacent bazaarPay/bazaarRegistries config keys — carve-out hit, not resolved/pushed; needs manual merge.

@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

Update: orchestrator-authorized the config.test.ts humanLines hunk as mechanical (test-scaffolding key count, not a payments code path). Re-ran the merge; that was the only conflict. Resolved by keeping both key sets (bazaarPay/bazaarRegistries + hooks.sessionPrimer) and updating the count assertion to 16. Config tests, lint, and typecheck pass. Merged and pushed at 2793125.

@vraspar vraspar left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: strong money-path discipline; one budget-accounting hole in the Bazaar lane, plus doc/coaching drift

Reviewed against docs/CONVENTIONS.md, the adversarial money model, and the PR body's claims, verified at 2793125.

What's solid:

  • The spend-gate extraction is genuinely drift-proof: gateSpend is the one deny/confirm/release ceremony, buy's call site is behavior-identical to what it replaced (byte-identical messages), and --yes reaches only the confirm branch, never a deny or cap.
  • The security tests assert the real thing: SIWX headers are parsed with the SDK and the domain/uri binding is asserted on both lanes; the registry-evidence tests write real stores and pin TTL at both ends (including the future-stamp case) plus malformed-row dropping, each red without its fix.
  • buildExactPayment's pin holds under an adversarial read: non-Base networks and non-canonical-USDC assets refuse with a checksummed compare, and the single-accept rebind means exactly the requirement the price check ran against is signed.
  • The optional-skill presence mechanism is conservative: placement only into already-consented directories, removal only of a frontmatter-verified own file, uninstall reclaims tenjin-pay regardless of the toggle.

Major

  1. security: releasing the reservation after the payment signature has left defeats sessionBudget against a hostile Bazaar seller: once buildExactPayment's signed header is transmitted, the counterparty irrevocably holds a bearer EIP-3009 authorization (x402-pay.ts says so itself), but a non-2xx paid response or a network failure releases the session-budget reservation, so the ledger stops counting money the seller can still settle. The error's fix text ("Check the wallet balance and network, then retry",
    fix: 'Check the wallet balance and network, then retry.',
    ) then coaches the exact loop that compounds it: a malicious registry-listed seller answers 402/500 after receiving each signature, the agent retries under --yes, and every round signs a fresh authorization while sessionBudget counts zero of them; worst-case exposure is N x price with no brake but --max-price per call. For buy against the configured deployment this release-on-failure shape is fine (trusted counterparty); the Bazaar lane is precisely the case where it is not. Fix: on the pay path, once the signed header has been transmitted, commit the reservation (or record it as spent-pending) instead of releasing on an ambiguous outcome, and soften the paid-leg retry coaching; releasing can stay correct for failures before transmission.
    try {
    const payment = await buildExactPayment(effectiveChallenge, signer);
    const paid = await httpRequest(url, { ...fetchOpts, headers: payment.headers });
    if (!paid.ok) throw fetchFailureToCliError(paid);
    if (paid.status >= 200 && paid.status < 300) {
    await authorizer.commit(reservationId, payment.amountAtomic);
    return deliver(url, lane, paid, {
    paid: true,
    amountAtomic: payment.amountAtomic,
    requirement: effectiveRequirement,
    ...(registry !== undefined ? { registry } : {}),
    printBody: args.printBody === true,
    });
    }
    // Still 402 (payment rejected) or anything else: nothing was delivered.
    // The reservation is released in the catch below; settlement state on a
    // non-402 non-2xx is honestly unknown and the message says so.
    throw new CliError(
    'PAYMENT_FAILED',
    paid.status === 402
    ? 'Payment was not accepted by the endpoint.'
    : `The endpoint answered ${paid.status} on the paid request; whether it settled is unknown.`,
    {
    fix: 'Check the wallet balance and network, then retry.',
    details: { status: paid.status, body: paid.json },
    },
    );
    } catch (err) {
    await authorizer.release(reservationId);
    throw err;
    }

Minor

  1. agent-usability: the coached enable command fails: both refusal texts say tenjin config set bazaarPay on (

    fix: 'An operator enables paying registry-listed non-Tenjin endpoints with `tenjin config set bazaarPay on`.',
    ,
    'bazaarPay is off: these endpoints are visible but not payable. An operator enables the lane with `tenjin config set bazaarPay on`.\n',
    ), but parseBoolean accepts only true/false, so the CLI's own remediation exits USAGE. Fix: accept on/off for boolean keys (the hooks keys already speak on/off) or coach bazaarPay true.

  2. hygiene/backend: three comments delegate placement-drift reporting to a doctor check that does not exist: config.ts (

    // `tenjin doctor` reports a presence that does not match the toggle.
    ), install.ts (
    // The skills check in the embedded doctor run reports what remains.
    ), and skill-placement.ts (
    * failures degrade per directory and doctor reports what remains.
    ) all say tenjin doctor reports a presence that does not match the toggle, but doctor never reads bazaarPay: compareWiredSkills covers content staleness of a present copy only and "never requires" the optional skill, so a failed removal (toggle off, skill still teaching the lane) and a failed placement (toggle on, no skill) are both invisible. The runtime gate in resolveLane keeps the lane itself safe either way. Fix: add a presence-vs-toggle line to doctor's skills check, or reword the three comments to say the drift is silent.

  3. testing: the SIWX-lane fresh-challenge re-verification has no test that reddens on revert: delete

    if (lane === 'bazaar') {
    registry = await assertRegistryVerified(settings, url, fresh, ctx.flags.timeout, ctx);
    }
    and the suite stays green. The case it alone refuses: a Bazaar seller whose first 402 matches its listing but whose post-SIWX fresh 402 swaps payTo or asset at the same price (the price-bump check passes, only the re-verify catches it). Add that test.

Nits (2), none blocking
  • src/commands/pay.ts L404: opts.amountAtomic! in deliver hides a real invariant behind a non-null assertion; a discriminated DeliverOpts (paid variant carries the amount) types it away.
  • Exit-code taxonomy: the tenjin-pay skill's table says exit 3 covers "a registry refusal", but unlisted and toggle-off refuse with USAGE (exit 2) (
    case 'unlisted':
    throw new CliError('USAGE', 'No configured registry is known to list this resource.', {
    fix: 'The Bazaar lane pays publicly listed deals only, and pay-time lookup leans on the local `discover` cache: run `tenjin discover [query]` so a sweep can surface this endpoint, then re-run pay.',
    });
    ). An agent parsing exit 2 as "my invocation was malformed" will re-shape the command rather than run discover. Either classify unlisted as the exit-3 refusal class or note the split in the skill.
Verified, not issues
  • USDC-on-Base pin: buildExactPayment refuses foreign chains and non-canonical assets (checksummed), and signs only the single accepts[0] the gate priced.
  • SIWX confused-deputy: buildSiwxHeader binds domain (host-with-port) and uri to the target origin; the foreign-seller test parses the real header and asserts the binding; the configured deployment's credential cannot leak, and a foreign seller's signature is worthless elsewhere. No SIWX is sent when the 402 does not advertise it (tested).
  • --yes scope: clears the confirm only; deny and --max-price route through gateSpend's deny branch before the confirm is ever reached, identically for both verbs.
  • Registry TOCTOU: a price raise or payTo rotation after the sweep lands in acceptsMismatch and refuses; a malicious cross-listing of a victim URL cannot force payment (the victim's live 402 will not match it) and cannot veto a valid listing (first matching listing verifies).
  • Redirects fail closed on the probe, the re-check, and the paid leg (tested on the probe; one shared blockRedirects opts object feeds all three).
  • Fail-closed lattice: registry check runs before the wallet opens; all-registries-unreachable is NETWORK_ERROR, wallet untouched (tested); http foreign origins refuse.
  • Install: headless never enables and persists nothing; both prompted answers remembered; question order pinned by test; the confirm prompt renders the sanitized payee before the price so escapes cannot repaint the amount.
  • Local run at head: full pnpm test 2233 passed / 10 skipped with HOME isolated; the 2 doctor.test.ts failures on a raw run are this machine's real ~/.claude install leaking into the doctor sandbox (they pass with a clean HOME and are pre-existing tests untouched by this PR). CI green at 2793125 including pack-smoke.
Closed as decisions
  • No dedupe on pay (every paid call pays): deliberate, stated in the PR body, skill, docs, and permission note; the brakes are named. Not re-raised.
  • Skill-presence-as-mechanism (no conditional content, no markers): owner call that closed the materialization direction; this PR implements it.
  • The stored-sweep-as-evidence design (CDP ignores payTo filters, search cannot match URLs): constraint verified live by the author; TTL and shape hardening landed in 9193ea1 with red-without-fix tests.

Verdict: comments-only. The one Major is a real adversarial-money gap in the lane this PR introduces (budget accounting stops at the delivery boundary while the signature has already left); the Minors are a failing coached command, comments claiming a diagnostic that does not exist, and one untested security gate. Ran locally at head: lint-clean full suite, 2233 passed with HOME isolated; environment-only doctor failures explained above; CI green including pack-smoke.

Review 4965895923 fixes:
- Major: a transmitted EIP-3009 authorization is a bearer instrument, so
  every post-transmission outcome now commits the reservation instead of
  releasing it; only a failure before the payment build releases. The
  paid-leg failure fix text no longer coaches the retry loop that
  compounded the gap. Red-without-fix tests on both lanes.
- Minor 1: parseBoolean accepts on/off, so the coached
  `tenjin config set bazaarPay on` now works (tested).
- Minor 2: doctor's skills check now reports tenjin-pay presence vs the
  bazaarPay toggle (both drift directions tested), making the three
  comments that pointed at it true.
- Minor 3: red-without-fix test for the SIWX fresh-challenge
  re-verification (payTo swap at the same price refuses, nothing signed).
- Nits: DeliverOpts is a discriminated union (no non-null assertion);
  the skill's exit-code table notes the unlisted/toggle-off USAGE split.

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

Re review 4965895923: all findings addressed in 5317b7f.

Major (budget accounting stops at the delivery boundary): fixed as prescribed. runPay now splits the failure path at the transmission boundary: a failure in buildExactPayment (no signature exists yet) still releases, but once the signed header goes out with the paid request the reservation is committed unconditionally, before any outcome branching, so a 402-after-payment, a non-2xx, and a post-transmit network failure all count against sessionBudget (httpRequest never throws on transport failure, so the linear commit covers every shape). The paid-leg fix text no longer coaches "then retry"; it says the signed payment already left, is counted against the session budget, and each retry signs a fresh authorization. Red-without-fix tests on both lanes: the hostile-Bazaar-seller test (rejects the paid leg after receiving the signature; asserts commit with the amount, no release, and that the fix text does not coach a retry) and the flipped tenjin-lane test both fail against the pre-fix code (verified by reverting pay.ts: 2 failures).

Minor 1 (coached command fails): parseBoolean accepts on/off alongside true/false (the hooks keys already speak on/off), so both refusal texts now coach a command that works; the USAGE fix text names both spellings. Round-trip tests added for on/off.

Minor 2 (phantom doctor diagnostic): took the first option and made the comments true instead of deleting them. Doctor's skills check now compares tenjin-pay presence against the bazaarPay toggle for every in-play wired directory: toggle on with the skill missing, and toggle off with the skill still teaching the lane, both warn with a tenjin config set bazaarPay on|off re-sync fix (which now works, per Minor 1). Warn not fail, since resolveLane keeps the lane itself safe, as you noted. Three tests cover both drift directions and the matching state.

Minor 3 (untested re-verification): added the exact case named: a Bazaar seller whose first 402 matches its listing but whose post-SIWX fresh 402 swaps payTo at the same price refuses with REGISTRY_MISMATCH, nothing signed, the authorizer never consulted. Verified red by deleting the pay.ts re-verify block: only this test fails.

Nits (both taken): DeliverOpts is now a discriminated union on paid, removing the amountAtomic! assertion; the tenjin-pay skill's exit-code line now states that bazaarPay off and unlisted refuse as USAGE (exit 2) with "run discover, then re-run pay" so an agent does not re-shape the command.

Gates at 5317b7f: lint clean, typecheck clean, prettier clean, full pnpm test 2242 passed / 10 skipped with HOME isolated (your doctor.test.ts caveat applied). The three parked operator decisions are untouched.

Verified keyless 2026-08-18 with the sweep's own client
(withBazaar(HTTPFacilitatorClient)): GET /discovery/resources answers
200 with the Bazaar envelope, 26,478 listings, type filter honored.
Like UltraVioleta it has no /discovery/search (a query sweep reports it
in errors), and like CDP it ignores payTo filters (the stored-sweep
evidence covers the pay lane). Defaults now pinned by a test; docs
updated.

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

Operator directive: PayAI's facilitator registry added to the default bazaarRegistries in 0c7043b.

  • Tenjin marketplace searched first: MISS (candidates surfaced x402 discovery-dataset coverage for GoPlausible/Agentic Market/Coinbase, nothing on PayAI's endpoint; outcome reported as regenerated).
  • Endpoint: https://facilitator.payai.network. Verified live and keylessly on 2026-08-18 with the exact client the sweep uses (withBazaar(new HTTPFacilitatorClient(...))): GET /discovery/resources answers 200 with the Bazaar envelope; listResources({ type: 'http', limit: 100 }) parses cleanly, 100 items returned, pagination.total 26,478.
  • Conformance matches the existing defaults: no /discovery/search (404), same as UltraVioleta, so a query sweep reports it in errors instead of failing; ignores payTo filters, same as CDP, which the stored-sweep evidence path already covers. Both noted in the defaults docblock.
  • Changes: src/lib/config.ts default list + docblock, new pin test for the default list in src/lib/config.test.ts, docs/command-reference.md default naming, PR body registry sentence. Registry text stays untrusted: discover/pay render url, registry, and description through sanitizeForTerminal per row with no per-source branching, so the new source is covered identically (no change needed).
  • Gates at 0c7043b: eslint, tsc, prettier clean; config/discover/pay/doctor test files: 232 passed.

Mechanical resync only; the live skill moved after a deploy (searchId
attribution doc). No hand edits.

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

Copy link
Copy Markdown
Contributor Author

576bd69 is the mechanical mirror resync (node scripts/sync-skill.mjs): the live tenjin.blog/skills.md moved after a deploy (searchId attribution doc), only skills/tenjin/SKILL.md changed, and the drift gate's own check (git diff --exit-code -- skills/ after resync) reproduces green locally.

@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.

@vraspar vraspar left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-review: the Major is fixed at the transmission boundary and pinned red-without-fix; both Minors and both nits closed; the new default registry is clean

Reviewed at 576bd69, delta over 2793125. Three new commits: the Major fix (5317b7f), the PayAI default registry (0c7043b, operator-directed), and a skills mirror resync (576bd69). Re-verified by running the affected suites and reverting the fix.

Resolved since round 1

  1. [Major] Reservation committed at the transmission boundary, not delivery. runPay now splits the failure path exactly right: a buildExactPayment throw (no signature exists) still releases, but once the signed header goes out the reservation is committed on the line immediately after httpRequest returns, before any outcome branching, so a 2xx, a 402, a non-2xx, and a transport failure all count against sessionBudget. Confirmed the load-bearing claim: httpRequest is fully try/finally-wrapped and every internal catch returns {ok:false} — it never throws on transport failure, so the commit always runs; and even a theoretical throw would leak the reservation to its TTL (conservative, never a wrongful release), so the stacking attack cannot recur. The paid-leg fix text no longer coaches "then retry" — it states the signed payment already left, is counted, and each retry signs a fresh authorization. Reverting pay.ts to the round-1 version fails 2 tests, including a hostile seller rejecting the paid leg still burns the session budget, so both lanes are pinned.
  2. [Minor] Coached enable command works. parseBoolean accepts on/off alongside true/false, and the USAGE fix names both; the two refusal texts now coach a command that succeeds. Pinned by ['on', true]/['off', false] round-trip tests.
  3. [Minor] The phantom doctor diagnostic is now real. Doctor's skills check compares tenjin-pay presence against the bazaarPay toggle for every wired directory and warns both drift directions (toggle on + skill missing, toggle off + skill still teaching) with a config set bazaarPay on|off re-sync fix — warn not fail, since resolveLane keeps the lane safe. Three tests cover both directions and the matching state; doctor suite passes 86 on a clean HOME.
  4. [Minor] SIWX fresh-challenge re-verification is tested. New test a fresh challenge that swaps payTo after SIWX is REGISTRY_MISMATCH, nothing signed — the exact case named in round 1: first 402 matches the listing, post-SIWX fresh 402 swaps payTo at the same price, passes the price-bump check, and only the re-verify block catches it; red when that block is deleted.
  5. [Nits] Both taken. DeliverOpts is a discriminated union on paid, removing the amountAtomic! assertion; the tenjin-pay skill's exit-code line now states bazaarPay off and unlisted refuse as USAGE (exit 2) with "run discover, then re-run pay".

New

  1. PayAI facilitator added to the default bazaarRegistries (0c7043b, operator-directed) — no new trust bypass. https://facilitator.payai.network is just another base URL in DEFAULT_BAZAAR_REGISTRIES; the verification path does not branch per registry. Every PayAI listing flows through the same sameResourceUrl (origin+path identity) and acceptsMismatch (scheme/network/asset/payTo exact, live amount ≤ advertised) as the existing sources, so a malicious PayAI listing still cannot verify a payment to a mismatched payTo, and the USDC-on-Base pin in buildExactPayment is the final backstop regardless of what any registry lists. Its documented quirks (no /discovery/search, ignores payTo filters) match UltraVioleta and CDP respectively and are already covered by the query-sweep errors path and the stored-sweep evidence. Keyless/live verification was done by the author with the exact sweep client (200, Bazaar envelope, 26k listings), and the default list is pinned by a test in src/lib/config.test.ts. This widens discovery surface, which is the intended operator decision, not a defect.

Verdict

Approve. The Major is fixed at the correct boundary and pinned red-without-fix on both lanes, all three Minors and both nits are closed and re-verified, and the new default registry adds discovery surface without a new trust path. Ran pay/config/discover/doctor suites (212 passing; the 2 doctor failures are this machine's ~/.claude leaking into the sandbox, green on a clean HOME); CI green at head.

Comment thread skills/tenjin/SKILL.md
- `excerpt` is a separate listing teaser, NOT the in-page preview.
- `resource` is the answer card. Compose it here rather than deferring it (a
merge-update via `PUT` still works later); field list and phrasing below.
- `searchId` (uuid) is optional supply-loop attribution: pass the `searchId` of an

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

WHy do we need to modify this skill and why does this skill exist. I think this mirrors the tenjin.blog right ? so this would be reverted in next updated ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes, skills/tenjin/SKILL.md is the vendored mirror of tenjin.blog/skills.md - the hosted skill served to CLI-less agents. It exists in-repo so the packaged CLI ships the same guidance offline and so CI (the skill-drift check) proves the two never diverge. The commits on this branch are resyncs in the live-to-repo direction only: the hosted source moved (twice this week - the searchId attribution doc and the scan additions), and the drift check goes red on any branch touching skills/ until the mirror catches up. Nothing here edits the mirror by hand, so nothing gets reverted on the next update - the next update IS another resync commit like these. The mirror's SOURCE lives in the tenjin repo; content changes happen there.

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.

2 participants