Skip to content

Commit 7003f47

Browse files
committed
feat/batch-changes: harden codingAgent v1 port
Three real follow-ups on top of the initial v1 port, all surfaced while porting feedback from sourcegraph/sourcegraph#12503: - Stop emitting SRC_BATCHES_MODEL_PROVIDER_TOKEN in src-cli logs. The forwarded token is now redacted in StepStarted UI metadata (JSON-lines) and in the 'full command' debug log, but still passed verbatim to the docker -e flags so the agent CLI inside the container can use it. Server-side RedactedValues stays as a backstop. - Reject v3 codingAgent steps that omit image: at parse time, instead of failing later in the executor with an empty-image error. - Harden the codex install script: extract into a mktemp dir and mv into the install path so a failed/retried install can't leave a half-written binary behind, and assert that the installed binary's --version contains pinnedVersion (catches a stale binary surviving from a prior failed install). Also adds tests for the new redaction helpers, Step.MarshalJSON canonicalization, and the new codingAgent-requires-image validation.
1 parent 7612fb3 commit 7003f47

5 files changed

Lines changed: 170 additions & 12 deletions

File tree

internal/batches/executor/run_steps.go

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -338,7 +338,7 @@ func executeSingleStep(
338338
// ----------
339339
// EXECUTION
340340
// ----------
341-
opts.UI.StepStarted(stepIdx+1, runScript, env)
341+
opts.UI.StepStarted(stepIdx+1, runScript, redactSensitiveEnv(env))
342342

343343
workspaceOpts, err := workspace.DockerRunOpts(ctx, workDir)
344344
if err != nil {
@@ -424,7 +424,7 @@ func executeSingleStep(
424424
}
425425

426426
opts.Logger.Logf("[Step %d] run: %q, container: %q", stepIdx+1, step.Run, step.Container)
427-
opts.Logger.Logf("[Step %d] full command: %q", stepIdx+1, strings.Join(cmd.Args, " "))
427+
opts.Logger.Logf("[Step %d] full command: %q", stepIdx+1, strings.Join(redactSensitiveArgs(cmd.Args), " "))
428428

429429
// Start the command.
430430
t0 := time.Now()
@@ -619,6 +619,46 @@ func forwardCodingAgentEnv(globalEnv []string, stepEnv map[string]string) {
619619
}
620620
}
621621

622+
// sensitiveEnvKeys names env vars that get passed verbatim into the user
623+
// container but must be scrubbed from UI sinks and log lines.
624+
var sensitiveEnvKeys = map[string]struct{}{
625+
codingagenttypes.ModelProviderTokenEnvVar: {},
626+
}
627+
628+
const redactedPlaceholder = "REDACTED"
629+
630+
func redactSensitiveEnv(env map[string]string) map[string]string {
631+
out := make(map[string]string, len(env))
632+
for k, v := range env {
633+
if _, sensitive := sensitiveEnvKeys[k]; sensitive && v != "" {
634+
out[k] = redactedPlaceholder
635+
} else {
636+
out[k] = v
637+
}
638+
}
639+
return out
640+
}
641+
642+
// redactSensitiveArgs scrubs the value side of `-e KEY=VALUE` pairs whose
643+
// KEY is sensitive, returning a copy of args suitable for logging.
644+
func redactSensitiveArgs(args []string) []string {
645+
out := make([]string, len(args))
646+
copy(out, args)
647+
for i := 0; i+1 < len(out); i++ {
648+
if out[i] != "-e" {
649+
continue
650+
}
651+
key, _, ok := strings.Cut(out[i+1], "=")
652+
if !ok {
653+
continue
654+
}
655+
if _, sensitive := sensitiveEnvKeys[key]; sensitive {
656+
out[i+1] = key + "=" + redactedPlaceholder
657+
}
658+
}
659+
return out
660+
}
661+
622662
// writeRunScriptFile writes a pre-rendered run script (e.g. a codingAgent
623663
// step desugared by the codingagent registry) verbatim to a temp file, with
624664
// the same permission semantics as createRunScriptFile. Unlike that helper,

internal/batches/executor/run_steps_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,57 @@
11
package executor
22

33
import (
4+
"slices"
5+
"strings"
46
"testing"
57

68
codingagenttypes "github.com/sourcegraph/sourcegraph/lib/batches/codingagent/types"
79
)
810

11+
func TestRedactSensitiveEnv(t *testing.T) {
12+
in := map[string]string{
13+
codingagenttypes.ModelProviderTokenEnvVar: "tok-abc",
14+
codingagenttypes.JobIDEnvVar: "job-123",
15+
"PATH": "/bin",
16+
}
17+
out := redactSensitiveEnv(in)
18+
if got := out[codingagenttypes.ModelProviderTokenEnvVar]; got != redactedPlaceholder {
19+
t.Errorf("token: got %q want %q", got, redactedPlaceholder)
20+
}
21+
if got := out[codingagenttypes.JobIDEnvVar]; got != "job-123" {
22+
t.Errorf("job id should not be redacted: got %q", got)
23+
}
24+
if got := out["PATH"]; got != "/bin" {
25+
t.Errorf("PATH should not be redacted: got %q", got)
26+
}
27+
if in[codingagenttypes.ModelProviderTokenEnvVar] != "tok-abc" {
28+
t.Errorf("input must not be mutated")
29+
}
30+
}
31+
32+
func TestRedactSensitiveArgs(t *testing.T) {
33+
in := []string{
34+
"docker", "run",
35+
"-e", codingagenttypes.ModelProviderTokenEnvVar + "=tok-abc",
36+
"-e", codingagenttypes.JobIDEnvVar + "=job-123",
37+
"-e", "PATH=/bin",
38+
"--", "image:tag", "/script",
39+
}
40+
out := redactSensitiveArgs(in)
41+
if slices.Contains(out, codingagenttypes.ModelProviderTokenEnvVar+"=tok-abc") {
42+
t.Errorf("token value still present in args: %v", out)
43+
}
44+
if !slices.Contains(out, codingagenttypes.ModelProviderTokenEnvVar+"="+redactedPlaceholder) {
45+
t.Errorf("token not redacted in args: %v", out)
46+
}
47+
if !slices.Contains(out, codingagenttypes.JobIDEnvVar+"=job-123") {
48+
t.Errorf("job id should pass through: %v", out)
49+
}
50+
if strings.Contains(strings.Join(out, " "), "tok-abc") {
51+
t.Errorf("token leaked anywhere in joined args: %v", out)
52+
}
53+
}
54+
955
// TestForwardCodingAgentEnv verifies that the model-provider auth env vars
1056
// placed on the v1 CliStep by the Sourcegraph server are forwarded into the
1157
// user container env for codingAgent steps.

lib/batches/batch_spec.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,10 @@ func parseBatchSpec(schema string, data []byte) (*BatchSpec, error) {
226226
if step.CodingAgent != nil && step.Run != "" {
227227
errs = errors.Append(errs, NewValidationError(errors.Newf("step %d: codingAgent and run cannot be combined in the same step", i+1)))
228228
}
229+
if step.CodingAgent != nil && step.Container == "" {
230+
// v3 image: is mirrored into Container above.
231+
errs = errors.Append(errs, NewValidationError(errors.Newf("step %d: codingAgent step requires an image", i+1)))
232+
}
229233
}
230234

231235
return &spec, errs

lib/batches/batch_spec_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,37 @@
11
package batches
22

33
import (
4+
"encoding/json"
5+
"strings"
46
"testing"
57
)
68

9+
// Step JSON must always emit `container` (never `image`) so prep-side cache
10+
// keys computed by marshaling Step match src-cli's serialization.
11+
func TestStep_MarshalJSON_canonicalizesImageToContainer(t *testing.T) {
12+
v3FromImage, err := json.Marshal(Step{Image: "alpine:3", Run: "echo hi"})
13+
if err != nil {
14+
t.Fatalf("marshal v3-shaped step: %v", err)
15+
}
16+
v1FromContainer, err := json.Marshal(Step{Container: "alpine:3", Run: "echo hi"})
17+
if err != nil {
18+
t.Fatalf("marshal v1-shaped step: %v", err)
19+
}
20+
if string(v3FromImage) != string(v1FromContainer) {
21+
t.Errorf("canonical JSON differs:\n v3 image: %s\n v1 container: %s", v3FromImage, v1FromContainer)
22+
}
23+
var out map[string]any
24+
if err := json.Unmarshal(v3FromImage, &out); err != nil {
25+
t.Fatalf("unmarshal: %v", err)
26+
}
27+
if _, ok := out["image"]; ok {
28+
t.Errorf("expected no image key in canonical JSON, got %s", v3FromImage)
29+
}
30+
if got, _ := out["container"].(string); got != "alpine:3" {
31+
t.Errorf("container: got %v want alpine:3 (full=%s)", out["container"], v3FromImage)
32+
}
33+
}
34+
735
// TestParseBatchSpec_v3_imageMirroredToContainer ensures that in v3 specs the
836
// step-level `image:` field is mirrored into Step.Container so executor
937
// consumers that read step.Container keep working.
@@ -39,6 +67,36 @@ changesetTemplate:
3967
}
4068
}
4169

