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
4 changes: 2 additions & 2 deletions commands/agent_triggers.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ Output:
AddStringFlag(cmdCreate, doctl.ArgAgentTriggerOutputMode, "", "none", "Output delivery mode (none|email|slack)")
AddStringFlag(cmdCreate, doctl.ArgAgentTriggerOutputEmail, "", "", "Destination email when output-mode=email")
AddStringFlag(cmdCreate, doctl.ArgAgentTriggerOutputSlackWebhook, "", "", "Slack incoming webhook URL when output-mode=slack")
AddStringFlag(cmdCreate, doctl.ArgAgentSpec, "", "", "Path to agents.yaml manifest for session-mode=fresh (\"-\" reads stdin)")
AddStringFlag(cmdCreate, doctl.ArgAgentSpec, "", "", "Path to agents.yaml manifest for session-mode=fresh (\"-\" reads stdin). ${VAR} references are resolved from the local environment at create time and stored expanded.")
AddStringFlag(cmdCreate, doctl.ArgAgentTriggerBoundSessionID, "", "", "Paused session ID for session-mode=reuse")
AddStringFlag(cmdCreate, doctl.ArgAgentTriggerProvider, "", "", "Webhook provider (github|gitlab|custom); default custom")
AddStringFlag(cmdCreate, doctl.ArgAgentTriggerCronExpr, "", "", "Cron expression when kind=cron")
Expand All @@ -104,7 +104,7 @@ Pause/re-enable via `+"`"+`--status paused|active`+"`"+`, or use the dedicated `
AddStringFlag(cmdUpdate, doctl.ArgAgentTriggerOutputMode, "", "", "Output delivery mode (none|email|slack)")
AddStringFlag(cmdUpdate, doctl.ArgAgentTriggerOutputEmail, "", "", "Destination email when output-mode=email")
AddStringFlag(cmdUpdate, doctl.ArgAgentTriggerOutputSlackWebhook, "", "", "Slack incoming webhook URL when output-mode=slack")
AddStringFlag(cmdUpdate, doctl.ArgAgentSpec, "", "", "Updated agents.yaml manifest for fresh triggers (\"-\" reads stdin)")
AddStringFlag(cmdUpdate, doctl.ArgAgentSpec, "", "", "Updated agents.yaml manifest for fresh triggers (\"-\" reads stdin). ${VAR} references are resolved from the local environment at update time and stored expanded.")
AddStringFlag(cmdUpdate, doctl.ArgAgentTriggerBoundSessionID, "", "", "Updated bound session ID for reuse triggers")
AddStringFlag(cmdUpdate, doctl.ArgAgentTriggerCronExpr, "", "", "Updated cron expression")
AddStringFlag(cmdUpdate, doctl.ArgAgentTriggerTimezone, "", "", "Updated IANA timezone")
Expand Down
48 changes: 42 additions & 6 deletions commands/agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -238,12 +238,12 @@ Commands that act on a single session accept either the session ID or its name.
"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.
The `+"`"+`--spec`+"`"+` flag is required and accepts a YAML manifest matching the `+"`"+`agents.digitalocean.com/v1alpha1`+"`"+` schema. `+"`"+`${VAR}`+"`"+` references in the manifest are resolved from your local environment before upload; referencing an unset variable is an error, and `+"`"+`$${VAR}`+"`"+` escapes to a literal `+"`"+`${VAR}`+"`"+`. The expanded manifest is then 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.ArgAgentSpec, "", "", `Path to an agent manifest in YAML or JSON. Set to "-" to read from stdin. ${VAR} references are resolved from the local environment.`, 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`

Expand Down Expand Up @@ -427,9 +427,11 @@ func RunAgentsStartProxy(c *CmdConfig) error {
})
}

// 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.
// readManifest returns the spec file as raw bytes with ${VAR} references
// resolved from the local environment (see expandManifestEnv). path "-" reads
// from stdin. Beyond env expansion, 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 {
Expand All @@ -453,7 +455,41 @@ func readManifest(stdin io.Reader, path string) ([]byte, error) {
if len(bytes.TrimSpace(raw)) == 0 {
return nil, fmt.Errorf("manifest is empty")
}
return raw, nil
return expandManifestEnv(raw)
}

// manifestEnvRef matches ${VAR} env references and their $${VAR} escape form.
// Only the strict braced form expands: bare $VAR is left alone so shell
// snippets embedded in manifests (skills instructions, prompts) survive.
var manifestEnvRef = regexp.MustCompile(`\$?\$\{[A-Za-z_][A-Za-z0-9_]*\}`)

