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
8 changes: 7 additions & 1 deletion api/openapi.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
title: "Debuglet dispatcher HTTP API"
version: "1.19"
version: "1.20"
description: "Wire contract of the dispatcher's public HTTP API. Bandwidth values are bits per second, timeouts are milliseconds, TESLA delays are seconds, TESLA anchor timestamps are Unix nanoseconds and every other timestamp is Unix seconds unless a field says otherwise. A client states the contract version it requires with the Debuglet-API-Version request header, declared on every operation below; every response carries the version this dispatcher implements in the same header. Requests are authenticated with a server-issued session: a native client presents it as an Authorization bearer token, a browser client in the session_token cookie plus the X-Debuglet-CSRF header on state-changing requests. Each operation states its own 401 and 403. Access to another account's run or payment order answers 404, so a response is no existence oracle. A dispatcher explicitly configured for the local development profile additionally serves the unauthenticated routes below as its own local operator. See docs/api.md for the access matrix and the compatibility and deprecation policy."
servers:
- url: "/"
Expand Down Expand Up @@ -6031,6 +6031,12 @@ components:
description: "pending means no certificate is bound; offline means enrolled without a ready control session; online means ready. Clients must tolerate future values."
ready:
type: "boolean"
admission:
type: "string"
description: "ready, maintenance (dispatcher admission paused) or offline (no ready control session, including pending enrollment). A snapshot, not a capacity guarantee; absent on older dispatchers."
drain_status:
type: "string"
description: "unknown: the control protocol does not observe the executor host's joined service shutdown. Offline and dispatcher maintenance never prove a safe executor drain. Absent on older dispatchers; clients must treat absent or unrecognized values as unavailable."
last_seen:
type: "integer"
format: "int64"
Expand Down
5 changes: 3 additions & 2 deletions api/spec.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ var OpenAPI []byte
const (
// Version is the HTTP API contract version as major.minor. It equals the
// info.version field of OpenAPI.
Version = "1.19"
Version = "1.20"
// Major is the compatibility number of Version. Clients that require a
// different major version are answered with an explicit incompatibility
// error instead of a best-effort response.
Expand Down Expand Up @@ -45,7 +45,8 @@ const (
// /executors and its status filter.
// 1.18 added server-assisted verification and receipt keys.
// 1.19 added recorded destination policies: deny, reason, expiry and their listing.
Minor = 19
// 1.20 added admission and unavailable drain status to account-owned executors.
Minor = 20
// VersionHeader carries the contract version a client requires on requests
// and the version the dispatcher implements on responses.
VersionHeader = "Debuglet-API-Version"
Expand Down
41 changes: 41 additions & 0 deletions cmd/dbl/allowance.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 ETH Zurich

package main

import (
"context"
"fmt"
"io"
)

const allowanceUsage = `Usage:
dbl [--dispatcher NAME] allowance

Report the account's granted, reserved, consumed and remaining TEST units.
These are non-transferable usage credits, not money. Requires a saved account
credential and a dispatcher offering allowances (API 1.15 or newer).
`

func allowanceCommand(ctx context.Context, args []string, options globalOptions, stdout, stderr io.Writer) int {
fs := newCommandFlagSet("allowance")
if code, ok := parseCommandFlags(fs, args, allowanceUsage, stdout, stderr); !ok {
return code
}
if fs.NArg() != 0 {
return usageError("dbl allowance", allowanceUsage, stderr, "allowance takes no arguments")
}
c, code, ok := connect(ctx, "dbl allowance", options, false, stderr)
if !ok {
return code
}
allowance, err := c.Allowance(ctx)
if err != nil {
return reportFailure(ctx, "dbl allowance", stderr, err)
}
return emitReported(ctx, "dbl allowance", options.Output, stdout, stderr, allowance, func(w io.Writer) error {
_, err := fmt.Fprintf(w, "currency: %s (non-monetary usage units)\ngranted: %s\nreserved: %s\nconsumed: %s\nremaining: %s\npricing rule: %s\n",
allowance.Currency, allowance.Granted, allowance.Reserved, allowance.Consumed, allowance.Remaining, allowance.PricingRule)
return err
})
}
72 changes: 72 additions & 0 deletions cmd/dbl/allowance_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 ETH Zurich

package main

import (
"context"
"net/http"
"path/filepath"
"strings"
"testing"

"github.com/netsec-ethz/debuglet/internal/connections"
"github.com/netsec-ethz/debuglet/pkg/client"
)

func TestAllowanceUsesSelectedAccountAndExactUnits(t *testing.T) {
const token = "allowance-private-credential"
want := client.Allowance{Currency: "TEST", Granted: "9007199254740993", Reserved: "4", Consumed: "9007199254740991", Remaining: "-2", PricingRule: "bw-s-ceil-ms-v1"}
for _, mode := range []string{outputHuman, outputJSON} {
t.Run(mode, func(t *testing.T) {
fx := newFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/me/allowance" || r.Header.Get("Authorization") != "Bearer "+token || r.Header.Get("Debuglet-API-Version") != "1.15" {
t.Errorf("wrong allowance request: %s %s", r.Method, r.URL.Path)
}
writeJSONResponse(w, http.StatusOK, want)
}))
path := filepath.Join(t.TempDir(), "connections.json")
if err := connections.Save(path, connections.Profile{Name: "selected", Endpoint: fx.endpoint()}, true); err != nil {
t.Fatal(err)
}
if err := connections.SaveCredential(path, "selected", connections.Credential{Endpoint: fx.endpoint(), Token: token}); err != nil {
t.Fatal(err)
}
code, out, errout := runCLI(context.Background(), "--config", path, "--dispatcher", "selected", "--output", mode, "allowance")
assertCode(t, code, exitOK, out, errout)
if strings.Contains(out+errout, token) || errout != "" || fx.total() != 1 {
t.Fatalf("unexpected allowance output or requests: %q / %q / %d", out, errout, fx.total())
}
if mode == outputJSON {
got := oneJSONDocument(t, out)
if got["granted"] != want.Granted || got["remaining"] != want.Remaining || got["currency"] != "TEST" {
t.Fatalf("allowance values lost: %s", out)
}
} else if !strings.Contains(out, "granted: "+want.Granted) || !strings.Contains(out, "remaining: -2") || !strings.Contains(out, "non-monetary") {
t.Fatalf("allowance values or units missing: %s", out)
}
})
}
}

