Skip to content

Commit bde1e8b

Browse files
committed
feat: emit X-Wherobots-Client header with command name (WBC-182)
Set an advisory X-Wherobots-Client header on outgoing API requests so the backend can attribute traffic to the CLI and the specific command that generated it. The CLI is an ORIGIN client and emits a single hop: client=cli;ver=<buildVersion>;cmd=<command> The command is the operation's CommandPath joined with "." (e.g. job-runs.list); the ;cmd= field is omitted when CommandPath is empty. Values are sanitized so commas and semicolons can never break the hop grammar. The header is advisory only and never affects auth. The build version reaches the executor package via an exported executor.Version var (default "dev"), which main.go sets from the ldflags-injected buildVersion. This avoids an import cycle and keeps version wiring in one place. A pure buildClientHeader helper renders the value for testability. Claude-Session: https://claude.ai/code/session_01QP5saxNQTUTJpMRRxZXhzR
1 parent 00b3c11 commit bde1e8b

3 files changed

Lines changed: 131 additions & 0 deletions

File tree

internal/executor/request.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,47 @@ import (
1515
"wherobots/cli/internal/spec"
1616
)
1717

18+
// Version identifies the CLI build for the advisory X-Wherobots-Client header.
19+
// main.go overwrites it with the ldflags-injected build version; the "dev"
20+
// default keeps local and test builds working without wiring.
21+
var Version = "dev"
22+
1823
type QueryPair struct {
1924
Key string
2025
Value string
2126
}
2227

28+
// clientHeaderName is the ordered, append-only client-identification header.
29+
// The CLI is an ORIGIN client, so it emits a single hop and never appends to
30+
// an existing value. The header is advisory only and never affects auth.
31+
const clientHeaderName = "X-Wherobots-Client"
32+
33+
// clientHeaderSanitizer strips characters that would break the hop grammar
34+
// (commas separate hops, semicolons separate fields) so they can never appear
35+
// inside a value.
36+
var clientHeaderSanitizer = strings.NewReplacer(",", "_", ";", "_")
37+
38+
// buildClientHeader renders this CLI's single origin hop:
39+
//
40+
// client=cli;ver=<version>;cmd=<command>
41+
//
42+
// The cmd field is omitted when command is empty. Values are sanitized so no
43+
// comma or semicolon leaks into the grammar. An empty version falls back to
44+
// "dev" to match the package default.
45+
func buildClientHeader(version, command string) string {
46+
if version == "" {
47+
version = "dev"
48+
}
49+
var b strings.Builder
50+
b.WriteString("client=cli;ver=")
51+
b.WriteString(clientHeaderSanitizer.Replace(version))
52+
if command != "" {
53+
b.WriteString(";cmd=")
54+
b.WriteString(clientHeaderSanitizer.Replace(command))
55+
}
56+
return b.String()
57+
}
58+
2359
type HTTPError struct {
2460
StatusCode int
2561
Body []byte
@@ -243,6 +279,7 @@ func BuildRequest(
243279
req.Header.Set("Content-Type", contentType)
244280
}
245281
req.Header.Set("x-api-key", cfg.APIKey)
282+
req.Header.Set(clientHeaderName, buildClientHeader(Version, strings.Join(op.CommandPath, ".")))
246283

247284
return req, nil
248285
}

internal/executor/request_test.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,94 @@ func TestBuildRequestInjectsPathQueryBodyAndAuth(t *testing.T) {
184184
}
185185
}
186186

