Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions internal/serviceoffercontroller/agent_confighash_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,32 +30,32 @@ func TestHermesConfigDecision(t *testing.T) {
wantDrift: false,
},
{
name: "annotation matches and data hashes to desired -> skip, no drift",
live: hermesConfigCM(t, "ns", desiredYAML, desiredHash, "1"),
name: "annotation matches and data hashes to desired -> skip, no drift",
live: hermesConfigCM(t, "ns", desiredYAML, desiredHash, "1"),
wantSkip: true,
wantDrift: false,
},
{
name: "annotation matches but data hand-edited -> skip with drift",
live: hermesConfigCM(t, "ns", otherYAML, desiredHash, "1"),
name: "annotation matches but data hand-edited -> skip with drift",
live: hermesConfigCM(t, "ns", otherYAML, desiredHash, "1"),
wantSkip: true,
wantDrift: true,
},
{
name: "annotation does not match desired -> apply",
live: hermesConfigCM(t, "ns", desiredYAML, otherHash, "1"),
name: "annotation does not match desired -> apply",
live: hermesConfigCM(t, "ns", desiredYAML, otherHash, "1"),
wantSkip: false,
wantDrift: false,
},
{
name: "annotation absent (pre-feature migration) -> apply",
live: hermesConfigCM(t, "ns", desiredYAML, "", "1"),
name: "annotation absent (pre-feature migration) -> apply",
live: hermesConfigCM(t, "ns", desiredYAML, "", "1"),
wantSkip: false,
wantDrift: false,
},
{
name: "annotation matches but data key missing -> skip with drift",
live: hermesConfigCMNoData(t, "ns", desiredHash, "1"),
name: "annotation matches but data key missing -> skip with drift",
live: hermesConfigCMNoData(t, "ns", desiredHash, "1"),
wantSkip: true,
wantDrift: true,
},
Expand Down
36 changes: 36 additions & 0 deletions internal/serviceoffercontroller/assets/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Agent chat widget assets

`chat.html` — the self-contained agent chat page served at `/chat` on every
agent-type offer's dedicated origin. Hand-maintained; no build step. It
derives the agent name from the hostname and price/model/network/asset from
the live 402 challenge, so the same file works for every agent offer on any
stack and network (Base mainnet `eip155:8453` and Base Sepolia
`eip155:84532` are supported).

`chat-vendor.js` — generated single-file ESM bundle of the widget's
dependencies. Do not edit by hand. Rebuild:

```sh
npm init -y && npm i viem@2.21.25 @x402/fetch@2.18.0 @x402/evm@2.18.0
cat > vendor-entry.mjs <<'EOF'
export { createWalletClient, createPublicClient, custom, http, erc20Abi,
formatUnits, parseUnits, keccak256 } from "viem";
export { privateKeyToAccount } from "viem/accounts";
export { base, baseSepolia } from "viem/chains";
export { wrapFetchWithPayment, x402Client } from "@x402/fetch";
export { ExactEvmScheme, toClientEvmSigner } from "@x402/evm";
EOF
npx esbuild vendor-entry.mjs --bundle --format=esm --minify --target=es2022 \
--outfile=chat-vendor.js
```

When the bundle is rebuilt, bump the `?v=` cache-buster on the
`chat-vendor.js` import in `chat.html` to the new sha256's first 8 hex
chars — intermediaries (e.g. Cloudflare) cache `.js` aggressively.

sha256 of the committed bundle:
`895fd923aa84d7cf80e2b1df299068aa38dba7307a9a380526c0b5426489724d`