func TestAllowanceFailureDoesNotPrintABalance(t *testing.T) {
for _, tc := range []struct {
name string
status int
body any
message string
}{
{"disabled", http.StatusNotFound, map[string]string{"code": "not_found", "message": "allowances are not enabled"}, "allowances are not enabled"},
{"unauthenticated", http.StatusUnauthorized, map[string]string{"code": "unauthorized", "message": "authentication required"}, "dbl login"},
{"incomplete", http.StatusOK, client.Allowance{Currency: "TEST"}, "incomplete allowance"},
} {
t.Run(tc.name, func(t *testing.T) {
fx := newFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { writeJSONResponse(w, tc.status, tc.body) }))
code, out, errout := runCLI(context.Background(), "--endpoint", fx.endpoint(), "allowance")
assertCode(t, code, exitFailure, out, errout)
if out != "" || !strings.Contains(errout, tc.message) {
t.Fatalf("invented balance or missing diagnostic: %q / %q", out, errout)
}
})
}
}
1 change: 1 addition & 0 deletions cmd/dbl/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ Commands:
obtain and store a session credential
logout revoke and forget the stored credential
whoami show the selected session's account
allowance show the account's remaining TEST units
config --role ROLE --file FILE inspect redacted daemon configuration
doctor [--role ROLE --file FILE] [--connection]
diagnose local setup or a saved connection
Expand Down
2 changes: 2 additions & 0 deletions cmd/dbl/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ func dispatch(ctx context.Context, command string, args []string, options global
return logoutCommand(ctx, args, options, stdout, stderr)
case "whoami":
return whoamiCommand(ctx, args, options, stdout, stderr)
case "allowance":
return allowanceCommand(ctx, args, options, stdout, stderr)
case "config":
return configCommand(ctx, args, options, stdout, stderr)
case "doctor":
Expand Down
22 changes: 22 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,17 @@ Creating a `USDC` intent requires `refund_address` to be a Sui address: `0x` fol

The intent prices the batch again when it is created and returns the result as `quote` in its response, in the same shape, so a client can compare it with an earlier quote; a changed `pricing_rule` means the server's rule changed in between. An intent recovered through an explicit retry does not repeat its quote.

A quote is an immediate snapshot, with no promised validity interval, expiry
timestamp or price lock. Editing the batch invalidates the client's displayed
review; fetch a fresh quote for the exact edited batch. Before payment or run
submission, compare the intent's rule, currency, units, total and per-order
prices with that review. If they differ, show the new price and require a new
review; do not pay or submit automatically. If an intent expires before use,
discard it and request a fresh quote and intent. Quote generation and intent
creation both recheck supported policy and price; normal submission still
checks current capacity. A successful quote does not reserve capacity or
guarantee admission. There is no server-side quote token to expire or consume.

`GET /me` adds `economics`: `payment_methods` lists the methods the intent admits now (`TEST`, and `USDC` when blockchain payments are enabled), `chain_payments` whether blockchain payments are enabled and `allowances` whether usage allowances are enabled on this dispatcher (see [usage allowances](#usage-allowances-api-115)).

`GET /me/orders` pages the caller's own payment intents, newest first. An intent has no stored creation time, so the order is that of its expiry time, which is creation plus five minutes, then of its ID. `limit` is 1 to 100 (default 25); pass the `next` value of a page as `before` to read the following one, and `next` is empty on the last page. Each intent reports `id`, `method`, `status` (`outstanding`, `paid`, `refunded`, `expired`, `aborted`, or `unknown`), its recorded `price` and `currency`, `pricing_rule` (empty when the dispatcher did not record one) and `expires_at`, and each of its orders `order_id`, `executor_id`, `price`, `currency`, `settlement` (`pending`, `credited` or `refunded`, as in run detail costs) and, once admitted, `run_id`. Another account's intents are never listed; a `before` value that is not one of your intents returns an empty page.
Expand Down Expand Up @@ -134,6 +145,17 @@ API 1.10 adds authenticated `GET` and `POST /operator/executors`, plus
operations scoped to the caller's machines; they do not grant dispatcher-wide
operator privileges. Inventory includes pending and offline machines.

API 1.20 adds `admission` to each inventory entry: `ready` (subject to normal
admission checks), `maintenance` (the dispatcher has paused new submissions), or
`offline` (no ready control session, including pending enrollment). `ready` and
`status` continue to describe the control connection even during dispatcher
maintenance. `drain_status` is `unknown`: the control protocol does not observe
the executor host's service manager or completed joined shutdown. Display this
as unavailable, including when the field is absent or unrecognized; neither
offline nor maintenance proves an executor was safely drained. Confirm a drain
on the executor host with the [managed service procedure](operations/services.md).
These read-only fields do not grant remote drain or service-control authority.

`POST /executor-enrollment` exchanges a single-use setup token and a signed CSR
for a machine certificate. It uses the token in the request body rather than a
user session. The executor creates and retains its own private key. See
Expand Down
18 changes: 17 additions & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,22 @@ dbl --dispatcher research --output json whoami
expired or revoked credential exits 1 with a `dbl login` hint. Credentials are
never printed.

### Inspect the account's usage allowance

```sh
dbl --dispatcher research allowance
dbl --dispatcher research --output json allowance
```

`allowance` reads the account's granted, reserved, consumed and remaining TEST
units through API 1.15 or newer. These are non-transferable usage credits, not
money or USDC. Amounts remain exact integer strings in JSON; a negative remaining
amount from earlier usage is reported as recorded. Reading creates no grant,
reservation or run. Disabled allowances or a missing/revoked credential exit 1
with the dispatcher's diagnostic; an authentication failure includes a login hint.
Grants are issued by an operator and never renew automatically. See
[usage allowances](api.md#usage-allowances-api-115) for the accounting rules.

### Inspect daemon configuration

```sh
Expand Down Expand Up @@ -271,7 +287,7 @@ See [probe verification](verification.md).
| --- | --- |
| Run local roles | `demo`, `up`, `dispatcher up`, `executor up` |
| Manage connections | `connect`, `dispatcher list`, `dispatcher use`, `dispatcher remove` |
| Manage credentials | `login`, `logout`, `whoami` |
| Manage credentials and inspect usage | `login`, `logout`, `whoami`, `allowance` |
| Submit work | `validate`, `run`, `retry`, `rendezvous`, `cancel` |
| Read results | `nodes`, `status`, `logs`, `recovery` |
| Verify received probes | `verify` |
Expand Down
9 changes: 9 additions & 0 deletions docs/operations/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ The optional `[allowance]` section caps what authenticated accounts may reserve

Nothing is granted automatically, at sign-up or later. An operator account grants an account an amount with a reason and an idempotency key through `POST /operator/accounts/{id}/allowance`; a request without an amount grants `default_grant`, which at a price of 1 covers ten runs of 100,000 bit/s for 30 seconds. Grants are never changed, reset or renewed, and an account's ceiling is the sum of its grants. While allowances are enabled, an account's `TEST` intent is created only if its remaining allowance covers the price, otherwise it is refused with `allowance_exceeded`; the request without a credential that the local development profile admits is not capped. See [the API reference](../api.md#usage-allowances-api-115) for how reservations are counted.

The operator decides who receives a grant and records its reason. Authentication
does not establish one account per person: allowances provide no Sybil resistance
on their own. Review related accounts and prior grants before issuing additional
units; a different idempotency key intentionally creates another grant. There is
no automatic refill, hosting reward or cash redemption. Users can inspect the
recorded totals with `dbl allowance` or the SDK's `Allowance` method. Accounts
without grants cannot reserve positive-cost work when allowances are enabled;
zero-cost work still remains subject to the ordinary admission quotas.

### Dispatcher blockchain payments

The `[sui]` section configures chain payments. Every shipped configuration sets `disabled = true` and leaves the other fields empty; in that mode they are ignored and the dispatcher serves `TEST` payments only. `server.local_development = true` requires `disabled = true`. Enabling chain payments is an operator decision with its own profile and checklist; see [chain payments](payments.md).
Expand Down
Loading