187+
func TestBuildRequestInjectsWherobotsClientHeader(t *testing.T) {
188+
// Not parallel: mutates the package-level Version var.
189+
prev := Version
190+
Version = "1.2.3"
191+
t.Cleanup(func() { Version = prev })
192+
193+
cfg := config.Config{APIKey: "abc123"}
194+
runtimeSpec := &spec.RuntimeSpec{BaseURL: "https://api.example.com"}
195+
op := &spec.Operation{
196+
Method: "GET",
197+
Path: "/job-runs",
198+
CommandPath: []string{"job-runs", "list"},
199+
}
200+
201+
req, err := BuildRequest(context.Background(), cfg, runtimeSpec, op, nil, nil, "")
202+
if err != nil {
203+
t.Fatalf("BuildRequest() error = %v", err)
204+
}
205+
if got, want := req.Header.Get("X-Wherobots-Client"), "client=cli;ver=1.2.3;cmd=job-runs.list"; got != want {
206+
t.Fatalf("X-Wherobots-Client = %q, want %q", got, want)
207+
}
208+
}
209+
210+
func TestBuildRequestWherobotsClientHeaderOmitsCommandWhenEmpty(t *testing.T) {
211+
// Not parallel: mutates the package-level Version var.
212+
prev := Version
213+
Version = "4.5.6"
214+
t.Cleanup(func() { Version = prev })
215+
216+
cfg := config.Config{APIKey: "abc123"}
217+
runtimeSpec := &spec.RuntimeSpec{BaseURL: "https://api.example.com"}
218+
op := &spec.Operation{Method: "GET", Path: "/job-runs"}
219+
220+
req, err := BuildRequest(context.Background(), cfg, runtimeSpec, op, nil, nil, "")
221+
if err != nil {
222+
t.Fatalf("BuildRequest() error = %v", err)
223+
}
224+
if got, want := req.Header.Get("X-Wherobots-Client"), "client=cli;ver=4.5.6"; got != want {
225+
t.Fatalf("X-Wherobots-Client = %q, want %q", got, want)
226+
}
227+
}
228+
229+
func TestBuildClientHeader(t *testing.T) {
230+
t.Parallel()
231+
232+
cases := []struct {
233+
name string
234+
version string
235+
command string
236+
want string
237+
}{
238+
{
239+
name: "version and command",
240+
version: "1.2.3",
241+
command: "job-runs.list",
242+
want: "client=cli;ver=1.2.3;cmd=job-runs.list",
243+
},
244+
{
245+
name: "empty command omits cmd",
246+
version: "1.2.3",
247+
command: "",
248+
want: "client=cli;ver=1.2.3",
249+
},
250+
{
251+
name: "empty version falls back to dev",
252+
version: "",
253+
command: "job-runs.list",
254+
want: "client=cli;ver=dev;cmd=job-runs.list",
255+
},
256+
{
257+
name: "sanitizes separators out of values",
258+
version: "1,2;3",
259+
command: "a;b,c.list",
260+
want: "client=cli;ver=1_2_3;cmd=a_b_c.list",
261+
},
262+
}
263+
264+
for _, tc := range cases {
265+
tc := tc
266+
t.Run(tc.name, func(t *testing.T) {
267+
t.Parallel()
268+
if got := buildClientHeader(tc.version, tc.command); got != tc.want {
269+
t.Fatalf("buildClientHeader(%q, %q) = %q, want %q", tc.version, tc.command, got, tc.want)
270+
}
271+
})
272+
}
273+
}
274+
187275
func TestBuildRequestMissingRequiredQueryReturnsError(t *testing.T) {
188276
t.Parallel()
189277

main.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88

99
"wherobots/cli/internal/commands"
1010
"wherobots/cli/internal/config"
11+
"wherobots/cli/internal/executor"
1112
"wherobots/cli/internal/spec"
1213
"wherobots/cli/internal/version"
1314
)
@@ -26,6 +27,11 @@ func main() {
2627
}
2728

2829
func run(ctx context.Context) error {
30+
// Surface the build version to the executor so outgoing requests carry the
31+
// advisory X-Wherobots-Client header. Set here (not via ldflags directly on
32+
// the executor var) to keep version wiring in one place.
33+
executor.Version = buildVersion
34+
2935
// Start a background update check early so it runs in parallel with setup.
3036
updateCh := version.CheckInBackground(ctx, buildVersion)
3137

0 commit comments

Comments
 (0)