-
Notifications
You must be signed in to change notification settings - Fork 489
Expand file tree
/
Copy pathagents.go
More file actions
2689 lines (2451 loc) · 88.8 KB
/
Copy pathagents.go
File metadata and controls
2689 lines (2451 loc) · 88.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/*
Copyright 2026 The Doctl Authors All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// The `doctl agents` subcommand wraps the godo HostedAgents service, which
// in turn talks to the hosted-agents Harness API. All wire types and the SSE
// iterator live in godo (hosted_agents.go); this file handles CLI plumbing,
// argument parsing, and human-readable rendering of streamed events.
package commands
import (
"bufio"
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"os/signal"
"path/filepath"
"regexp"
"strings"
"sync"
"syscall"
"time"
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/lipgloss"
"github.com/digitalocean/doctl"
"github.com/digitalocean/doctl/commands/charm"
"github.com/digitalocean/doctl/commands/displayers"
"github.com/digitalocean/doctl/do"
"github.com/digitalocean/doctl/internal/agentproxy"
"github.com/digitalocean/doctl/internal/agentproxy/codex"
"github.com/digitalocean/godo"
"github.com/muesli/termenv"
"github.com/spf13/cobra"
"golang.org/x/term"
yaml "gopkg.in/yaml.v2"
)
// stylingEnabled gates ANSI color and markdown rendering. It is flipped on by
// the interactive entrypoints (attach/logs) when stdout is a real terminal and
// NO_COLOR is unset; it stays false in unit tests and piped output so their
// results are plain and deterministic.
var stylingEnabled bool
// Agent chat palette, sourced from doctl's shared color scheme.
var (
colSuccess = charm.Colors.Success
colError = charm.Colors.Error
colWarning = charm.Colors.Warning
colHighlight = charm.Colors.Highlight
colMuted = charm.Colors.Muted
)
// detectStyling reports whether ANSI styling should be emitted for the current
// process: stdout is a terminal and NO_COLOR is unset.
func detectStyling() bool {
if os.Getenv("NO_COLOR") != "" {
return false
}
return term.IsTerminal(int(os.Stdout.Fd()))
}
// colorize applies a foreground color when styling is enabled, else returns s.
func colorize(s string, c lipgloss.Color) string {
if !stylingEnabled {
return s
}
return lipgloss.NewStyle().Foreground(c).Render(s)
}
// boldColor applies a bold foreground color when styling is enabled.
func boldColor(s string, c lipgloss.Color) string {
if !stylingEnabled {
return s
}
return lipgloss.NewStyle().Foreground(c).Bold(true).Render(s)
}
// renderMarkdown turns a markdown document into styled terminal text. With
// styling disabled (pipes, CI, unit tests) it returns the text unchanged so
// scripts keep clean, greppable output.
func renderMarkdown(text string) string {
if strings.TrimSpace(text) == "" {
return ""
}
if !stylingEnabled {
return text
}
r, err := glamour.NewTermRenderer(
glamour.WithStandardStyle("dark"),
glamour.WithColorProfile(termenv.TrueColor),
glamour.WithWordWrap(mdWrapWidth()),
// Keep line breaks the agent emits. Without this, Markdown collapses
// single newlines into spaces, so code the agent writes without a
// well-formed fence (e.g. a ```lang tag mid-sentence) gets flattened
// onto one line and becomes unreadable.
glamour.WithPreservedNewLines(),
)
if err != nil {
return text
}
out, err := r.Render(normalizeCodeFences(text))
if err != nil {
return text
}
return out
}
// normalizeCodeFences rewrites the agent's Markdown so every ``` code-fence
// marker starts on its own line, and any opening fence with an info string
// (```python) is closed by a bare ``` before end-of-message.
//
// Streamed agent output routinely glues an opening fence to the end of a
// sentence ("...straightforward.```python") and/or never emits a closing
// fence. Either mistake stops the Markdown parser from recognizing the code
// block, so the code renders as flowed plain text with its indentation
// collapsed. Detaching the fences (and balancing an odd one) lets the renderer
// syntax-highlight the block and preserve indentation verbatim.
func normalizeCodeFences(s string) string {
var b strings.Builder
b.Grow(len(s) + 16)
atLineStart := true
fences := 0
i := 0
for i < len(s) {
if strings.HasPrefix(s[i:], "```") {
if !atLineStart {
b.WriteByte('\n')
}
b.WriteString("```")
i += 3
fences++
// Copy the rest of the fence line (the info string, e.g. "python")
// verbatim; the code body starts on the following line.
for i < len(s) && s[i] != '\n' {
b.WriteByte(s[i])
i++
}
atLineStart = false
continue
}
c := s[i]
b.WriteByte(c)
atLineStart = c == '\n'
i++
}
// An odd fence count means a block was opened but never closed; add the
// missing closing fence so the parser renders it as code rather than
// swallowing the rest of the message.
if fences%2 == 1 {
if !atLineStart {
b.WriteByte('\n')
}
b.WriteString("```\n")
}
return b.String()
}
// mdWrapWidth is the word-wrap column for markdown, clamped to a readable range.
func mdWrapWidth() int {
if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && w >= 40 {
if w > 100 {
return 100
}
return w
}
return 80
}
// msgAccumulator buffers an assistant turn's streamed token deltas so the whole
// message can be rendered as markdown once it's complete, rather than emitting
// raw tokens one at a time.
type msgAccumulator struct {
buf strings.Builder
}
func (m *msgAccumulator) add(s string) { m.buf.WriteString(s) }
// flush renders the buffered message as markdown and writes it, then resets.
func (m *msgAccumulator) flush(out io.Writer) {
if m.buf.Len() == 0 {
return
}
text := m.buf.String()
m.buf.Reset()
rendered := renderMarkdown(text)
if !strings.HasSuffix(rendered, "\n") {
rendered += "\n"
}
fmt.Fprint(out, rendered)
}
// Agents creates the `doctl agents` command tree.
func Agents() *Command {
cmd := &Command{
Command: &cobra.Command{
Use: "agents",
Aliases: []string{"agent"},
Short: "Launch and manage hosted DigitalOcean agent sessions",
Long: `The ` + "`" + `doctl agents` + "`" + ` commands manage hosted coding-agent sessions running in DigitalOcean sandboxes.
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.`,
GroupID: hostedAgentsGroup,
},
}
cmdStart := CmdBuilder(cmd, RunAgentsStart, "start",
"Start a new agent session",
`Creates a new agent session from an agent manifest file and prints its session id and status.
The `+"`"+`--spec`+"`"+` flag is required and accepts a YAML manifest matching the `+"`"+`agents.digitalocean.com/v1alpha1`+"`"+` schema. The manifest is sent to the server, which owns parsing and validation.
Use `+"`"+`--name`+"`"+` to name the session (this sets the manifest's `+"`"+`metadata.name`+"`"+`). If omitted, the server auto-generates a name. The name must be unique among your team's active sessions, and once set you can reference the session by name in other commands (e.g. `+"`"+`doctl agents attach <name>`+"`"+`).`,
Writer, aliasOpt("deploy"),
displayerType(&displayers.HostedAgentSession{}))
AddStringFlag(cmdStart, doctl.ArgAgentSpec, "", "", `Path to an agent manifest in YAML or JSON. Set to "-" to read from stdin.`, requiredOpt())
AddStringFlag(cmdStart, doctl.ArgAgentName, "", "", "Name for the new session (sets the manifest's metadata.name). If omitted, the server auto-generates a name. Must be unique among your team's active sessions.")
cmdStart.Example = `doctl agents start --spec agent-spec.yaml --name my-session`
cmdStartProxy := CmdBuilder(cmd, RunAgentsStartProxy, "start-proxy",
"Run a local facade that lets a coding-agent CLI drive a hosted session",
`Starts a local WebSocket server that impersonates a coding-agent's own app-server protocol, so the unmodified CLI can attach to a hosted session as if it were a local backend.
`+"`"+`--type`+"`"+` selects which protocol to impersonate (v1: `+"`"+`codex`+"`"+` only; future agents get their own facade behind this same command, not a new command). Once `+"`"+`start-proxy`+"`"+` is listening, connect the real CLI, e.g. `+"`"+`codex --remote ws://127.0.0.1:1144`+"`"+`.
Tested against `+"`"+`codex-cli `+codex.TestedVersion+"`"+`. Codex's WS/app-server transport is officially experimental and can change without notice — re-verify the protocol capture on every codex upgrade before trusting this against a newer CLI.
Run only one of the proxy and `+"`"+`doctl agents attach`+"`"+` per session from the same machine: both stream as this device, so the newer one takes the session over and the older stops. Close one before opening the other.`,
Writer)
AddStringFlag(cmdStartProxy, doctl.ArgAgentProxyType, "", "codex", "Coding-agent protocol to impersonate (v1: codex)")
AddStringFlag(cmdStartProxy, doctl.ArgAgentProxySession, "", "", "Session ID or name to bridge to", requiredOpt())
AddIntFlag(cmdStartProxy, doctl.ArgAgentProxyPort, "", 1144, "Local port to listen on")
AddBoolFlag(cmdStartProxy, doctl.ArgAgentProxyReplay, "", false, "Replay the session's event history into the first thread on connect")
cmdStartProxy.Example = `doctl agents start-proxy --type codex --session my-session --port 1144`
cmdAttach := CmdBuilder(cmd, RunAgentsAttach, "attach <session>",
"Attach to an agent session",
`Opens an interactive line-mode TUI on an existing session. Streams events from the server and accepts typed input. If the SSE connection drops, doctl shows Reconnecting... and retries automatically (5 attempts with backoff). If reconnection fails, it prints an error and stops the stream.
When a HITL approval is pending, the prompt switches to a compact approve/reject/defer menu showing the command awaiting approval. In an interactive terminal you can move the highlight with the arrow keys and press Enter, or resolve directly with a single keystroke -- no Enter required: `+"`"+`y`+"`"+`/`+"`"+`a`+"`"+` approves, `+"`"+`n`+"`"+`/`+"`"+`r`+"`"+` rejects, `+"`"+`d`+"`"+` defers. Piped input (CI / scripts) must send the letter word (`+"`"+`yes`+"`"+`/`+"`"+`no`+"`"+`/`+"`"+`defer`+"`"+`) followed by a newline. The explicit `+"`"+`/a <request-id>`+"`"+`, `+"`"+`/r <request-id>`+"`"+`, `+"`"+`/d <request-id>`+"`"+` slash commands still work; type `+"`"+`/help`+"`"+` to see them. Ctrl-D detaches without destroying the session.`,
Writer, aliasOpt("chat"))
cmdAttach.Example = `doctl agents attach sess_abc123; doctl agents attach my-session-name`
cmdList := CmdBuilder(cmd, RunAgentsList, "list",
"List agent sessions",
`Lists agent sessions visible to the caller. Supports pagination and filtering via `+"`"+`--page-size`+"`"+`, `+"`"+`--page-token`+"`"+`, `+"`"+`--status`+"`"+`, and `+"`"+`--name`+"`"+`. When more pages exist, the next page token is printed after the table.`,
Writer, aliasOpt("ls"),
displayerType(&displayers.HostedAgentSession{}))
AddIntFlag(cmdList, doctl.ArgAgentPageSize, "", 0, "Maximum number of sessions to return per page")
AddStringFlag(cmdList, doctl.ArgAgentPageToken, "", "", "Pagination cursor from a previous list response")
AddStringFlag(cmdList, doctl.ArgAgentStatus, "", "", "Filter by session status (e.g. SESSION_STATUS_READY, SESSION_STATUS_DESTROYED)")
AddStringFlag(cmdList, doctl.ArgAgentName, "", "", "Filter by session name")
cmdList.Example = `doctl agents list --page-size 10 --status SESSION_STATUS_READY; doctl agents list --name demo-agent`
CmdBuilder(cmd, RunAgentsShow, "show <session>",
"Show a single agent session",
"Prints details of one agent session.",
Writer, aliasOpt("get"),
displayerType(&displayers.HostedAgentSession{}))
CmdBuilder(cmd, RunAgentsLogs, "logs <session>",
"Replay the full event history for a session",
"Replays the full server-side event history for a session, then exits.",
Writer)
CmdBuilder(cmd, RunAgentsApprove, "approve <session> <request-id> <approve|reject|defer>",
"Resolve a pending HITL request out of band",
"Approves, rejects, or defers a pending HITL request without attaching the interactive TUI. The resolution source is recorded as `RESOLUTION_SOURCE_OUT_OF_BAND`. Inside an attached session, the same outcomes are available as `/a`, `/r`, `/d` slash commands.",
Writer)
CmdBuilder(cmd, RunAgentsDestroy, "destroy <session>",
"Destroy an agent session",
"Tears down the workspace sandbox and removes the session.",
Writer, aliasOpt("rm"))
cmdPause := CmdBuilder(cmd, RunAgentsPause, "pause <session>",
"Pause an agent session",
"Pauses a running agent session. The sandbox is preserved and the session can be resumed later with `doctl agents resume`.",
Writer)
cmdPause.Example = `doctl agents pause sess_abc123`
cmdResume := CmdBuilder(cmd, RunAgentsResume, "resume <session>",
"Resume a paused agent session",
"Resumes a previously paused agent session.",
Writer)
cmdResume.Example = `doctl agents resume sess_abc123`
cmdUpload := CmdBuilder(cmd, RunAgentsUpload, "upload <session>",
"Upload a file into a session workspace",
`Uploads a local file (or tar archive) into the session's sandbox workspace.
`+"`"+`--workspace-path`+"`"+` is resolved inside the workspace root (`+"`"+`/workspace`+"`"+`); a path that escapes the root is rejected by the server. Pass `+"`"+`--archive`+"`"+` when the local file is a tar that the server should extract at the destination. doctl computes the SHA-256 of the payload and forwards it so the guest can verify the upload. All file sizes use the workspace transfer API (multipart upload for large payloads). Maximum size is 50 GiB.`,
Writer,
displayerType(&displayers.HostedAgentWorkspaceUpload{}))
AddStringFlag(cmdUpload, doctl.ArgAgentWorkspacePath, "", "", "Destination path inside the workspace root (/workspace)", requiredOpt())
AddStringFlag(cmdUpload, doctl.ArgAgentLocalFile, "", "", "Path to the local file to upload", requiredOpt())
AddBoolFlag(cmdUpload, doctl.ArgAgentArchive, "", false, "Treat the local file as a tar archive to extract at the destination")
cmdUpload.Example = `doctl agents upload sess_abc123 --local-file ./main.go --workspace-path src/main.go`
cmdDownload := CmdBuilder(cmd, RunAgentsDownload, "download <session>",
"Download a file from a session workspace",
`Downloads a file (or tar archive) from the session's sandbox workspace and writes it to a local destination.
`+"`"+`--workspace-path`+"`"+` is resolved inside the workspace root (`+"`"+`/workspace`+"`"+`). Pass `+"`"+`--archive`+"`"+` to download a directory as a tar archive. All file sizes use the workspace transfer API: doctl polls for a presigned download URL, fetches the object directly, and verifies SHA-256 from the transfer status. Maximum size is 50 GiB.`,
Writer)
AddStringFlag(cmdDownload, doctl.ArgAgentWorkspacePath, "", "", "Source path inside the workspace root (/workspace)", requiredOpt())
AddStringFlag(cmdDownload, doctl.ArgAgentSaveTo, "", "", "Local file path to write the download to", requiredOpt())
AddBoolFlag(cmdDownload, doctl.ArgAgentArchive, "", false, "Tar-stream the directory at the source path")
cmdDownload.Example = `doctl agents download sess_abc123 --workspace-path src/main.go --save-to ./main.go`
return cmd
}
// --- runners ----------------------------------------------------------------
// RunAgentsStart creates a new hosted agent session by uploading an agent
// manifest verbatim.
func RunAgentsStart(c *CmdConfig) error {
specPath, err := c.Doit.GetString(c.NS, doctl.ArgAgentSpec)
if err != nil {
return err
}
name, err := c.Doit.GetString(c.NS, doctl.ArgAgentName)
if err != nil {
return err
}
manifest, err := readManifest(os.Stdin, specPath)
if err != nil {
return err
}
// --name is a convenience that sets the manifest's metadata.name. When it's
// omitted the manifest is sent verbatim and the server auto-generates a name.
manifest, err = injectManifestName(manifest, name)
if err != nil {
return err
}
sess, err := c.HostedAgents().CreateSessionFromManifest(manifest)
if err != nil {
if sessionLimitErr(err) {
msg, _, _ := agentAPIError(err)
return fmt.Errorf("%s. Free a slot by destroying one: run `doctl agents list` to find a session ID, then `doctl agents destroy SESSION_ID`", strings.TrimRight(msg, "."))
}
return err
}
return c.Display(&displayers.HostedAgentSession{Sessions: []do.HostedAgentSession{*sess}})
}
// RunAgentsStartProxy runs a local WebSocket facade that impersonates a
// coding-agent's own app-server protocol, bridging an unmodified agent CLI to
// a hosted session.
func RunAgentsStartProxy(c *CmdConfig) error {
proxyType, err := c.Doit.GetString(c.NS, doctl.ArgAgentProxyType)
if err != nil {
return err
}
if proxyType != "codex" {
return fmt.Errorf("unsupported --type %q; v1 supports only \"codex\"", proxyType)
}
sessionRef, err := c.Doit.GetString(c.NS, doctl.ArgAgentProxySession)
if err != nil {
return err
}
port, err := c.Doit.GetInt(c.NS, doctl.ArgAgentProxyPort)
if err != nil {
return err
}
replay, err := c.Doit.GetBool(c.NS, doctl.ArgAgentProxyReplay)
if err != nil {
return err
}
svc := c.HostedAgents()
sessionID, err := resolveSessionRef(svc, sessionRef)
if err != nil {
return err
}
if _, err := svc.GetSession(sessionID); err != nil {
return fmt.Errorf("session %q not found: %w", sessionRef, err)
}
fmt.Fprintf(c.Out, "Proxying session %s as a codex app-server on ws://127.0.0.1:%d\n", sessionID, port)
fmt.Fprintf(c.Out, "Connect with: codex --remote ws://127.0.0.1:%d\n", port)
// SIGTERM alongside SIGINT: under a process manager or plain `kill` (not
// `-9`), only handling os.Interrupt meant the graceful-shutdown path in
// ServeListener never triggered — the process just died abruptly instead.
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
return agentproxy.Serve(ctx, port, &codex.Facade{SessionID: sessionID, Sessions: svc, Replay: replay})
}
// readManifest returns the spec file as raw bytes. path "-" reads from stdin.
// The only client-side validation is "non-empty after trim" so a stray
// `--spec /dev/null` fails fast instead of hitting the server.
func readManifest(stdin io.Reader, path string) ([]byte, error) {
var src io.Reader
if path == "-" && stdin != nil {
src = stdin
} else {
f, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("opening manifest: %s does not exist", path)
}
return nil, fmt.Errorf("opening manifest: %w", err)
}
defer f.Close()
src = f
}
raw, err := io.ReadAll(src)
if err != nil {
return nil, fmt.Errorf("reading manifest: %w", err)
}
if len(bytes.TrimSpace(raw)) == 0 {
return nil, fmt.Errorf("manifest is empty")
}
return raw, nil
}
// injectManifestName sets metadata.name on the manifest to name. An empty name
// returns the manifest unchanged so the server can auto-generate one. The
// server still owns full manifest validation (including name syntax); this only
// wires the --name convenience flag into the YAML the server parses.
func injectManifestName(manifest []byte, name string) ([]byte, error) {
if name == "" {
return manifest, nil
}
var doc map[string]any
if err := yaml.Unmarshal(manifest, &doc); err != nil {
return nil, fmt.Errorf("parsing manifest to apply --name: %w", err)
}
if doc == nil {
doc = map[string]any{}
}
// yaml.v2 decodes nested mappings as map[any]any.
meta, ok := doc["metadata"].(map[any]any)
if !ok {
meta = map[any]any{}
}
meta["name"] = name
doc["metadata"] = meta
out, err := yaml.Marshal(doc)
if err != nil {
return nil, fmt.Errorf("applying --name to manifest: %w", err)
}
return out, nil
}
// RunAgentsList lists hosted agent sessions visible to the caller.
func RunAgentsList(c *CmdConfig) error {
opt, err := agentsListOptions(c)
if err != nil {
return err
}
sessions, nextPageToken, err := c.HostedAgents().ListSessions(opt)
if err != nil {
return err
}
if err := c.Display(&displayers.HostedAgentSession{Sessions: sessions}); err != nil {
return err
}
if nextPageToken != "" {
if Output == "json" {
fmt.Fprintf(os.Stderr, "Next page token: %s\n", nextPageToken)
} else {
fmt.Fprintf(c.Out, "Next page token: %s\n", nextPageToken)
}
}
return nil
}
func agentsListOptions(c *CmdConfig) (*godo.HostedAgentSessionListOptions, error) {
pageSize, err := c.Doit.GetInt(c.NS, doctl.ArgAgentPageSize)
if err != nil {
return nil, err
}
pageToken, err := c.Doit.GetString(c.NS, doctl.ArgAgentPageToken)
if err != nil {
return nil, err
}
status, err := c.Doit.GetString(c.NS, doctl.ArgAgentStatus)
if err != nil {
return nil, err
}
name, err := c.Doit.GetString(c.NS, doctl.ArgAgentName)
if err != nil {
return nil, err
}
if pageSize == 0 && pageToken == "" && status == "" && name == "" {
return nil, nil
}
opt := &godo.HostedAgentSessionListOptions{}
if pageSize > 0 {
opt.PageSize = pageSize
}
if pageToken != "" {
opt.PageToken = pageToken
}
if status != "" {
opt.Status = godo.HostedAgentSessionStatus(status)
}
if name != "" {
opt.Name = name
}
return opt, nil
}
// sessionIDPrefix is a legacy/opaque session-ID prefix. Session IDs are
// canonically UUIDs, but we also accept this prefix defensively.
const sessionIDPrefix = "sess_"
// sessionUUIDRe matches the canonical hosted-agent session ID format (a UUID,
// e.g. 019f275e-96dc-7ea0-98bd-9ecf2a0834c3). It lets us tell an ID from a
// human-supplied session name without an API round-trip.
var sessionUUIDRe = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
// looksLikeSessionID reports whether ref is already a session ID rather than a
// name, so it can be used directly without a name lookup.
func looksLikeSessionID(ref string) bool {
return sessionUUIDRe.MatchString(ref) || strings.HasPrefix(ref, sessionIDPrefix)
}
// terminalSessionStatuses are the lifecycle states in which a session no longer
// owns its name (the name is freed for reuse). They're excluded from name
// resolution so a destroyed session can't shadow a live one that reused the name.
func isTerminalSessionStatus(s godo.HostedAgentSessionStatus) bool {
switch s {
case godo.HostedAgentSessionStatusDestroying,
godo.HostedAgentSessionStatusDestroyed,
godo.HostedAgentSessionStatusFailed:
return true
default:
return false
}
}
// humanSessionStatus renders a session status for display in plain-English
// messages, e.g. SESSION_STATUS_DESTROYED -> "destroyed". Table output
// (agents list/get) keeps the raw enum value; this is only for prose.
func humanSessionStatus(s godo.HostedAgentSessionStatus) string {
return strings.ToLower(strings.TrimPrefix(string(s), "SESSION_STATUS_"))
}
// resolveSessionRef turns a user-supplied session reference into a session ID.
// References that already look like an ID (a UUID) are returned unchanged with
// no API call, so existing scripts keep working with no added latency.
func resolveSessionRef(svc do.HostedAgentsService, ref string) (string, error) {
if ref == "" {
return "", errors.New("a session ID or name is required")
}
if looksLikeSessionID(ref) {
return ref, nil
}
sessions, _, err := svc.ListSessions(&godo.HostedAgentSessionListOptions{Name: ref})
if err != nil {
return "", fmt.Errorf("resolving session name %q: %w", ref, err)
}
// The name filter is case-insensitive server-side; mirror that here while
// keeping only exact (not fuzzy/substring) matches, and drop terminal
// sessions whose name has been freed for reuse.
live := make([]do.HostedAgentSession, 0, len(sessions))
for _, s := range sessions {
if strings.EqualFold(s.Name, ref) && !isTerminalSessionStatus(s.Status) {
live = append(live, s)
}
}
switch len(live) {
case 0:
return "", fmt.Errorf("no agent session goes by the name %q; pass a session ID or run `doctl agents list` to see available sessions", ref)
case 1:
return live[0].SessionID, nil
default:
ids := make([]string, 0, len(live))
for _, m := range live {
ids = append(ids, m.SessionID)
}
return "", fmt.Errorf("many agent sessions go by the name %q, they have the following IDs: %s", ref, strings.Join(ids, ", "))
}
}
// sessionIDArg validates that exactly one positional argument was supplied and
// resolves it (either a session ID or a session name) to a session ID.
func sessionIDArg(c *CmdConfig) (string, error) {
if err := ensureOneArg(c); err != nil {
return "", err
}
return resolveSessionRef(c.HostedAgents(), c.Args[0])
}
// RunAgentsShow prints one session.
func RunAgentsShow(c *CmdConfig) error {
sessionID, err := sessionIDArg(c)
if err != nil {
return err
}
sess, err := c.HostedAgents().GetSession(sessionID)
if err != nil {
return err
}
return c.Display(&displayers.HostedAgentSession{Sessions: []do.HostedAgentSession{*sess}})
}
// RunAgentsDestroy tears down a session.
func RunAgentsDestroy(c *CmdConfig) error {
sessionID, err := sessionIDArg(c)
if err != nil {
return err
}
if err := c.HostedAgents().DestroySession(sessionID); err != nil {
return err
}
notice("Session %s destroyed", sessionID)
return nil
}
// RunAgentsPause pauses a session.
func RunAgentsPause(c *CmdConfig) error {
sessionID, err := sessionIDArg(c)
if err != nil {
return err
}
if err := c.HostedAgents().PauseSession(sessionID); err != nil {
return err
}
notice("Session %s paused", sessionID)
return nil
}
// RunAgentsResume resumes a paused session.
func RunAgentsResume(c *CmdConfig) error {
sessionID, err := sessionIDArg(c)
if err != nil {
return err
}
if err := c.HostedAgents().ResumeSession(sessionID); err != nil {
return err
}
notice("Session %s resumed", sessionID)
return nil
}
// maxWorkspaceTransferBytes is the hard cap for workspace transfers (OHS contract).
// Tests may lower this to avoid allocating a 50 GiB file on disk.
var maxWorkspaceTransferBytes int64 = 50 << 30 // 50 GiB
// workspaceObjectHTTPClient performs direct PUT/GET against presigned object
// URLs returned by the staged transfer APIs. No client timeout so large
// transfers are not cut off by an arbitrary deadline.
var workspaceObjectHTTPClient = &http.Client{}
// workspaceTransferPollInterval is how often GetTransfer is polled after commit
// (upload) or create (download). Tests may shorten this.
var workspaceTransferPollInterval = time.Second
// RunAgentsUpload sends a local file (or tar archive) into a session's
// workspace sandbox via the workspace transfer API. The SHA-256 of the payload
// is computed up front and forwarded so the guest can verify what it received.
func RunAgentsUpload(c *CmdConfig) error {
sessionID, err := sessionIDArg(c)
if err != nil {
return err
}
workspacePath, err := c.Doit.GetString(c.NS, doctl.ArgAgentWorkspacePath)
if err != nil {
return err
}
localFile, err := c.Doit.GetString(c.NS, doctl.ArgAgentLocalFile)
if err != nil {
return err
}
isArchive, err := c.Doit.GetBool(c.NS, doctl.ArgAgentArchive)
if err != nil {
return err
}
info, err := os.Stat(localFile)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("opening upload file: %s does not exist", localFile)
}
return fmt.Errorf("opening upload file: %w", err)
}
if info.Size() > maxWorkspaceTransferBytes {
return fmt.Errorf("upload file exceeds the workspace transfer limit of 50 GiB (%d bytes)", maxWorkspaceTransferBytes)
}
f, err := os.Open(localFile)
if err != nil {
return fmt.Errorf("opening upload file: %w", err)
}
defer f.Close()
// Hash the payload before sending so integrity can be verified end-to-end;
// rewind afterward so the same bytes stream as the body / parts.
sum, err := hashFile(f)
if err != nil {
return fmt.Errorf("hashing upload file: %w", err)
}
if _, err := f.Seek(0, io.SeekStart); err != nil {
return fmt.Errorf("rewinding upload file: %w", err)
}
return workspaceTransferUpload(c, sessionID, workspacePath, f, info.Size(), isArchive, sum)
}
// hashFile returns the hex-encoded SHA-256 of r, reading it to EOF.
func hashFile(r io.Reader) (string, error) {
h := sha256.New()
if _, err := io.Copy(h, r); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
// workspaceTransferUpload implements upload via /workspace/transfers:
// CreateTransfer → per-part CreatePartUploadURL + PUT → CommitTransfer → poll GetTransfer.
func workspaceTransferUpload(c *CmdConfig, sessionID, workspacePath string, f *os.File, size int64, isArchive bool, sha256hex string) error {
svc := c.HostedAgents()
create, err := svc.CreateWorkspaceTransfer(sessionID, &godo.HostedAgentWorkspaceTransferCreateRequest{
Direction: godo.HostedAgentWorkspaceTransferDirectionUpload,
Path: workspacePath,
IsArchive: isArchive,
SizeBytes: size,
SHA256: sha256hex,
})
if err != nil {
return err
}
if create.PartSize <= 0 {
return fmt.Errorf("workspace transfer returned invalid part_size %d", create.PartSize)
}
transferID := create.TransferID
cancel := func(reason string) {
_, _ = svc.CancelWorkspaceTransfer(sessionID, transferID, &godo.HostedAgentWorkspaceTransferCancelRequest{Reason: reason})
}
var offset int64
for partNumber := 1; offset < size; partNumber++ {
partLen := create.PartSize
if remaining := size - offset; remaining < partLen {
partLen = remaining
}
section := io.NewSectionReader(f, offset, partLen)
uploadURL, err := workspacePartUploadURL(svc, sessionID, transferID, partNumber)
if err != nil {
cancel("failed to obtain part upload URL")
return fmt.Errorf("obtaining upload URL for part %d: %w", partNumber, err)
}
if err := putWorkspaceObject(uploadURL, section, partLen); err != nil {
// URL may have expired; refresh once and retry the same part.
uploadURL, retryErr := workspacePartUploadURL(svc, sessionID, transferID, partNumber)
if retryErr != nil {
cancel("part upload failed")
return fmt.Errorf("uploading part %d: %w (refresh URL: %v)", partNumber, err, retryErr)
}
if _, seekErr := section.Seek(0, io.SeekStart); seekErr != nil {
cancel("part upload failed")
return fmt.Errorf("rewinding part %d: %w", partNumber, seekErr)
}
if retryPut := putWorkspaceObject(uploadURL, section, partLen); retryPut != nil {
cancel("part upload failed")
return fmt.Errorf("uploading part %d: %w", partNumber, retryPut)
}
}
offset += partLen
}
if _, err := svc.CommitWorkspaceTransfer(sessionID, transferID, &godo.HostedAgentWorkspaceTransferCommitRequest{
SHA256: sha256hex,
}); err != nil {
cancel("commit failed")
return fmt.Errorf("committing workspace upload: %w", err)
}
xfer, err := pollWorkspaceTransfer(svc, sessionID, transferID)
if err != nil {
return err
}
written := xfer.BytesWritten
if written == 0 {
written = size
}
return c.Display(&displayers.HostedAgentWorkspaceUpload{Uploads: []*godo.HostedAgentWorkspaceUploadResponse{{
Path: workspacePath,
BytesWritten: written,
}}})
}
// workspacePartUploadURL mints a presigned PUT URL for a single 1-based part.
func workspacePartUploadURL(svc do.HostedAgentsService, sessionID, transferID string, partNumber int) (string, error) {
out, err := svc.CreateWorkspaceTransferPartUploadURLs(sessionID, transferID, &godo.HostedAgentWorkspaceTransferPartUploadURLsRequest{
PartNumbers: []int{partNumber},
})
if err != nil {
return "", err
}
for _, part := range out.PartURLs {
if part.PartNumber == partNumber && part.UploadURL != "" {
return part.UploadURL, nil
}
}
return "", fmt.Errorf("part upload URLs response missing part %d", partNumber)
}
// putWorkspaceObject PUTs part bytes to a presigned object URL.
func putWorkspaceObject(uploadURL string, body io.Reader, contentLength int64) error {
req, err := http.NewRequest(http.MethodPut, uploadURL, body)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/octet-stream")
req.ContentLength = contentLength
resp, err := workspaceObjectHTTPClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("presigned upload returned HTTP %d", resp.StatusCode)
}
return nil
}
// pollWorkspaceTransfer waits until a staged transfer reaches a terminal status.
func pollWorkspaceTransfer(svc do.HostedAgentsService, sessionID, transferID string) (*godo.HostedAgentWorkspaceTransfer, error) {
for {
xfer, err := svc.GetWorkspaceTransfer(sessionID, transferID)
if err != nil {
return nil, err
}
switch xfer.Status {
case godo.HostedAgentWorkspaceTransferStatusCompleted:
return xfer, nil
case godo.HostedAgentWorkspaceTransferStatusFailed:
msg := xfer.ErrorMessage
if msg == "" {
msg = "unknown error"
}
return nil, fmt.Errorf("workspace transfer failed: %s", msg)
default:
time.Sleep(workspaceTransferPollInterval)
}
}
}
// RunAgentsDownload fetches a file (or tar archive) from a session workspace via
// the workspace transfer API: CreateTransfer → poll GetTransfer for download_url
// + sha256 → GET the presigned URL → verify digest. Bytes are written to a
// temporary file first and only moved into place once verification succeeds; a
// failed transfer is discarded.
func RunAgentsDownload(c *CmdConfig) error {
sessionID, err := sessionIDArg(c)
if err != nil {
return err
}
workspacePath, err := c.Doit.GetString(c.NS, doctl.ArgAgentWorkspacePath)
if err != nil {
return err
}
saveTo, err := c.Doit.GetString(c.NS, doctl.ArgAgentSaveTo)
if err != nil {
return err
}
asArchive, err := c.Doit.GetBool(c.NS, doctl.ArgAgentArchive)
if err != nil {
return err
}
written, err := workspaceTransferDownload(c.HostedAgents(), sessionID, workspacePath, saveTo, asArchive)
if err != nil {
return err
}
notice("Downloaded %d bytes to %s", written, saveTo)
return nil
}
// workspaceTransferDownload implements download via /workspace/transfers:
// CreateTransfer → poll GetTransfer → GET download_url → verify sha256.
func workspaceTransferDownload(svc do.HostedAgentsService, sessionID, workspacePath, saveTo string, asArchive bool) (int64, error) {
create, err := svc.CreateWorkspaceTransfer(sessionID, &godo.HostedAgentWorkspaceTransferCreateRequest{
Direction: godo.HostedAgentWorkspaceTransferDirectionDownload,
Path: workspacePath,
AsArchive: asArchive,
})
if err != nil {
return 0, err
}
xfer, err := pollWorkspaceTransfer(svc, sessionID, create.TransferID)
if err != nil {
return 0, err
}
if xfer.DownloadURL == "" {
return 0, fmt.Errorf("workspace transfer completed without a download_url")
}
return downloadWorkspaceObject(xfer.DownloadURL, saveTo, xfer.SHA256)
}
// downloadWorkspaceObject GETs a presigned URL into saveTo and optionally
// verifies the SHA-256 digest from GetTransfer (no DOWSSHA1 body footer).
func downloadWorkspaceObject(downloadURL, saveTo, wantSHA256 string) (int64, error) {
req, err := http.NewRequest(http.MethodGet, downloadURL, nil)
if err != nil {
return 0, err
}
resp, err := workspaceObjectHTTPClient.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
io.Copy(io.Discard, resp.Body)
return 0, fmt.Errorf("presigned download returned HTTP %d", resp.StatusCode)
}
dir := filepath.Dir(saveTo)
tmp, err := os.CreateTemp(dir, ".doctl-download-*")
if err != nil {
return 0, fmt.Errorf("creating temp file: %w", err)
}
tmpName := tmp.Name()
cleanup := func() {
tmp.Close()
os.Remove(tmpName)
}
h := sha256.New()
written, copyErr := io.Copy(tmp, io.TeeReader(resp.Body, h))
if copyErr != nil {
cleanup()
return 0, fmt.Errorf("downloading workspace file: %w", copyErr)
}
if err := tmp.Close(); err != nil {
os.Remove(tmpName)
return 0, fmt.Errorf("flushing download: %w", err)
}
if wantSHA256 != "" {
got := hex.EncodeToString(h.Sum(nil))
if !strings.EqualFold(got, wantSHA256) {
os.Remove(tmpName)
return 0, fmt.Errorf("workspace download checksum mismatch: got %s, want %s", got, wantSHA256)
}
}
if err := os.Rename(tmpName, saveTo); err != nil {
os.Remove(tmpName)
return 0, fmt.Errorf("saving download to %s: %w", saveTo, err)
}
return written, nil
}
// RunAgentsApprove resolves a pending HITL request out of band.
func RunAgentsApprove(c *CmdConfig) error {
if len(c.Args) < 3 {
return doctl.NewMissingArgsErr(c.NS)
}
if len(c.Args) > 3 {
return doctl.NewTooManyArgsErr(c.NS)
}
sessionID, err := resolveSessionRef(c.HostedAgents(), c.Args[0])
if err != nil {