Skip to content

Commit 47a012f

Browse files
bussyjdOisinKyne
authored andcommitted
fix(buy): enforce inference spend safety
1 parent e1da415 commit 47a012f

8 files changed

Lines changed: 483 additions & 140 deletions

File tree

cmd/obol/buy.go

Lines changed: 180 additions & 98 deletions
Large diffs are not rendered by default.

cmd/obol/buy_test.go

Lines changed: 156 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ import (
66
"strings"
77
"testing"
88

9+
"github.com/ObolNetwork/obol-stack/internal/agentruntime"
910
"github.com/ObolNetwork/obol-stack/internal/buy"
11+
"github.com/ObolNetwork/obol-stack/internal/schemas"
1012
)
1113

1214
func TestBudgetToBaseUnits(t *testing.T) {
@@ -59,6 +61,61 @@ func TestBudgetToBaseUnits(t *testing.T) {
5961
}
6062
}
6163

64+
func TestResolveBudgetEnforcesExplicitCap(t *testing.T) {
65+
t.Parallel()
66+
67+
pricePerAuth := big.NewInt(1000)
68+
tests := []struct {
69+
name string
70+
flag string
71+
authCount int
72+
want *big.Int
73+
wantErr string
74+
}{
75+
{
76+
name: "no explicit budget returns exact signed spend",
77+
authCount: 5,
78+
want: big.NewInt(5000),
79+
},
80+
{
81+
name: "explicit budget equal to signed spend passes",
82+
flag: "0.005",
83+
authCount: 5,
84+
want: big.NewInt(5000),
85+
},
86+
{
87+
name: "explicit budget above signed spend still returns exact signed spend",
88+
flag: "0.01",
89+
authCount: 5,
90+
want: big.NewInt(5000),
91+
},
92+
{
93+
name: "explicit budget below count cost errors",
94+
flag: "0.004",
95+
authCount: 5,
96+
wantErr: "below the requested pre-authorization cost",
97+
},
98+
}
99+
for _, tc := range tests {
100+
t.Run(tc.name, func(t *testing.T) {
101+
t.Parallel()
102+
got, err := resolveBudget(tc.flag, "USDC", tc.authCount, pricePerAuth)
103+
if tc.wantErr != "" {
104+
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
105+
t.Fatalf("resolveBudget err = %v, want substring %q", err, tc.wantErr)
106+
}
107+
return
108+
}
109+
if err != nil {
110+
t.Fatalf("resolveBudget unexpected err: %v", err)
111+
}
112+
if got.Cmp(tc.want) != 0 {
113+
t.Fatalf("resolveBudget = %s, want %s", got, tc.want)
114+
}
115+
})
116+
}
117+
}
118+
62119
func TestBuildBuyPyArgv(t *testing.T) {
63120
tests := []struct {
64121
name string
@@ -72,15 +129,15 @@ func TestBuildBuyPyArgv(t *testing.T) {
72129
Seller: "https://demo.example/services/x",
73130
BudgetMicro: "10000000",
74131
},
75-
want: []string{
76-
hermesPython, hermesBuyPyPath, "buy", "default-paid",
132+
want: append(buy.BuyPyCommand(agentruntime.Hermes, "buy", "default-paid"),
77133
"--endpoint", "https://demo.example/services/x",
78134
"--budget", "10000000",
79-
},
135+
),
80136
},
81137
{
82138
name: "all optional flags",
83139
opts: buyPyOptions{
140+
Runtime: agentruntime.Hermes,
84141
Name: "demo",
85142
Seller: "https://s.example",
86143
Model: "qwen3.5:9b",
@@ -92,8 +149,7 @@ func TestBuildBuyPyArgv(t *testing.T) {
92149
SetDefault: true,
93150
Force: true,
94151
},
95-
want: []string{
96-
hermesPython, hermesBuyPyPath, "buy", "demo",
152+
want: append(buy.BuyPyCommand(agentruntime.Hermes, "buy", "demo"),
97153
"--endpoint", "https://s.example",
98154
"--budget", "5000000",
99155
"--model", "qwen3.5:9b",
@@ -103,22 +159,20 @@ func TestBuildBuyPyArgv(t *testing.T) {
103159
"--cost-cap", "150000",
104160
"--set-default",
105161
"--force",
106-
},
162+
),
107163
},
108164
{
109-
name: "cost cap without auto-refill is still emitted (host owns intent)",
165+
name: "cost cap without auto-refill is suppressed",
110166
opts: buyPyOptions{
111167
Name: "demo",
112168
Seller: "https://s.example",
113169
BudgetMicro: "1000000",
114170
CostCap: big.NewInt(42),
115171
},
116-
want: []string{
117-
hermesPython, hermesBuyPyPath, "buy", "demo",
172+
want: append(buy.BuyPyCommand(agentruntime.Hermes, "buy", "demo"),
118173
"--endpoint", "https://s.example",
119174
"--budget", "1000000",
120-
"--cost-cap", "42",
121-
},
175+
),
122176
},
123177
{
124178
name: "cost cap of zero is suppressed",
@@ -128,11 +182,10 @@ func TestBuildBuyPyArgv(t *testing.T) {
128182
BudgetMicro: "1000000",
129183
CostCap: big.NewInt(0),
130184
},
131-
want: []string{
132-
hermesPython, hermesBuyPyPath, "buy", "demo",
185+
want: append(buy.BuyPyCommand(agentruntime.Hermes, "buy", "demo"),
133186
"--endpoint", "https://s.example",
134187
"--budget", "1000000",
135-
},
188+
),
136189
},
137190
{
138191
name: "explicit count emits --count",
@@ -142,12 +195,11 @@ func TestBuildBuyPyArgv(t *testing.T) {
142195
BudgetMicro: "5000000000000000000",
143196
Count: 5000,
144197
},
145-
want: []string{
146-
hermesPython, hermesBuyPyPath, "buy", "demo",
198+
want: append(buy.BuyPyCommand(agentruntime.Hermes, "buy", "demo"),
147199
"--endpoint", "https://s.example",
148200
"--budget", "5000000000000000000",
149201
"--count", "5000",
150-
},
202+
),
151203
},
152204
{
153205
name: "auto-refill without explicit counts",
@@ -157,12 +209,11 @@ func TestBuildBuyPyArgv(t *testing.T) {
157209
BudgetMicro: "1000000",
158210
AutoRefill: true,
159211
},
160-
want: []string{
161-
hermesPython, hermesBuyPyPath, "buy", "demo",
212+
want: append(buy.BuyPyCommand(agentruntime.Hermes, "buy", "demo"),
162213
"--endpoint", "https://s.example",
163214
"--budget", "1000000",
164215
"--auto-refill",
165-
},
216+
),
166217
},
167218
{
168219
name: "auto-refill off does not emit refill flags",
@@ -174,11 +225,10 @@ func TestBuildBuyPyArgv(t *testing.T) {
174225
RefillThreshold: 3,
175226
RefillCount: 7,
176227
},
177-
want: []string{
178-
hermesPython, hermesBuyPyPath, "buy", "demo",
228+
want: append(buy.BuyPyCommand(agentruntime.Hermes, "buy", "demo"),
179229
"--endpoint", "https://s.example",
180230
"--budget", "1000000",
181-
},
231+
),
182232
},
183233
{
184234
name: "model whitespace trimmed",
@@ -188,12 +238,24 @@ func TestBuildBuyPyArgv(t *testing.T) {
188238
Model: " qwen3.5:9b ",
189239
BudgetMicro: "1000000",
190240
},
191-
want: []string{
192-
hermesPython, hermesBuyPyPath, "buy", "demo",
241+
want: append(buy.BuyPyCommand(agentruntime.Hermes, "buy", "demo"),
193242
"--endpoint", "https://s.example",
194243
"--budget", "1000000",
195244
"--model", "qwen3.5:9b",
245+
),
246+
},
247+
{
248+
name: "openclaw runtime uses openclaw skill mount",
249+
opts: buyPyOptions{
250+
Runtime: agentruntime.OpenClaw,
251+
Name: "demo",
252+
Seller: "https://s.example",
253+
BudgetMicro: "1000000",
196254
},
255+
want: append(buy.BuyPyCommand(agentruntime.OpenClaw, "buy", "demo"),
256+
"--endpoint", "https://s.example",
257+
"--budget", "1000000",
258+
),
197259
},
198260
}
199261

@@ -407,7 +469,8 @@ func TestResolveBuyModel(t *testing.T) {
407469

408470
// resolveCostCap: explicit flag wins (in atomic units OR human decimal),
409471
// otherwise we apply the costCapMarkupBps markup over price when
410-
// auto-refill is on. No auto-refill means no auto cap.
472+
// auto-refill is on. Explicit --cost-cap without auto-refill is rejected so
473+
// it cannot accidentally create an in-pod auto-refill policy.
411474
func TestResolveCostCap(t *testing.T) {
412475
t.Parallel()
413476
price := big.NewInt(1000)
@@ -424,8 +487,9 @@ func TestResolveCostCap(t *testing.T) {
424487
{name: "no flag + no auto-refill = nil", autoRefill: false, price: price, wantNil: true},
425488
{name: "no flag + auto-refill = 150% markup", autoRefill: true, price: price, want: big.NewInt(1500)},
426489
{name: "atomic-unit flag wins", flag: "42", autoRefill: true, price: price, want: big.NewInt(42)},
427-
{name: "human-decimal USDC fallback", flag: "0.001", token: "USDC", autoRefill: false, price: price, want: big.NewInt(1000)},
428-
{name: "invalid flag errors", flag: "not-a-number", token: "USDC", autoRefill: false, price: price, wantErr: "not a valid number"},
490+
{name: "human-decimal USDC fallback", flag: "0.001", token: "USDC", autoRefill: true, price: price, want: big.NewInt(1000)},
491+
{name: "cost cap without auto-refill errors", flag: "42", token: "USDC", autoRefill: false, price: price, wantErr: "requires --auto-refill"},
492+
{name: "invalid flag errors", flag: "not-a-number", token: "USDC", autoRefill: true, price: price, wantErr: "not a valid number"},
429493
}
430494

431495
for _, tc := range tests {
@@ -454,6 +518,70 @@ func TestResolveCostCap(t *testing.T) {
454518
}
455519
}
456520

521+
func TestAuthCapAndCapacityLabel(t *testing.T) {
522+
t.Parallel()
523+
524+
permit2Entry := &buy.CatalogEntry{Asset: &schemas.ServiceCatalogAsset{TransferMethod: "permit2"}}
525+
eip3009Entry := &buy.CatalogEntry{Asset: &schemas.ServiceCatalogAsset{TransferMethod: "eip3009"}}
526+
527+
tests := []struct {
528+
name string
529+
entry *buy.CatalogEntry
530+
requested int
531+
wantAuths int
532+
wantCapped bool
533+
wantReason string
534+
wantLabel string
535+
}{
536+
{
537+
name: "permit2 perMTok can cap below one natural unit",
538+
entry: permit2Entry,
539+
requested: defaultInteractivePerMTokCount * schemas.ApproxTokensPerRequest,
540+
wantAuths: permit2SafeAuthCount,
541+
wantCapped: true,
542+
wantReason: "Permit2 storage limit",
543+
wantLabel: "0.5 million tokens",
544+
},
545+
{
546+
name: "non-permit2 still mirrors buy.py max auth count",
547+
entry: eip3009Entry,
548+
requested: defaultInteractivePerMTokCount * schemas.ApproxTokensPerRequest,
549+
wantAuths: maxBuyPyAuthCount,
550+
wantCapped: true,
551+
wantReason: "buy.py signing limit",
552+
wantLabel: "1 million tokens",
553+
},
554+
{
555+
name: "small request is not capped",
556+
entry: permit2Entry,
557+
requested: 25,
558+
wantAuths: 25,
559+
wantCapped: false,
560+
wantLabel: "25 requests",
561+
},
562+
}
563+
564+
for _, tc := range tests {
565+
t.Run(tc.name, func(t *testing.T) {
566+
t.Parallel()
567+
got, capped, reason := applyAuthCap(tc.entry, tc.requested)
568+
if got != tc.wantAuths || capped != tc.wantCapped || reason != tc.wantReason {
569+
t.Fatalf("applyAuthCap = (%d, %v, %q), want (%d, %v, %q)",
570+
got, capped, reason, tc.wantAuths, tc.wantCapped, tc.wantReason)
571+
}
572+
unit := "perRequest"
573+
multiplier := 1
574+
if strings.Contains(tc.wantLabel, "million tokens") {
575+
unit = "perMTok"
576+
multiplier = schemas.ApproxTokensPerRequest
577+
}
578+
if label := capacityLabel(got, multiplier, unit); label != tc.wantLabel {
579+
t.Fatalf("capacityLabel = %q, want %q", label, tc.wantLabel)
580+
}
581+
})
582+
}
583+
}
584+
457585
// looksLikeURL keeps the positional URL detection in sync with the error
458586
// message we surface when users pass a name-shaped positional.
459587
func TestLooksLikeURL(t *testing.T) {

internal/buy/balance.go

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -70,12 +70,7 @@ func FetchWalletInfo(cfg *config.Config, runtime agentruntime.Runtime, id, token
7070
kubectlBin := filepath.Join(cfg.BinDir, "kubectl")
7171
kubeconfig := filepath.Join(cfg.ConfigDir, "kubeconfig.yaml")
7272

73-
argv := []string{
74-
"/opt/hermes/.venv/bin/python3",
75-
"/data/.hermes/obol-skills/buy-x402/scripts/buy.py",
76-
"balance",
77-
"--chain", chain,
78-
}
73+
argv := BuyPyCommand(runtime, "balance", "--chain", chain)
7974
kubectlArgs := agentruntime.BuildExecArgs(runtime, id, argv, false)
8075

8176
cmd := exec.Command(kubectlBin, kubectlArgs...)

internal/buy/purchases.go

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,7 @@ func ListPurchases(cfg *config.Config, runtime agentruntime.Runtime, id string)
4545
kubectlBin := filepath.Join(cfg.BinDir, "kubectl")
4646
kubeconfig := filepath.Join(cfg.ConfigDir, "kubeconfig.yaml")
4747

48-
argv := []string{
49-
"/opt/hermes/.venv/bin/python3",
50-
"/data/.hermes/obol-skills/buy-x402/scripts/buy.py",
51-
"list",
52-
"--json",
53-
}
48+
argv := BuyPyCommand(runtime, "list", "--json")
5449
kubectlArgs := agentruntime.BuildExecArgs(runtime, id, argv, false)
5550

5651
cmd := exec.Command(kubectlBin, kubectlArgs...)

internal/buy/script.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package buy
2+
3+
import "github.com/ObolNetwork/obol-stack/internal/agentruntime"
4+
5+
const (
6+
hermesPythonPath = "/opt/hermes/.venv/bin/python3"
7+
hermesBuyPyPath = "/data/.hermes/obol-skills/buy-x402/scripts/buy.py"
8+
9+
openClawPythonPath = "python3"
10+
openClawBuyPyPath = "/data/.openclaw/skills/buy-x402/scripts/buy.py"
11+
)
12+
13+
// BuyPyCommand returns the in-pod argv prefix for the buy-x402 helper in the
14+
// selected runtime. Hermes carries its own venv; OpenClaw exposes python3 on
15+
// PATH and mounts skills under /data/.openclaw/skills.
16+
func BuyPyCommand(runtime agentruntime.Runtime, args ...string) []string {
17+
python := hermesPythonPath
18+
script := hermesBuyPyPath
19+
if runtime == agentruntime.OpenClaw {
20+
python = openClawPythonPath
21+
script = openClawBuyPyPath
22+
}
23+
argv := []string{python, script}
24+
return append(argv, args...)
25+
}

0 commit comments

Comments
 (0)