The pinned versions are the exact pair validated end-to-end against the
x402-verifier with real on-chain settlements (X-PAYMENT v1 and
PAYMENT-SIGNATURE v2 flows both accepted since #690).
57 changes: 57 additions & 0 deletions internal/serviceoffercontroller/assets/chat-vendor.js

Large diffs are not rendered by default.

338 changes: 338 additions & 0 deletions internal/serviceoffercontroller/assets/chat.html

Large diffs are not rendered by default.

21 changes: 13 additions & 8 deletions internal/serviceoffercontroller/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ func (c *Controller) listServiceOffersForCatalog(ctx context.Context, override *
return offers, nil
}

func skillCatalogContentMatches(cm *unstructured.Unstructured, content, servicesJSON, openAPIJSON, apiDocsHTML string, bundles []offerBundleFile) bool {
func staticSiteContentMatches(cm *unstructured.Unstructured, content, servicesJSON, openAPIJSON, apiDocsHTML string, bundles []offerBundleFile) bool {
if cm == nil {
return false
}
Expand All @@ -51,7 +51,8 @@ func skillCatalogContentMatches(cm *unstructured.Unstructured, content, services
if data["skill.md"] != content ||
data["services.json"] != servicesJSON ||
data["openapi.json"] != openAPIJSON ||
data["api.html"] != apiDocsHTML {
data["api.html"] != apiDocsHTML ||
data["chat-vendor.js"] != chatWidgetVendorJS {
return false
}
// Per-offer bundles: every expected file present + identical, and no
Expand All @@ -75,22 +76,26 @@ func skillCatalogContentMatches(cm *unstructured.Unstructured, content, services
return true
}

func (c *Controller) skillCatalogContentUnchanged(ctx context.Context, content, servicesJSON, openAPIJSON, apiDocsHTML string, bundles []offerBundleFile) (bool, error) {
cm, err := c.configMaps.Namespace(skillCatalogNamespace).Get(ctx, skillCatalogConfigMapName, metav1.GetOptions{})
func (c *Controller) staticSiteContentUnchanged(ctx context.Context, content, servicesJSON, openAPIJSON, apiDocsHTML string, bundles []offerBundleFile) (bool, error) {
cm, err := c.configMaps.Namespace(staticSiteNamespace).Get(ctx, staticSiteConfigMapName, metav1.GetOptions{})
if apierrors.IsNotFound(err) {
return false, nil
}
if err != nil {
return false, err
}
return skillCatalogContentMatches(cm, content, servicesJSON, openAPIJSON, apiDocsHTML, bundles), nil
return staticSiteContentMatches(cm, content, servicesJSON, openAPIJSON, apiDocsHTML, bundles), nil
}

func computeSkillCatalogContentHash(content, servicesJSON, openAPIJSON, apiDocsHTML string, bundles []offerBundleFile) string {
return fmt.Sprintf("%x", md5Sum(content+servicesJSON+openAPIJSON+apiDocsHTML+bundleDigestInput(bundles)))[:8]
func computeStaticSiteContentHash(content, servicesJSON, openAPIJSON, apiDocsHTML string, bundles []offerBundleFile) string {
// The embedded vendor bundle is part of the served content: fold it in
// so a controller upgrade that changes it re-applies the ConfigMap and
// rolls the httpd (otherwise the skip-when-unchanged fast path pins the
// old asset forever). The per-offer chat pages flow through bundles.
return fmt.Sprintf("%x", md5Sum(content+servicesJSON+openAPIJSON+apiDocsHTML+chatWidgetVendorJS+bundleDigestInput(bundles)))[:8]
}

func skillCatalogDeployedContentHash(deployment *unstructured.Unstructured) string {
func staticSiteDeployedContentHash(deployment *unstructured.Unstructured) string {
if deployment == nil {
return ""
}
Expand Down
46 changes: 32 additions & 14 deletions internal/serviceoffercontroller/catalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,46 +6,64 @@ import (
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)

func TestComputeSkillCatalogContentHashDeterministic(t *testing.T) {
a := computeSkillCatalogContentHash("# cat", `{"services":[]}`, `{"openapi":"3.1.0"}`, "<html></html>", nil)
b := computeSkillCatalogContentHash("# cat", `{"services":[]}`, `{"openapi":"3.1.0"}`, "<html></html>", nil)
func TestComputeStaticSiteContentHashDeterministic(t *testing.T) {
a := computeStaticSiteContentHash("# cat", `{"services":[]}`, `{"openapi":"3.1.0"}`, "<html></html>", nil)
b := computeStaticSiteContentHash("# cat", `{"services":[]}`, `{"openapi":"3.1.0"}`, "<html></html>", nil)
if a != b {
t.Fatalf("hash not deterministic: %q vs %q", a, b)
}
if len(a) != 8 {
t.Fatalf("hash length = %d, want 8", len(a))
}

changed := computeSkillCatalogContentHash("# cat", `{"services":[{"name":"a"}]}`, `{"openapi":"3.1.0"}`, "<html></html>", nil)
changed := computeStaticSiteContentHash("# cat", `{"services":[{"name":"a"}]}`, `{"openapi":"3.1.0"}`, "<html></html>", nil)
if changed == a {
t.Fatal("expected different hash when catalog content changes")
}
}

func TestSkillCatalogContentMatches(t *testing.T) {
cm := buildSkillCatalogConfigMap("# cat", `{"services":[]}`, `{"openapi":"3.1.0"}`, "<html></html>", nil)
if !skillCatalogContentMatches(cm, "# cat", `{"services":[]}`, `{"openapi":"3.1.0"}`, "<html></html>", nil) {
func TestStaticSiteContentMatches(t *testing.T) {
cm := buildStaticSiteConfigMap("# cat", `{"services":[]}`, `{"openapi":"3.1.0"}`, "<html></html>", nil)
if !staticSiteContentMatches(cm, "# cat", `{"services":[]}`, `{"openapi":"3.1.0"}`, "<html></html>", nil) {
t.Fatal("expected matching catalog content")
}
if skillCatalogContentMatches(cm, "# changed", `{"services":[]}`, `{"openapi":"3.1.0"}`, "<html></html>", nil) {
if staticSiteContentMatches(cm, "# changed", `{"services":[]}`, `{"openapi":"3.1.0"}`, "<html></html>", nil) {
t.Fatal("expected different skill.md to not match")
}
if skillCatalogContentMatches(nil, "# cat", `{}`, `{}`, "", nil) {
if staticSiteContentMatches(nil, "# cat", `{}`, `{}`, "", nil) {
t.Fatal("nil configmap must not match")
}
}

func TestSkillCatalogDeployedContentHash(t *testing.T) {
deployment := buildSkillCatalogDeployment("abc12345", nil)
if got := skillCatalogDeployedContentHash(deployment); got != "abc12345" {
func TestStaticSiteDeployedContentHash(t *testing.T) {
deployment := buildStaticSiteDeployment("abc12345", nil)
if got := staticSiteDeployedContentHash(deployment); got != "abc12345" {
t.Fatalf("hash = %q, want abc12345", got)
}
if got := skillCatalogDeployedContentHash(nil); got != "" {
if got := staticSiteDeployedContentHash(nil); got != "" {
t.Fatalf("nil deployment hash = %q, want empty", got)
}

empty := &unstructured.Unstructured{Object: map[string]any{"spec": map[string]any{}}}
if got := skillCatalogDeployedContentHash(empty); got != "" {
if got := staticSiteDeployedContentHash(empty); got != "" {
t.Fatalf("missing annotation hash = %q, want empty", got)
}
}

// TestStaticSiteStaleChatWidgetTriggersUpdate pins the upgrade path: a
// deployed ConfigMap whose chat widget differs from the binary's embedded
// copy must NOT match, otherwise the skip-when-unchanged fast path pins the
// old asset across controller upgrades forever. (Per-offer chat pages flow
// through the offer bundles, which the match already covers.)
func TestStaticSiteStaleChatWidgetTriggersUpdate(t *testing.T) {
cm := buildStaticSiteConfigMap("# cat", `{}`, `{}`, "<html></html>", nil)
if !staticSiteContentMatches(cm, "# cat", `{}`, `{}`, "<html></html>", nil) {
t.Fatalf("fresh ConfigMap should match its own inputs")
}
if err := unstructured.SetNestedField(cm.Object, "stale vendor", "data", "chat-vendor.js"); err != nil {
t.Fatal(err)
}
if staticSiteContentMatches(cm, "# cat", `{}`, `{}`, "<html></html>", nil) {
t.Fatalf("stale chat-vendor.js must trigger a ConfigMap update")
}
}
63 changes: 63 additions & 0 deletions internal/serviceoffercontroller/chatwidget.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package serviceoffercontroller

import (
_ "embed"
"html/template"
"strings"

"github.com/ObolNetwork/obol-stack/internal/monetizeapi"
"github.com/ObolNetwork/obol-stack/internal/schemas"
"github.com/ObolNetwork/obol-stack/internal/storefront"
)

// The agent chat widget: a self-contained browser chat client served free on
// every agent-type offer's dedicated origin at /chat (and embedded on the
// offer's landing page). The page discovers pricing at runtime from its own
// origin — price, model, payment network and asset come from the 402
// challenge on POST /v1/chat/completions — while identity and theme are
// rendered per offer: the template receives the offer's display name and the
// same resolved storefront theme tokens as its landing page, so default and
// branded designs flow through identically.
//
// Payment is fully client-side: the visitor connects an injected wallet,
// signs one fixed message ("sign in with Ethereum") whose keccak256 becomes
// a deterministic local session key, funds that session address with a small
// USDC transfer, and every chat turn is then paid silently via x402
// (EIP-3009 transferWithAuthorization signed by the session key — gasless
// for the payer). The session key never leaves the page and is re-derived by
// re-signing the same message, so nothing is persisted.
//
//go:embed assets/chat.html
var chatWidgetTmplSrc string

var chatWidgetTmpl = template.Must(template.New("chat_widget").Parse(chatWidgetTmplSrc))

// chatWidgetVendorJS is the widget's only dependency: viem 2.21.25 +
// @x402/fetch 2.18.0 + @x402/evm 2.18.0 bundled into one ESM file so the
// page loads with zero external requests (no CDN, works on air-gapped
// stacks). Served once at the catalog httpd root — per-offer /chat pages
// import it behind a content-hash ?v= cache-buster. Rebuild: see
// assets/README.md.
//
//go:embed assets/chat-vendor.js
var chatWidgetVendorJS string

// buildOfferChatHTML renders the offer's /chat page with the same title and
// resolved theme as its landing page.
func buildOfferChatHTML(offer *monetizeapi.ServiceOffer, profile schemas.StorefrontProfile) string {
title := strings.TrimSpace(offer.Spec.Registration.Name)
if title == "" {
title = offer.Name
}
theme := storefront.ResolveTheme(profile.Theme, profile.AccentColor)
var out strings.Builder
err := chatWidgetTmpl.Execute(&out, map[string]any{
"Title": title,
"OfferName": offer.Name,
"ThemeCSS": template.CSS(theme.CSSVars()),
})
if err != nil {
return "<!doctype html><title>" + template.HTMLEscapeString(title) + "</title>"
}
return out.String()
}
Loading
Loading