Skip to content

Commit 176758c

Browse files
authored
x402 pre-merge follow-up: seller gateway, unsettled metric, verifyOnly warning (#345)
* feat(x402/buyer): detect 2xx without X-PAYMENT-RESPONSE and expose via metric Post-#343, settlement moved off the Traefik ForwardAuth hop and became the seller's responsibility. The buyer sidecar calls ConfirmSpend on any upstream 2xx regardless of whether X-PAYMENT-RESPONSE is present, so a seller that returns 200 without settling silently consumes the payer's voucher with no observable signal. This matches the W2/W9 gap flagged in the PR #343 review. - Add OnPaymentUnsettled callback to replayableX402Transport. Fires exactly when the upstream returns 2xx but no successful X-PAYMENT-RESPONSE is emitted, logs a WARN, and increments a new counter. - Add PaymentEventUnsettled event type. - Add obol_x402_buyer_payment_unsettled_confirmations_total metric with upstream/remote_model labels. Operators should alert on any non-zero value. - Pin invariant with two new tests: - TestProxy_UpstreamSuccessNoSettlementHeader_IncrementsUnsettledMetric - TestProxy_UpstreamSuccessWithSettlementHeader_DoesNotIncrementUnsettledMetric - Pin mux symmetry invariant that both /chat/completions and /v1/chat/completions route identically — catches the class of regression that produced the PR #343 /v1 add/revert/re-add churn. * fix(x402/forwardauth): warn on verifyOnly=false, shrink facilitator timeout to 5s Addresses W7 and W8 from the PR #343 review. W7 — verifyOnly=false footgun: VerifyOnly is the right name for the flag in the in-process gateway context but is semantically load-bearing for Traefik ForwardAuth, where the auth hop cannot observe the upstream response. If an operator flips x402-pricing.yaml verifyOnly=false believing it enables "real" settlement, the verifier will debit the payer before the upstream serves the request. We cannot remove the flag without a broader refactor of internal/inference/gateway.go, so instead: - NewForwardAuthMiddleware now logs a loud WARNING at construction when VerifyOnly=false, explaining the safe usage. - cmd/x402-verifier/main.go emits the same warning on startup and log-scrub filters will surface it. - ForwardAuthConfig.VerifyOnly documents the invariant ("MUST be true behind Traefik ForwardAuth"), so a contributor flipping it gets the explanation inline. W8 — facilitator timeout: reduce http.Client.Timeout from 30s to 5s. /verify is a cheap signature check; anything beyond 5s is a network problem the caller should see quickly rather than having every paid request hang for half a minute on a slow facilitator. Tests: - TestForwardAuth_VerifyOnlyFalse_EmitsStartupWarning pins the warning text. - TestForwardAuth_VerifyOnlyTrue_NoStartupWarning is the negative control so operators don't train themselves to filter the warning out. * chore(embed): lint :latest image tags with pin-by-digest policy Addresses W4 from the PR #343 review. The /v1 back-and-forth on PR #343 (add → revert → re-add) was consistent with a deployed x402-buyer:latest image lagging behind main, and the fix hardcoded /v1 in the LiteLLM template instead of pinning the image. Same risk applies to x402-verifier and serviceoffer-controller which also ship as :latest. - New internal/embed/embed_image_pin_test.go scans every embedded template and fails when a new :latest appears without an allowlist entry. The allowlist currently covers the three obolnetwork images pending digest pinning; each entry carries a short reason. Removing an entry without replacing :latest in the YAML fails the test (stale-allowlist check). - Inline TODO(image-pin) comments in llm.yaml and x402.yaml explain the policy at the point of violation so contributors who touch the deployment spec see it. This does not pin the images (that requires GHCR access to produce the digest) — it establishes the contract and makes drift visible. * feat(x402): route sell http through seller gateway * fix(obolup): harden installer writes and tty prompts * docs: keep seller gateway report in pr body only * feat(model): add master token accessor (#347) * feat(sell): register by default with explicit opt-out (#349) * feat(sell): warn that --register is off-chain only * feat(sell): register by default with explicit opt-out * feat(sell): show registration summary in sell status * test(sell): cover registration defaults and sync skill docs * feat(openclaw): surface generated agent wallet (#348) * fix(stack): preload openclaw image in dev k3d (#352) * feat(x402-buyer): expose confirm-spend persistence failures (#351) --------- Co-authored-by: bussyjd <bussyjd@users.noreply.github.com>
1 parent 96d7f12 commit 176758c

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+1893
-423
lines changed

cmd/obol/model.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ func modelCommand(cfg *config.Config) *cli.Command {
2323
Commands: []*cli.Command{
2424
modelSetupCommand(cfg),
2525
modelStatusCommand(cfg),
26+
modelTokenCommand(cfg),
2627
modelSyncCommand(cfg),
2728
modelPullCommand(),
2829
modelListCommand(cfg),
@@ -31,6 +32,28 @@ func modelCommand(cfg *config.Config) *cli.Command {
3132
}
3233
}
3334

35+
func modelTokenCommand(cfg *config.Config) *cli.Command {
36+
return &cli.Command{
37+
Name: "token",
38+
Usage: "Print the LiteLLM master token for API access",
39+
Action: func(ctx context.Context, cmd *cli.Command) error {
40+
u := getUI(cmd)
41+
42+
token, err := model.GetMasterKey(cfg)
43+
if err != nil {
44+
return err
45+
}
46+
47+
if u.IsJSON() {
48+
return u.JSON(map[string]string{"token": token})
49+
}
50+
51+
u.Print(token)
52+
return nil
53+
},
54+
}
55+
}
56+
3457
func modelSetupCommand(cfg *config.Config) *cli.Command {
3558
return &cli.Command{
3659
Name: "setup",

cmd/obol/model_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package main
2+
3+
import (
4+
"testing"
5+
6+
"github.com/ObolNetwork/obol-stack/internal/config"
7+
)
8+
9+
func TestModelCommand_Structure(t *testing.T) {
10+
cfg := &config.Config{}
11+
cmd := modelCommand(cfg)
12+
13+
if cmd.Name != "model" {
14+
t.Fatalf("command name = %q, want model", cmd.Name)
15+
}
16+
17+
expected := map[string]bool{
18+
"setup": false,
19+
"status": false,
20+
"token": false,
21+
"sync": false,
22+
"pull": false,
23+
"list": false,
24+
"remove": false,
25+
}
26+
27+
for _, sub := range cmd.Commands {
28+
if _, ok := expected[sub.Name]; ok {
29+
expected[sub.Name] = true
30+
}
31+
}
32+
33+
for name, found := range expected {
34+
if !found {
35+
t.Errorf("missing subcommand %q", name)
36+
}
37+
}
38+
}

cmd/obol/openclaw.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,36 @@ func openclawWalletCommand(cfg *config.Config) *cli.Command {
275275
}, getUI(cmd))
276276
},
277277
},
278+
{
279+
Name: "address",
280+
Usage: "Show the wallet address for an OpenClaw instance",
281+
ArgsUsage: "[instance-name]",
282+
Action: func(ctx context.Context, cmd *cli.Command) error {
283+
args := cmd.Args().Slice()
284+
285+
if len(args) == 0 {
286+
addr, err := openclaw.ResolveWalletAddress(cfg)
287+
if err != nil {
288+
return err
289+
}
290+
getUI(cmd).Print(addr)
291+
return nil
292+
}
293+
294+
id, _, err := openclaw.ResolveInstance(cfg, args)
295+
if err != nil {
296+
return err
297+
}
298+
299+
walletsUI := getUI(cmd)
300+
walletInfo, err := openclaw.ReadWalletMetadata(openclaw.DeploymentPath(cfg, id))
301+
if err != nil {
302+
return err
303+
}
304+
walletsUI.Print(walletInfo.Address)
305+
return nil
306+
},
307+
},
278308
{
279309
Name: "list",
280310
Usage: "List wallets for OpenClaw instances",

cmd/obol/openclaw_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package main
2+
3+
import "testing"
4+
5+
func TestOpenClawWalletCommand_Structure(t *testing.T) {
6+
cfg := newTestConfig(t)
7+
cmd := openclawCommand(cfg)
8+
wallet := findSubcommand(t, cmd, "wallet")
9+
10+
expected := map[string]bool{
11+
"backup": false,
12+
"restore": false,
13+
"address": false,
14+
"list": false,
15+
}
16+
17+
for _, sub := range wallet.Commands {
18+
if _, ok := expected[sub.Name]; ok {
19+
expected[sub.Name] = true
20+
}
21+
}
22+
23+
for name, found := range expected {
24+
if !found {
25+
t.Errorf("missing wallet subcommand %q", name)
26+
}
27+
}
28+
}

0 commit comments

Comments
 (0)