70+
// A v3 codingAgent step without image: must be rejected at parse time so
71+
// it doesn't fail later with an opaque empty-image error in the executor.
72+
func TestParseBatchSpec_v3_codingAgentRequiresImage(t *testing.T) {
73+
spec := []byte(`
74+
version: 3
75+
name: test
76+
description: test
77+
on:
78+
- repository: github.com/sourcegraph/sourcegraph
79+
steps:
80+
- codingAgent:
81+
type: codex
82+
prompt: do the thing
83+
changesetTemplate:
84+
title: test
85+
body: test
86+
branch: test
87+
commit:
88+
message: test
89+
`)
90+
_, err := ParseBatchSpec(spec)
91+
if err == nil {
92+
t.Fatal("expected validation error, got nil")
93+
}
94+
if !strings.Contains(err.Error(), "requires an image") &&
95+
!strings.Contains(err.Error(), "Must validate") {
96+
t.Errorf("error should mention missing image, got: %v", err)
97+
}
98+
}
99+
42100
// TestParseBatchSpec_v3_codingAgentStep ensures that a v3 spec with a
43101
// codingAgent step parses with the expected typed step.
44102
func TestParseBatchSpec_v3_codingAgentStep(t *testing.T) {

lib/batches/codex/codex.go

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,21 +26,31 @@ func (Agent) Type() batcheslib.CodingAgentType { return batchesli
2626
func (Agent) ModelProviderRoutes() []types.ModelProviderRoute { return routes }
2727
func (Agent) ImageRequirements() []string { return []string{"curl", "tar"} }
2828

29-
// InstallScript installs codex from GitHub Releases into types.InstallDir.
29+
// InstallScript installs codex at pinnedVersion into types.InstallDir.
3030
func (Agent) InstallScript() string {
3131
return fmt.Sprintf(`_install_dir=%s
3232
_version=%s
33-
_codex_arch=$(uname -m)
34-
case "$_codex_arch" in
35-
x86_64) _codex_triple=x86_64-unknown-linux-musl ;;
36-
aarch64) _codex_triple=aarch64-unknown-linux-musl ;;
37-
*) echo "codingAgent codex: unsupported architecture: $_codex_arch" >&2; exit 1 ;;
33+
34+
case "$(uname -m)" in
35+
x86_64) _triple=x86_64-unknown-linux-musl ;;
36+
aarch64) _triple=aarch64-unknown-linux-musl ;;
37+
*) echo "codingAgent codex: unsupported architecture $(uname -m)" >&2; exit 1 ;;
3838
esac
39+
40+
# Stage in a temp dir and mv into place so a failed retry can't leave a half-written binary behind.
41+
_url="https://github.com/openai/codex/releases/download/rust-v${_version}/codex-${_triple}.tar.gz"
42+
_tmp=$(mktemp -d "${TMPDIR:-/tmp}/sg-codex.XXXXXX")
3943
mkdir -p "$_install_dir"
40-
curl -fsSL "https://github.com/openai/codex/releases/download/rust-v${_version}/codex-${_codex_triple}.tar.gz" | tar -xz -C "$_install_dir"
41-
mv "$_install_dir/codex-${_codex_triple}" "$_install_dir/codex"
42-
chmod +x "$_install_dir/codex"
43-
"$_install_dir/codex" --version >/dev/null || { echo "codingAgent codex: install verification failed (cannot exec $_install_dir/codex)" >&2; exit 1; }
44+
curl -fsSL "$_url" | tar -xz -C "$_tmp" || { echo "codingAgent codex: download/extract failed: $_url" >&2; exit 1; }
45+
chmod +x "$_tmp/codex-${_triple}"
46+
mv -f "$_tmp/codex-${_triple}" "$_install_dir/codex"
47+
rm -rf "$_tmp"
48+
49+
_actual=$("$_install_dir/codex" --version 2>&1) || { echo "codingAgent codex: cannot exec $_install_dir/codex: $_actual" >&2; exit 1; }
50+
case "$_actual" in
51+
*"$_version"*) ;;
52+
*) echo "codingAgent codex: version mismatch: want $_version, got: $_actual" >&2; exit 1 ;;
53+
esac
4454
`, types.InstallDir, pinnedVersion)
4555
}
4656

0 commit comments

Comments
 (0)