From f6eca0cbd586297cc3f413f1450efdd16fa66abb Mon Sep 17 00:00:00 2001 From: Amulya Date: Fri, 31 Jul 2026 20:15:35 +0530 Subject: [PATCH 1/3] agents: pin godo to the data-plane /events build Points godo at digitalocean/godo#1072, which re-applies the two commits reverted from `OHS_endpoints`, so `StreamSession` reads both live and replay-only streams from the data plane's `/v2/agents/sessions/{id}/events` again. Vendor-only in effect: the sole diff under vendor/ is the 89 lines that the earlier pin to the released v1.202.0-beta.1 had dropped from hosted_agents.go. The next commit moves doctl's own code back onto it. Co-authored-by: Cursor --- go.mod | 2 +- go.sum | 4 +- .../digitalocean/godo/hosted_agents.go | 89 ++++++++++++++++--- vendor/modules.txt | 2 +- 4 files changed, 82 insertions(+), 15 deletions(-) diff --git a/go.mod b/go.mod index 399c070e2..ce3fd8a21 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.25.0 require ( github.com/blang/semver v3.5.1+incompatible github.com/creack/pty v1.1.21 - github.com/digitalocean/godo v1.202.0-beta.1 + github.com/digitalocean/godo v1.202.1-0.20260731143800-4a1a63adeadb github.com/docker/cli v24.0.5+incompatible github.com/docker/docker v25.0.6+incompatible github.com/docker/docker-credential-helpers v0.7.0 // indirect diff --git a/go.sum b/go.sum index 2347536fd..87f47f28d 100644 --- a/go.sum +++ b/go.sum @@ -120,8 +120,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/digitalocean/godo v1.202.0-beta.1 h1:1+wJiSwcshFgLdDHCr9MnGlnFUihox7EpY8cFxf76VA= -github.com/digitalocean/godo v1.202.0-beta.1/go.mod h1:xQsWpVCCbkDrWisHA72hPzPlnC+4W5w/McZY5ij9uvU= +github.com/digitalocean/godo v1.202.1-0.20260731143800-4a1a63adeadb h1:rBMYNfU4K4N8Uz5hiLTG2DId6k/TYA/ETsFvW9USJ2k= +github.com/digitalocean/godo v1.202.1-0.20260731143800-4a1a63adeadb/go.mod h1:xQsWpVCCbkDrWisHA72hPzPlnC+4W5w/McZY5ij9uvU= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= diff --git a/vendor/github.com/digitalocean/godo/hosted_agents.go b/vendor/github.com/digitalocean/godo/hosted_agents.go index d7c221e6a..24c10e402 100644 --- a/vendor/github.com/digitalocean/godo/hosted_agents.go +++ b/vendor/github.com/digitalocean/godo/hosted_agents.go @@ -22,7 +22,7 @@ const ( hostedAgentsSessionsBasePath = "/v2/agents/sessions" hostedAgentSessionByIDPath = hostedAgentsSessionsBasePath + "/%s" - hostedAgentSessionStreamPath = hostedAgentSessionByIDPath + "/stream" + hostedAgentSessionEventsPath = hostedAgentSessionByIDPath + "/events" hostedAgentSessionInputPath = hostedAgentSessionByIDPath + "/input" hostedAgentSessionHITLPath = hostedAgentSessionByIDPath + "/hitl/%s" hostedAgentSessionSandboxExecPath = hostedAgentSessionByIDPath + "/sandbox/exec" @@ -40,6 +40,10 @@ const ( workspaceIsArchiveHeader = "X-Workspace-Is-Archive" workspaceSizeBytesHeader = "X-Workspace-Size-Bytes" + // sseLastEventIDHeader is the standard SSE resume cursor. The data-plane + // events endpoint takes the cursor here rather than as a query parameter. + sseLastEventIDHeader = "Last-Event-ID" + // workspaceDownloadFooter is appended by OHS after a successful download // payload so integrity survives intermediaries that strip HTTP trailers // (e.g. Cloudflare). Format: DOWSSHA1 + 64 lowercase hex + '\n' = 73 bytes. @@ -208,6 +212,39 @@ const ( HostedAgentEventKindRunSandboxReleased HostedAgentEventKind = "run.sandbox_released" HostedAgentEventKindRunCostAccrued HostedAgentEventKind = "run.cost_accrued" HostedAgentEventKindRunLog HostedAgentEventKind = "run.log" + + // HostedAgentEventKindStreamState is a transport control frame, not an agent + // event: it reports the health of the SSE connection itself. Only the + // data-plane events endpoint emits it. It arrives in the same envelope as an + // event, but only SessionID, At and Payload are meaningful — decode Payload + // with HostedAgentStreamState. Renderers should skip it rather than display + // it as session activity. + HostedAgentEventKindStreamState HostedAgentEventKind = "stream.state" +) + +// HostedAgentStreamState is the payload of a HostedAgentEventKindStreamState +// frame: the current health of the SSE connection. +type HostedAgentStreamState struct { + State HostedAgentStreamStateValue `json:"state"` + Cursor string `json:"cursor,omitempty"` +} + +// HostedAgentStreamStateValue enumerates the stream health states. +type HostedAgentStreamStateValue string + +const ( + // HostedAgentStreamStateLive means events are being delivered contiguously. + HostedAgentStreamStateLive HostedAgentStreamStateValue = "live" + // HostedAgentStreamStateCatchingUp means the connection is replaying recent + // history before it joins the live tail. + HostedAgentStreamStateCatchingUp HostedAgentStreamStateValue = "catching_up" + // HostedAgentStreamStateDegraded means delivery continues with reduced + // guarantees (the server fell back to polling). + HostedAgentStreamStateDegraded HostedAgentStreamStateValue = "degraded" + // HostedAgentStreamStateSuperseded means a newer connection from the same + // device took over. The server closes this stream; the client should stop + // rather than reconnect, or the two connections will evict each other. + HostedAgentStreamStateSuperseded HostedAgentStreamStateValue = "superseded" ) // HostedAgentSessionOriginProduct identifies the product workflow that created @@ -284,7 +321,7 @@ type HostedAgentHITLDecision struct { Reason string `json:"reason,omitempty"` } -// HostedAgentEvent is one SSE payload from GET /v2/agents/sessions/{id}/stream. +// HostedAgentEvent is one SSE payload from a session stream (see StreamSession). // // The server serializes the SPI canonical event envelope, whose JSON shape // differs from this struct's field names: the discriminator is `type` (not @@ -363,8 +400,16 @@ type HostedAgentSessionsListResponse struct { } // HostedAgentSessionStreamOptions configures the session SSE stream. +// +// The two fields select between the two modes StreamSession can open on the +// events endpoint; see StreamSession. type HostedAgentSessionStreamOptions struct { + // ReplayFrom is the resume cursor: the id of the last event the caller + // already rendered. On the live stream it is sent as Last-Event-ID; on a + // ReplayOnly read it is sent as the replay_from query parameter. ReplayFrom string + // ReplayOnly reads the stored event history and ends the stream at the last + // stored event instead of holding the connection open for live events. ReplayOnly bool } @@ -664,23 +709,42 @@ func (s *HostedAgentsServiceOp) ResumeSession(ctx context.Context, sessionID str } // StreamSession opens the SSE stream for a session. Callers MUST Close the stream. +// +// Both reads are served by the data plane at GET .../sessions/{id}/events; the +// two modes differ in where the stream starts and whether it ends: +// +// - Live (the default) delivers forward-only from the moment of attach, +// preceded by whatever recent history the server decides to replay, and +// holds the connection open. ReplayFrom is sent as the standard +// Last-Event-ID header. +// - ReplayOnly adds ?replay_only=true: the server writes the session's stored +// event history and then ends the stream, so the read terminates on its own. +// ReplayFrom is sent as the replay_from query parameter, since it is an +// explicit pagination cursor here rather than a resume hint. +// +// Both carry HostedAgentEventKindStreamState control frames (see +// HostedAgentStreamState). A replay-only read reports catching_up and then +// simply ends; it never reaches live. func (s *HostedAgentsServiceOp) StreamSession(ctx context.Context, sessionID string, opt *HostedAgentSessionStreamOptions) (*HostedAgentSessionStream, *Response, error) { if sessionID == "" { return nil, nil, errors.New("hosted agents: session id is required") } - path := fmt.Sprintf(hostedAgentSessionStreamPath, sessionID) + replayOnly := opt != nil && opt.ReplayOnly + cursor := "" if opt != nil { + cursor = opt.ReplayFrom + } + + path := fmt.Sprintf(hostedAgentSessionEventsPath, sessionID) + if replayOnly { q := url.Values{} - if opt.ReplayFrom != "" { - q.Set("replay_from", opt.ReplayFrom) - } - if opt.ReplayOnly { - q.Set("replay_only", "true") - } - if encoded := q.Encode(); encoded != "" { - path += "?" + encoded + q.Set("replay_only", "true") + if cursor != "" { + q.Set("replay_from", cursor) } + path += "?" + q.Encode() } + req, err := s.client.NewRequest(ctx, http.MethodGet, path, nil) if err != nil { return nil, nil, err @@ -688,6 +752,9 @@ func (s *HostedAgentsServiceOp) StreamSession(ctx context.Context, sessionID str req.Header.Set("Accept", "text/event-stream") req.Header.Set("Cache-Control", "no-cache") req.Header.Set("Connection", "keep-alive") + if !replayOnly && cursor != "" { + req.Header.Set(sseLastEventIDHeader, cursor) + } resp, err := s.client.DoStream(ctx, req) if err != nil { diff --git a/vendor/modules.txt b/vendor/modules.txt index 57e03e36b..2a1167521 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -103,7 +103,7 @@ github.com/creack/pty # github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc ## explicit github.com/davecgh/go-spew/spew -# github.com/digitalocean/godo v1.202.0-beta.1 +# github.com/digitalocean/godo v1.202.1-0.20260731143800-4a1a63adeadb ## explicit; go 1.23.0 github.com/digitalocean/godo github.com/digitalocean/godo/metrics From fb8697331feafcc268af7e49e371facaf4822e54 Mon Sep 17 00:00:00 2001 From: Amulya Date: Fri, 31 Jul 2026 20:15:50 +0530 Subject: [PATCH 2/3] agents: read live and replay streams from the data-plane /events endpoint With godo back on `/events`, undo the three local accommodations that were made while the endpoint was unavailable. `commands/agents.go` drops its local copies of the `stream.state` kind and payload and uses godo's `HostedAgentEventKindStreamState` / `HostedAgentStreamState` again, so the wire contract lives in one place rather than being restated here. The reconnect test reads the resume cursor from the `Last-Event-ID` header instead of a `replay_from` query parameter, matching where the live lane actually carries it. `replay_from` stays the cursor for replay-only reads, which are a different lane. The agentproxy harness serves `/events` and opens every stream with a `stream.state` frame, so the codex facade tests exercise a stream shaped like the real one. The control plane's `/stream` is deliberately left unregistered: no agentproxy caller makes a replay-only read, so a request landing there is a bug worth failing on rather than quietly serving. Co-authored-by: Cursor --- commands/agents.go | 21 +++------------- commands/agents_test.go | 11 ++++---- internal/agentproxy/agentproxytest/harness.go | 25 +++++++++++-------- 3 files changed, 25 insertions(+), 32 deletions(-) diff --git a/commands/agents.go b/commands/agents.go index ee3634dff..b6e00e557 100644 --- a/commands/agents.go +++ b/commands/agents.go @@ -66,19 +66,6 @@ var ( colMuted = charm.Colors.Muted ) -// Stream-state transport frames (SSE kind "stream.state"). Defined locally so -// doctl builds against godo pins that have not yet exported HostedAgentEventKindStreamState -// / HostedAgentStreamState*. Wire values match the published godo API. -const ( - hostedAgentEventKindStreamState godo.HostedAgentEventKind = "stream.state" - hostedAgentStreamStateSuperseded = "superseded" -) - -type hostedAgentStreamState struct { - State string `json:"state"` - Cursor string `json:"cursor,omitempty"` -} - // detectStyling reports whether ANSI styling should be emitted for the current // process: stdout is a terminal and NO_COLOR is unset. func detectStyling() bool { @@ -1148,7 +1135,7 @@ func RunAgentsLogs(c *CmdConfig) error { for stream.Next() { ev := stream.Current() // Connection health, not session activity — never part of the history. - if ev.Kind == hostedAgentEventKindStreamState { + if ev.Kind == godo.HostedAgentEventKindStreamState { continue } if ev.Kind == godo.HostedAgentEventKindTokenChunk { @@ -1544,9 +1531,9 @@ func drainStream(stream *godo.HostedAgentSessionStream, out io.Writer, pending * // stream.state reports the health of the connection, not session // activity, so it never renders and never moves the cursor. - if ev.Kind == hostedAgentEventKindStreamState { - var st hostedAgentStreamState - if err := json.Unmarshal(ev.Payload, &st); err == nil && st.State == hostedAgentStreamStateSuperseded { + if ev.Kind == godo.HostedAgentEventKindStreamState { + var st godo.HostedAgentStreamState + if err := json.Unmarshal(ev.Payload, &st); err == nil && st.State == godo.HostedAgentStreamStateSuperseded { thinking.stop() acc.flush(out) flushAwaitingApproval(out, &awaiting) diff --git a/commands/agents_test.go b/commands/agents_test.go index 0bda695e6..c18b3786b 100644 --- a/commands/agents_test.go +++ b/commands/agents_test.go @@ -2182,7 +2182,7 @@ func TestStreamWithReconnect_supersededStopsWithoutReconnect(t *testing.T) { stubReconnectSleep(t) body := sseFrame("evt-1", string(godo.HostedAgentEventKindSessionUpdated), `{}`) + - sseFrame("", string(hostedAgentEventKindStreamState), `{"state":"superseded","cursor":""}`) + sseFrame("", string(godo.HostedAgentEventKindStreamState), `{"state":"superseded","cursor":""}`) srv := httptest.NewServer(hostedAgentSSEHandler(body, nil)) t.Cleanup(srv.Close) @@ -2241,9 +2241,9 @@ func TestDrainStream_HITLReattachShowsCommand(t *testing.T) { // frame is transport bookkeeping: it renders nothing and must not become the // reconnect cursor, or a reconnect would resume from a position no event holds. func TestDrainStream_skipsStreamStateControlFrames(t *testing.T) { - body := sseFrame("", string(hostedAgentEventKindStreamState), `{"state":"live","cursor":""}`) + + body := sseFrame("", string(godo.HostedAgentEventKindStreamState), `{"state":"live","cursor":""}`) + sseFrame("evt-7", string(godo.HostedAgentEventKindSessionUpdated), `{}`) + - sseFrame("", string(hostedAgentEventKindStreamState), `{"state":"catching_up","cursor":""}`) + sseFrame("", string(godo.HostedAgentEventKindStreamState), `{"state":"catching_up","cursor":""}`) srv := httptest.NewServer(hostedAgentSSEHandler(body, nil)) t.Cleanup(srv.Close) @@ -2358,8 +2358,9 @@ func TestStreamWithReconnect_replayCursorAfterMidStreamDrop(t *testing.T) { mu.Lock() calls++ n := calls - // Resume cursor rides as replay_from on control-plane /stream. - replayFrom := r.URL.Query().Get("replay_from") + // The live stream carries the resume cursor in the standard SSE + // Last-Event-ID header, not a replay_from query parameter. + replayFrom := r.Header.Get("Last-Event-ID") mu.Unlock() w.Header().Set("Content-Type", "text/event-stream") diff --git a/internal/agentproxy/agentproxytest/harness.go b/internal/agentproxy/agentproxytest/harness.go index 7cc27218a..d3d73eb61 100644 --- a/internal/agentproxy/agentproxytest/harness.go +++ b/internal/agentproxy/agentproxytest/harness.go @@ -17,10 +17,12 @@ import ( "net/http/httptest" "sync" "testing" + + "github.com/digitalocean/godo" ) // Event is one canned SSE event the harness streams back from -// GET /v2/agents/sessions/{id}/stream, matching the event-specific part of +// GET /v2/agents/sessions/{id}/events, matching the event-specific part of // godo.HostedAgentEvent's wire shape (see HostedAgentEventKind's doc comment // for the canonical type strings). type Event struct { @@ -57,7 +59,7 @@ type Harness struct { mu sync.Mutex sessionID string runID string // returned by the next POST .../input call - events []Event // streamed, in order, by the next GET .../stream call + events []Event // streamed, in order, by the next GET .../events call } // New starts the fake harness and registers its shutdown via t.Cleanup. @@ -69,8 +71,11 @@ func New(t *testing.T, sessionID string) *Harness { mux := http.NewServeMux() mux.HandleFunc("GET /v2/agents/sessions/{id}", h.handleGetSession) - // Temporarily on control-plane .../stream until OHP /events is on stage2. - mux.HandleFunc("GET /v2/agents/sessions/{id}/stream", h.handleStream) + // Live streaming is served by the data plane at .../events. The control + // plane's .../stream is deliberately not registered: it serves only + // replay-only reads, which no agentproxy caller makes, so a request landing + // there is a bug worth failing on. + mux.HandleFunc("GET /v2/agents/sessions/{id}/events", h.handleStream) mux.HandleFunc("POST /v2/agents/sessions/{id}/input", h.handleInput) mux.HandleFunc("POST /v2/agents/sessions/{id}/hitl/{requestID}", h.handleHITL) @@ -80,7 +85,7 @@ func New(t *testing.T, sessionID string) *Harness { } // QueueRun arranges for the next POST .../input call to return runID, and -// for GET .../stream to then emit events (in order, tagged with runID), +// for GET .../events to then emit events (in order, tagged with runID), // flushing after each one so a concurrent reader observes them incrementally // rather than all at once at EOF. // @@ -139,20 +144,20 @@ func (h *Harness) handleStream(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) flusher, canFlush := w.(http.Flusher) - // Optional stream.state control frame (same wire value as the data-plane - // transport). Consumers must skip it — emit it so tests exercise that. - const streamStateKind = "stream.state" + // The data plane opens every stream with a stream.state control frame. It + // belongs to no run, so consumers must skip it rather than mistake it for + // session activity — emit it here so tests exercise that. streamState, err := json.Marshal(eventWire{ TenantID: "15726539", SessionID: sessionID, Timestamp: "2026-01-01T00:00:00Z", - Type: streamStateKind, + Type: string(godo.HostedAgentEventKindStreamState), Data: json.RawMessage(`{"state":"live","cursor":""}`), }) if err != nil { panic(fmt.Sprintf("agentproxytest: stream.state does not marshal to JSON: %v", err)) } - fmt.Fprintf(w, "event: %s\ndata: %s\n\n", streamStateKind, streamState) + fmt.Fprintf(w, "event: %s\ndata: %s\n\n", godo.HostedAgentEventKindStreamState, streamState) if canFlush { flusher.Flush() } From 1375f163150f384240932e6b2f957eb670e1e230 Mon Sep 17 00:00:00 2001 From: Amulya Date: Fri, 14 Aug 2026 17:14:28 +0530 Subject: [PATCH 3/3] agents: default the agents surface to the hosted-agents host Hosted agents are fronted by their own host, which serves both the session control plane and the data-plane event stream, so reaching them meant exporting DIGITALOCEAN_API_URL by hand -- and that redirects every other doctl command along with it. Give the `doctl agents` services their own client pinned to that host, and apply caller-supplied client options ahead of the --api-url override so an endpoint the user named explicitly still wins. That is what keeps a non-production environment reachable (the preview host, for instance) and leaves the rest of doctl on api.digitalocean.com. Co-authored-by: Cursor --- commands/agents.go | 4 ++- commands/command_config.go | 13 ++++++++-- doit.go | 22 +++++++++++++--- doit_test.go | 52 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 7 deletions(-) diff --git a/commands/agents.go b/commands/agents.go index 426428d75..3ef946294 100644 --- a/commands/agents.go +++ b/commands/agents.go @@ -218,7 +218,9 @@ func Agents() *Command { A session is one long-lived agent process (Claude Code, OpenCode, ...) running inside a workspace sandbox. doctl drives it: starting it from an agent spec, attaching an interactive TUI, listing existing sessions, resolving HITL approvals out of band, and tearing it down. -Commands that act on a single session accept either the session ID or its name. A name must match exactly one session; if it is ambiguous, pass the session ID instead.`, +Commands that act on a single session accept either the session ID or its name. A name must match exactly one session; if it is ambiguous, pass the session ID instead. + +These commands talk to the hosted-agents endpoint (` + "`" + `https://ohr-agent.do-ai.run` + "`" + `) rather than ` + "`" + `https://api.digitalocean.com` + "`" + `. Pass ` + "`" + `--api-url` + "`" + ` (or set ` + "`" + `DIGITALOCEAN_API_URL` + "`" + `) to point them somewhere else.`, GroupID: hostedAgentsGroup, }, } diff --git a/commands/command_config.go b/commands/command_config.go index 44254c4dc..3f09b6e82 100644 --- a/commands/command_config.go +++ b/commands/command_config.go @@ -17,6 +17,7 @@ import ( "fmt" "io" + "github.com/digitalocean/godo" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -111,6 +112,14 @@ func NewCmdConfig(ns string, dc doctl.Config, out io.Writer, args []string, init return fmt.Errorf("Unable to initialize DigitalOcean API client: %s", err) } + // The hosted-agents surface lives on its own host rather than + // api.digitalocean.com, so it gets its own client. --api-url still + // overrides both (see doctl.HostedAgentsAPIURL). + agentsClient, err := c.Doit.GetGodoClient(Trace, true, accessToken, godo.SetBaseURL(doctl.HostedAgentsAPIURL)) + if err != nil { + return fmt.Errorf("Unable to initialize DigitalOcean Hosted Agents API client: %s", err) + } + c.Keys = func() do.KeysService { return do.NewKeysService(godoClient) } c.Sizes = func() do.SizesService { return do.NewSizesService(godoClient) } c.Regions = func() do.RegionsService { return do.NewRegionsService(godoClient) } @@ -167,9 +176,9 @@ func NewCmdConfig(ns string, dc doctl.Config, out io.Writer, args []string, init c.Nfs = func() do.NfsService { return do.NewNfsService(godoClient) } c.NfsActions = func() do.NfsActionsService { return do.NewNfsActionsService(godoClient) } c.Security = func() do.SecurityService { return do.NewSecurityService(godoClient) } - c.HostedAgents = func() do.HostedAgentsService { return do.NewHostedAgentsService(godoClient) } + c.HostedAgents = func() do.HostedAgentsService { return do.NewHostedAgentsService(agentsClient) } c.HostedAgentTriggers = func() do.HostedAgentTriggersService { - return do.NewHostedAgentTriggersService(godoClient) + return do.NewHostedAgentTriggersService(agentsClient) } c.Secrets = func() do.SecretsService { return do.NewSecretsService(godoClient) } return nil diff --git a/doit.go b/doit.go index 3df852a9f..71db6d209 100644 --- a/doit.go +++ b/doit.go @@ -47,6 +47,16 @@ import ( const ( // LatestReleaseURL is the latest release URL endpoint. LatestReleaseURL = "https://api.github.com/repos/digitalocean/doctl/releases/latest" + + // HostedAgentsAPIURL is the API endpoint the `doctl agents` commands use. + // Hosted agents are fronted by their own host, which serves both the + // session control plane (/v2/agents/...) and the data-plane event stream + // (.../events), rather than by api.digitalocean.com. + // + // An explicit --api-url (DIGITALOCEAN_API_URL) still wins, which is how a + // non-production environment is reached — the preview host, for instance, + // is https://ohr-agent.do-ai-test.run. + HostedAgentsAPIURL = "https://ohr-agent.do-ai.run/" ) // Version is the version info for doit. @@ -209,7 +219,7 @@ func (glv *GithubLatestVersioner) LatestVersion() (string, error) { // Config is an interface that represent doit's config. type Config interface { - GetGodoClient(trace, allowRetries bool, accessToken string) (*godo.Client, error) + GetGodoClient(trace, allowRetries bool, accessToken string, opts ...godo.ClientOpt) (*godo.Client, error) GetDockerEngineClient() (builder.DockerEngineClient, error) SSH(user, host, keyPath string, port int, opts ssh.Options) runner.Runner Listen(url *url.URL, token string, schemaFunc listen.SchemaFunc, out io.Writer, inCh <-chan []byte) listen.ListenerService @@ -235,8 +245,10 @@ type LiveConfig struct { var _ Config = &LiveConfig{} -// GetGodoClient returns a GodoClient. -func (c *LiveConfig) GetGodoClient(trace, allowRetries bool, accessToken string) (*godo.Client, error) { +// GetGodoClient returns a GodoClient. opts are applied before the --api-url +// override, so a caller-supplied default base URL (see HostedAgentsAPIURL) +// yields to an endpoint the user asked for explicitly. +func (c *LiveConfig) GetGodoClient(trace, allowRetries bool, accessToken string, opts ...godo.ClientOpt) (*godo.Client, error) { if accessToken == "" { return nil, fmt.Errorf("access token is required. (hint: run 'doctl auth init')") } @@ -273,6 +285,8 @@ func (c *LiveConfig) GetGodoClient(trace, allowRetries bool, accessToken string) args = append(args, godo.WithRetryAndBackoffs(retryConfig)) } + args = append(args, opts...) + apiURL := viper.GetString("api-url") if apiURL != "" { args = append(args, godo.SetBaseURL(apiURL)) @@ -535,7 +549,7 @@ func NewTestConfig() *TestConfig { // GetGodoClient mocks a GetGodoClient call. The returned godo client will // be nil. -func (c *TestConfig) GetGodoClient(trace, allowRetries bool, accessToken string) (*godo.Client, error) { +func (c *TestConfig) GetGodoClient(trace, allowRetries bool, accessToken string, opts ...godo.ClientOpt) (*godo.Client, error) { return &godo.Client{}, nil } diff --git a/doit_test.go b/doit_test.go index c4da474fc..5de702da3 100644 --- a/doit_test.go +++ b/doit_test.go @@ -17,6 +17,9 @@ import ( "os" "regexp" "testing" + + "github.com/digitalocean/godo" + "github.com/spf13/viper" ) func TestMain(m *testing.M) { @@ -128,6 +131,55 @@ func (slr stubLatestRelease) LatestVersion() (string, error) { return slr.version, nil } +// TestGetGodoClientBaseURL pins the precedence that lets the `doctl agents` +// commands default to their own host without taking the endpoint away from a +// user who named one: caller-supplied options set a default, --api-url +// overrides it. +func TestGetGodoClientBaseURL(t *testing.T) { + cases := []struct { + name string + apiURL string + opts []godo.ClientOpt + want string + }{ + { + name: "no options and no api-url leaves godo's default", + want: "https://api.digitalocean.com/", + }, + { + name: "caller-supplied base URL applies", + opts: []godo.ClientOpt{godo.SetBaseURL(HostedAgentsAPIURL)}, + want: HostedAgentsAPIURL, + }, + { + name: "api-url wins over a caller-supplied base URL", + apiURL: "https://ohr-agent.do-ai-test.run/", + opts: []godo.ClientOpt{godo.SetBaseURL(HostedAgentsAPIURL)}, + want: "https://ohr-agent.do-ai-test.run/", + }, + { + name: "api-url applies with no caller options", + apiURL: "https://example.test/", + want: "https://example.test/", + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + viper.Set("api-url", c.apiURL) + t.Cleanup(func() { viper.Set("api-url", "") }) + + client, err := (&LiveConfig{}).GetGodoClient(false, false, "fake-token", c.opts...) + if err != nil { + t.Fatalf("GetGodoClient() unexpected error: %v", err) + } + if got := client.BaseURL.String(); got != c.want { + t.Errorf("BaseURL = %q; want %q", got, c.want) + } + }) + } +} + func TestCommandName(t *testing.T) { t.Run("snap name set to goland", func(t *testing.T) { const snapName = "goland"