// expandManifestEnv resolves ${VAR} references in the manifest against the
// local environment before the manifest is sent to the server. $${VAR}
// escapes to a literal ${VAR}. Referencing a variable that is not set locally
// is an error rather than a silent empty substitution, so a missing key fails
// here instead of inside the sandbox.
func expandManifestEnv(manifest []byte) ([]byte, error) {
var missing []string
seen := map[string]bool{}
out := manifestEnvRef.ReplaceAllFunc(manifest, func(m []byte) []byte {
if bytes.HasPrefix(m, []byte("$$")) {
return m[1:] // $${VAR} -> literal ${VAR}
}
name := string(m[2 : len(m)-1])
val, ok := os.LookupEnv(name)
if !ok {
if !seen[name] {
seen[name] = true
missing = append(missing, name)
}
return m
}
return []byte(val)
})
if len(missing) > 0 {
return nil, fmt.Errorf("manifest references environment variable(s) not set locally: %s (escape a literal with $${...})", strings.Join(missing, ", "))
}
return out, nil
}

// injectManifestName sets metadata.name on the manifest to name. An empty name
Expand Down
51 changes: 51 additions & 0 deletions commands/agents_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,57 @@ func TestReadManifest(t *testing.T) {
assert.Error(t, err)
assert.Contains(t, err.Error(), "empty")
})

t.Run("expands env references", func(t *testing.T) {
t.Setenv("DOCTL_TEST_API_KEY", "sk-test-123")
raw, err := readManifest(strings.NewReader("env:\n OPENAI_API_KEY: ${DOCTL_TEST_API_KEY}\n"), "-")
assert.NoError(t, err)
assert.Equal(t, "env:\n OPENAI_API_KEY: sk-test-123\n", string(raw))
})
}

func TestExpandManifestEnv(t *testing.T) {
t.Run("expands set variables", func(t *testing.T) {
t.Setenv("DOCTL_TEST_KEY", "value-1")
t.Setenv("DOCTL_TEST_OTHER", "value-2")
out, err := expandManifestEnv([]byte("a: ${DOCTL_TEST_KEY}\nb: ${DOCTL_TEST_OTHER}\n"))
assert.NoError(t, err)
assert.Equal(t, "a: value-1\nb: value-2\n", string(out))
})

t.Run("expands empty-but-set variables", func(t *testing.T) {
t.Setenv("DOCTL_TEST_EMPTY", "")
out, err := expandManifestEnv([]byte("a: '${DOCTL_TEST_EMPTY}'\n"))
assert.NoError(t, err)
assert.Equal(t, "a: ''\n", string(out))
})

t.Run("unset variable errors with its name", func(t *testing.T) {
_, err := expandManifestEnv([]byte("a: ${DOCTL_TEST_DEFINITELY_UNSET}\n"))
assert.Error(t, err)
assert.Contains(t, err.Error(), "DOCTL_TEST_DEFINITELY_UNSET")
})

t.Run("reports all missing variables once", func(t *testing.T) {
t.Setenv("DOCTL_TEST_KEY", "v")
_, err := expandManifestEnv([]byte("a: ${DOCTL_TEST_MISSING_A}\nb: ${DOCTL_TEST_KEY}\nc: ${DOCTL_TEST_MISSING_B}\nd: ${DOCTL_TEST_MISSING_A}\n"))
assert.Error(t, err)
assert.Contains(t, err.Error(), "DOCTL_TEST_MISSING_A, DOCTL_TEST_MISSING_B")
assert.Equal(t, 1, strings.Count(err.Error(), "DOCTL_TEST_MISSING_A"))
})

t.Run("escape produces a literal reference", func(t *testing.T) {
out, err := expandManifestEnv([]byte("a: $${DOCTL_TEST_DEFINITELY_UNSET}\n"))
assert.NoError(t, err)
assert.Equal(t, "a: ${DOCTL_TEST_DEFINITELY_UNSET}\n", string(out))
})

t.Run("bare dollar forms are untouched", func(t *testing.T) {
in := "script: |\n echo $HOME $1 $(pwd) ${!indirect} ${no spaces allowed}\n"
out, err := expandManifestEnv([]byte(in))
assert.NoError(t, err)
assert.Equal(t, in, string(out))
})
}

func TestRunAgentsStart(t *testing.T) {
Expand Down