Skip to content

Commit cf28ae7

Browse files
authored
Add platform headers (X-Agent-OS, X-Agent-Arch) to update check requests (#164)
- Create internal/httpclient package with AgentTransport RoundTripper - AgentTransport injects X-Agent-Version, X-Agent-OS, X-Agent-Arch headers - Remove currentVersion parameter from Check() method (breaking change) - Add ErrUnsupportedPlatform error for 400 responses - Log unsupported platform at WARN level instead of ERROR - Update main.go to use httpclient.NewClient() This allows the control plane to serve the correct binary for the agent's OS/architecture combination during self-updates.
1 parent 32e8309 commit cf28ae7

File tree

8 files changed

+245
-44
lines changed

8 files changed

+245
-44
lines changed

AGENTS.md

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1 @@
1-
- ALWAYS USE PARALLEL TASKS SUBAGENTS FOR CODE EXPLORATION, INVESTIGATION, DEEP DIVES
2-
- Use all tools available to keep current context window as small as possible
3-
- When reading files, DELEGATE to subagents, if possible
4-
- In plan mode, be bias to delegate to subagents
5-
- Use question tool more frequently
61
- Use jj instead of git
7-
- ALWAYS FOLLOW TDD, red phase to green phase
8-
- Use ripgrep instead of grep, use fd instead of find
9-
10-
## Usage of question tool
11-
12-
Before any kind of implementation, interview me in detail using the question tool.
13-
14-
Ask about technical implementation, UI/UX, edge cases, concerns, and tradeoffs.
15-
Don't ask obvious questions, dig into the hard parts I might not have considered.
16-
17-
Keep interviewing until we've covered everything.
18-
19-
## Tests
20-
21-
- Test actual behavior, not the implementation
22-
- Only test implementation when there is a technical limit to simulating the behavior

app/jobs/selfupdatejob/selfupdatejob.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ package selfupdatejob
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
67
"path/filepath"
8+
"runtime"
79
"sync"
810
"time"
911

@@ -27,7 +29,7 @@ type TriggerFunc func(context.Context, func() error)
2729

2830
// UpdateCheckerInterface abstracts the update check client.
2931
type UpdateCheckerInterface interface {
30-
Check(currentVersion string) (*updatecheck.UpdateInfo, error)
32+
Check() (*updatecheck.UpdateInfo, error)
3133
}
3234

3335
// DownloaderInterface abstracts the download and verify functionality.
@@ -125,8 +127,13 @@ func (j *SelfUpdateJob) Shutdown() {
125127
// runUpdate performs a single update check and apply cycle.
126128
func (j *SelfUpdateJob) runUpdate(ctx context.Context) error {
127129
// Step 1: Check for updates
128-
info, err := j.config.UpdateChecker.Check(j.config.CurrentVersion)
130+
info, err := j.config.UpdateChecker.Check()
129131
if err != nil {
132+
// Log unsupported platform at WARN level and return nil (not an error condition)
133+
if errors.Is(err, updatecheck.ErrUnsupportedPlatform) {
134+
log.Warnf("update check failed: unsupported platform %s/%s", runtime.GOOS, runtime.GOARCH)
135+
return nil
136+
}
130137
return fmt.Errorf("update check failed: %w", err)
131138
}
132139
if !info.UpdateAvailable {

app/jobs/selfupdatejob/selfupdatejob_test.go

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,30 @@ func TestUpdateFlow_SkipsWhenNoUpdate(t *testing.T) {
206206
}
207207
}
208208

209+
func TestUpdateFlow_UnsupportedPlatform_ReturnsNilError(t *testing.T) {
210+
// When the control plane returns 400 (unsupported platform),
211+
// runUpdate should log WARN and return nil (not an error).
212+
checker := &mockUpdateChecker{
213+
err: updatecheck.ErrUnsupportedPlatform,
214+
}
215+
downloader := &mockDownloader{}
216+
217+
job := NewWithConfig(SelfUpdateJobConfig{
218+
UpdateChecker: checker,
219+
Downloader: downloader,
220+
CurrentVersion: "1.0.0",
221+
})
222+
223+
err := job.runUpdate(context.Background())
224+
225+
if err != nil {
226+
t.Errorf("expected nil error for unsupported platform, got %v", err)
227+
}
228+
if downloader.callCount.Load() > 0 {
229+
t.Error("downloader should not be called when platform unsupported")
230+
}
231+
}
232+
209233
func TestUpdateFlow_SkipsWhenPreflightFails(t *testing.T) {
210234
checker := &mockUpdateChecker{
211235
result: &updatecheck.UpdateInfo{
@@ -1112,7 +1136,7 @@ type mockUpdateChecker struct {
11121136
callCount atomic.Int32
11131137
}
11141138

1115-
func (m *mockUpdateChecker) Check(currentVersion string) (*updatecheck.UpdateInfo, error) {
1139+
func (m *mockUpdateChecker) Check() (*updatecheck.UpdateInfo, error) {
11161140
m.callCount.Add(1)
11171141
return m.result, m.err
11181142
}

app/services/updatecheck/updatecheck.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ import (
77
"net/http"
88
)
99

10+
// ErrUnsupportedPlatform is returned when the control plane returns 400,
11+
// indicating no binary exists for the agent's OS/architecture combination.
12+
var ErrUnsupportedPlatform = errors.New("unsupported platform")
13+
1014
// UpdateInfo represents the response from the update check endpoint.
1115
type UpdateInfo struct {
1216
UpdateAvailable bool `json:"update_available"`
@@ -43,16 +47,15 @@ func New(client *http.Client, controlPlaneURL, agentID string, signer RequestSig
4347
}
4448

4549
// Check queries the control plane for available updates.
46-
func (c *UpdateChecker) Check(currentVersion string) (*UpdateInfo, error) {
50+
// Agent headers (X-Agent-Version, X-Agent-OS, X-Agent-Arch) are set by the HTTP transport.
51+
func (c *UpdateChecker) Check() (*UpdateInfo, error) {
4752
url := fmt.Sprintf("%s/api/v1/agents/%s/update", c.controlPlaneURL, c.agentID)
4853

4954
req, err := http.NewRequest(http.MethodGet, url, nil)
5055
if err != nil {
5156
return nil, fmt.Errorf("failed to create request: %w", err)
5257
}
5358

54-
req.Header.Set("X-Agent-Version", currentVersion)
55-
5659
if c.signer != nil {
5760
if err := c.signer.SignRequest(req); err != nil {
5861
return nil, fmt.Errorf("failed to sign request: %w", err)
@@ -65,6 +68,9 @@ func (c *UpdateChecker) Check(currentVersion string) (*UpdateInfo, error) {
6568
}
6669
defer resp.Body.Close()
6770

71+
if resp.StatusCode == http.StatusBadRequest {
72+
return nil, fmt.Errorf("update check returned status %d: %w", resp.StatusCode, ErrUnsupportedPlatform)
73+
}
6874
if resp.StatusCode != http.StatusOK {
6975
return nil, fmt.Errorf("update check returned status %d", resp.StatusCode)
7076
}

app/services/updatecheck/updatecheck_test.go

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package updatecheck
22

33
import (
44
"encoding/json"
5+
"errors"
56
"net/http"
67
"net/http/httptest"
78
"testing"
@@ -23,7 +24,7 @@ func TestCheck_UpdateAvailable(t *testing.T) {
2324
defer server.Close()
2425

2526
checker := newTestChecker(t, server.Client(), server.URL, nil)
26-
info, err := checker.Check("1.0.0")
27+
info, err := checker.Check()
2728
if err != nil {
2829
t.Fatalf("unexpected error: %v", err)
2930
}
@@ -54,7 +55,7 @@ func TestCheck_NoUpdateAvailable(t *testing.T) {
5455
defer server.Close()
5556

5657
checker := newTestChecker(t, server.Client(), server.URL, nil)
57-
info, err := checker.Check("2.0.0")
58+
info, err := checker.Check()
5859
if err != nil {
5960
t.Fatalf("unexpected error: %v", err)
6061
}
@@ -65,7 +66,7 @@ func TestCheck_NoUpdateAvailable(t *testing.T) {
6566

6667
func TestCheck_NetworkError(t *testing.T) {
6768
checker := newTestChecker(t, http.DefaultClient, "http://localhost:1", nil)
68-
_, err := checker.Check("1.0.0")
69+
_, err := checker.Check()
6970
if err == nil {
7071
t.Fatal("expected error for bad URL, got nil")
7172
}
@@ -82,7 +83,7 @@ func TestCheck_SignsRequest(t *testing.T) {
8283

8384
signer := &mockSigner{agentID: "agent-123"}
8485
checker := newTestChecker(t, server.Client(), server.URL, signer)
85-
_, err := checker.Check("1.0.0")
86+
_, err := checker.Check()
8687
if err != nil {
8788
t.Fatalf("unexpected error: %v", err)
8889
}
@@ -101,23 +102,25 @@ func TestCheck_SignsRequest(t *testing.T) {
101102
}
102103
}
103104

104-
func TestCheck_SendsCurrentVersionAsHeader(t *testing.T) {
105-
var receivedVersion string
105+
func TestCheck_MakesRequest(t *testing.T) {
106+
// Note: X-Agent-Version, X-Agent-OS, X-Agent-Arch headers are now set by the transport.
107+
// Header verification is done in internal/httpclient tests.
108+
var requestMade bool
106109
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
107-
receivedVersion = r.Header.Get("X-Agent-Version")
110+
requestMade = true
108111
resp := UpdateInfo{UpdateAvailable: false}
109112
json.NewEncoder(w).Encode(resp)
110113
}))
111114
defer server.Close()
112115

113116
checker := newTestChecker(t, server.Client(), server.URL, nil)
114-
_, err := checker.Check("1.5.3")
117+
_, err := checker.Check()
115118
if err != nil {
116119
t.Fatalf("unexpected error: %v", err)
117120
}
118121

119-
if receivedVersion != "1.5.3" {
120-
t.Errorf("expected X-Agent-Version header 1.5.3, got %s", receivedVersion)
122+
if !requestMade {
123+
t.Error("expected request to be made")
121124
}
122125
}
123126

@@ -131,7 +134,7 @@ func TestCheck_NoQueryParams(t *testing.T) {
131134
defer server.Close()
132135

133136
checker := newTestChecker(t, server.Client(), server.URL, nil)
134-
checker.Check("1.5.3")
137+
checker.Check()
135138

136139
if receivedRawQuery != "" {
137140
t.Errorf("expected no query params, got %s", receivedRawQuery)
@@ -145,20 +148,38 @@ func TestCheck_HTTPErrorStatus(t *testing.T) {
145148
defer server.Close()
146149

147150
checker := newTestChecker(t, server.Client(), server.URL, nil)
148-
_, err := checker.Check("1.0.0")
151+
_, err := checker.Check()
149152
if err == nil {
150153
t.Fatal("expected error for 500 status, got nil")
151154
}
152155
}
153156

157+
func TestCheck_UnsupportedPlatform_Returns400(t *testing.T) {
158+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
159+
w.WriteHeader(http.StatusBadRequest)
160+
}))
161+
defer server.Close()
162+
163+
checker := newTestChecker(t, server.Client(), server.URL, nil)
164+
_, err := checker.Check()
165+
if err == nil {
166+
t.Fatal("expected error for 400 status, got nil")
167+
}
168+
169+
// Verify we get the specific ErrUnsupportedPlatform error
170+
if !errors.Is(err, ErrUnsupportedPlatform) {
171+
t.Errorf("expected ErrUnsupportedPlatform, got %v", err)
172+
}
173+
}
174+
154175
func TestCheck_InvalidJSON(t *testing.T) {
155176
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
156177
w.Write([]byte("not json"))
157178
}))
158179
defer server.Close()
159180

160181
checker := newTestChecker(t, server.Client(), server.URL, nil)
161-
_, err := checker.Check("1.0.0")
182+
_, err := checker.Check()
162183
if err == nil {
163184
t.Fatal("expected error for invalid JSON, got nil")
164185
}
@@ -174,7 +195,7 @@ func TestCheck_UsesGETMethod(t *testing.T) {
174195
defer server.Close()
175196

176197
checker := newTestChecker(t, server.Client(), server.URL, nil)
177-
checker.Check("1.0.0")
198+
checker.Check()
178199

179200
if receivedMethod != http.MethodGet {
180201
t.Errorf("expected GET method, got %s", receivedMethod)
@@ -194,7 +215,7 @@ func TestCheck_UsesCorrectPath(t *testing.T) {
194215
if err != nil {
195216
t.Fatalf("unexpected error: %v", err)
196217
}
197-
checker.Check("1.0.0")
218+
checker.Check()
198219

199220
if receivedPath != "/api/v1/agents/agent-123/update" {
200221
t.Errorf("expected path /api/v1/agents/agent-123/update, got %s", receivedPath)

internal/httpclient/transport.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Package httpclient provides HTTP client utilities with agent identification headers.
2+
package httpclient
3+
4+
import (
5+
"net/http"
6+
"runtime"
7+
"time"
8+
9+
"hostlink/version"
10+
)
11+
12+
// AgentTransport wraps an http.RoundTripper and injects agent identification headers.
13+
type AgentTransport struct {
14+
Base http.RoundTripper
15+
}
16+
17+
// RoundTrip implements http.RoundTripper.
18+
func (t *AgentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
19+
// Clone request to avoid mutating the original
20+
clone := req.Clone(req.Context())
21+
22+
clone.Header.Set("X-Agent-Version", version.Version)
23+
clone.Header.Set("X-Agent-OS", runtime.GOOS)
24+
clone.Header.Set("X-Agent-Arch", runtime.GOARCH)
25+
26+
base := t.Base
27+
if base == nil {
28+
base = http.DefaultTransport
29+
}
30+
return base.RoundTrip(clone)
31+
}
32+
33+
// NewClient returns an *http.Client configured with AgentTransport and the specified timeout.
34+
func NewClient(timeout time.Duration) *http.Client {
35+
return &http.Client{
36+
Transport: &AgentTransport{},
37+
Timeout: timeout,
38+
}
39+
}

0 commit comments

Comments
 (0)