Skip to content

Commit 1d80643

Browse files
authored
[SSH] Pass Codex context via developer_instructions (#7197)
## Changes Use `-c developer_instructions="..."` instead of `AGENTS.md` to pass system context to Codex ## Why This avoids issues with stale/user defined `AGENT.md` files ## Tests tests updated
1 parent 2fa09b7 commit 1d80643

4 files changed

Lines changed: 105 additions & 81 deletions

File tree

‎experimental/ssh/internal/agentlauncher/agentlauncher.go‎

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,11 @@ import (
77
"context"
88
"errors"
99
"fmt"
10+
"os"
1011
"path/filepath"
12+
"strings"
1113

14+
"github.com/BurntSushi/toml"
1215
"github.com/databricks/cli/libs/atomicfile"
1316
"github.com/databricks/cli/libs/env"
1417
"github.com/databricks/cli/libs/shellquote"
@@ -24,21 +27,48 @@ const (
2427

2528
// WorkspaceHomeEnv passes the user's workspace home to the shim for the agent context.
2629
WorkspaceHomeEnv = "DATABRICKS_WORKSPACE_HOME"
30+
31+
// ContextFile holds the Databricks session context, under RootDir.
32+
ContextFile = "agent-system-context.md"
2733
)
2834

2935
type AgentSpec struct {
3036
Name string
3137
TelemetryName protos.SshAgentShimAgent
32-
// ContextFlag passes the context file via this CLI flag (Claude's --append-system-prompt-file).
33-
ContextFlag string
34-
// ContextHomeFile writes the context to this $HOME instructions file (codex's AGENTS.md).
35-
ContextHomeFile string
38+
// ContextArg puts the system context where the agent reads it, returning the
39+
// argv to pass to the agent binary.
40+
ContextArg func(home, systemContext string) ([]string, error)
3641
}
3742

3843
// Currently limited to the agents whose Unity Gateway CLI command supports --workspace
3944
var SupportedAgents = []AgentSpec{
40-
{Name: "claude", TelemetryName: protos.SshAgentShimAgentClaudeCode, ContextFlag: "--append-system-prompt-file"},
41-
{Name: "codex", TelemetryName: protos.SshAgentShimAgentCodex, ContextHomeFile: ".codex/AGENTS.md"},
45+
{Name: "claude", TelemetryName: protos.SshAgentShimAgentClaudeCode, ContextArg: claudeContextArg},
46+
{Name: "codex", TelemetryName: protos.SshAgentShimAgentCodex, ContextArg: codexContextArg},
47+
}
48+
49+
func claudeContextArg(home, systemContext string) ([]string, error) {
50+
// Keep the scratch context file under the shim's own directory so it never
51+
// clobbers an unrelated file the user happens to have in their home.
52+
dir := filepath.Join(home, RootDir)
53+
if err := os.MkdirAll(dir, 0o755); err != nil {
54+
return nil, fmt.Errorf("failed to create %s: %w", dir, err)
55+
}
56+
f := filepath.Join(dir, ContextFile)
57+
if err := os.WriteFile(f, []byte(systemContext), 0o644); err != nil {
58+
return nil, fmt.Errorf("failed to write agent context file: %w", err)
59+
}
60+
return []string{"--append-system-prompt-file", f}, nil
61+
}
62+
63+
func codexContextArg(home, systemContext string) ([]string, error) {
64+
// The override value is parsed as TOML, so encode the multi-line context as
65+
// a TOML string rather than relying on codex's raw-string fallback.
66+
// https://github.com/openai/codex/blob/rust-v0.145.0/codex-rs/utils/cli/src/config_override.rs
67+
kv, err := toml.Marshal(map[string]string{"developer_instructions": systemContext})
68+
if err != nil {
69+
return nil, fmt.Errorf("failed to encode agent context: %w", err)
70+
}
71+
return []string{"-c", strings.TrimSpace(string(kv))}, nil
4272
}
4373

4474
func AgentByName(name string) (AgentSpec, bool) {

‎experimental/ssh/internal/agentlauncher/agentlauncher_test.go‎

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"runtime"
77
"testing"
88

9+
"github.com/BurntSushi/toml"
910
"github.com/databricks/cli/libs/env"
1011
"github.com/stretchr/testify/assert"
1112
"github.com/stretchr/testify/require"
@@ -62,20 +63,40 @@ func TestInstallContinuesPastFailure(t *testing.T) {
6263
}
6364

6465
func TestSupportedAgents(t *testing.T) {
65-
// Each agent injects context by exactly one mechanism (flag OR home file);
66-
// the two are never both set.
67-
expect := map[string]struct{ flag, home string }{
68-
"claude": {flag: "--append-system-prompt-file"},
69-
"codex": {home: ".codex/AGENTS.md"},
70-
}
71-
require.Len(t, SupportedAgents, len(expect))
72-
for name, want := range expect {
66+
for _, name := range []string{"claude", "codex"} {
7367
a, ok := AgentByName(name)
7468
require.True(t, ok)
75-
assert.Equal(t, want.flag, a.ContextFlag, "%s ContextFlag", name)
76-
assert.Equal(t, want.home, a.ContextHomeFile, "%s ContextHomeFile", name)
69+
assert.NotNil(t, a.ContextArg, "%s ContextArg", name)
7770
}
71+
assert.Len(t, SupportedAgents, 2)
7872

7973
_, ok := AgentByName("not-an-agent")
8074
assert.False(t, ok)
8175
}
76+
77+
func TestClaudeContextArg(t *testing.T) {
78+
home := t.TempDir()
79+
args, err := claudeContextArg(home, "the context")
80+
require.NoError(t, err)
81+
path := filepath.Join(home, RootDir, ContextFile)
82+
assert.Equal(t, []string{"--append-system-prompt-file", path}, args)
83+
data, err := os.ReadFile(path)
84+
require.NoError(t, err)
85+
assert.Equal(t, "the context", string(data))
86+
}
87+
88+
func TestCodexContextArg(t *testing.T) {
89+
home := t.TempDir()
90+
const systemContext = "line \"one\"\nline two"
91+
args, err := codexContextArg(home, systemContext)
92+
require.NoError(t, err)
93+
require.Len(t, args, 2)
94+
assert.Equal(t, "-c", args[0])
95+
// codex splits the override on the first '=' and parses the rest as TOML
96+
var got map[string]string
97+
require.NoError(t, toml.Unmarshal([]byte(args[1]), &got))
98+
assert.Equal(t, map[string]string{"developer_instructions": systemContext}, got)
99+
entries, err := os.ReadDir(home)
100+
require.NoError(t, err)
101+
assert.Empty(t, entries)
102+
}

‎experimental/ssh/internal/client/agentshim.go‎

Lines changed: 13 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"path/filepath"
1414
"regexp"
1515
"runtime"
16+
"slices"
1617
"strings"
1718
"time"
1819

@@ -77,7 +78,7 @@ const (
7778
workspaceHomeEnv = agentlauncher.WorkspaceHomeEnv
7879

7980
// the file holding the Databricks session context.
80-
contextFile = "agent-system-context.md"
81+
contextFile = agentlauncher.ContextFile
8182

8283
// lock file to serialize first-run toolchain setup across
8384
// concurrent SSH clients sharing this driver's $HOME.
@@ -688,45 +689,24 @@ func prepareAgentLaunch(ctx context.Context, home string, agent agentlauncher.Ag
688689
return nil, nil, categorize(protos.SshAgentShimErrorCategoryAgentContextSetupFailed, err)
689690
}
690691
argv = []string{ugExecutable, agent.Name, "--"}
691-
argv = append(argv, contextArgs...)
692-
argv = append(argv, agentArgs...)
692+
// If the user passed `--` then inject context args just before, otherwise add
693+
// them after the user's args. This is needed so we don't interfere with `ug`'s
694+
// heuristics around positional args
695+
i := slices.Index(agentArgs, "--")
696+
if i == -1 {
697+
argv = append(argv, agentArgs...)
698+
argv = append(argv, contextArgs...)
699+
} else {
700+
argv = slices.Concat(argv, agentArgs[:i], contextArgs, agentArgs[i:])
701+
}
693702
return argv, os.Environ(), nil
694703
}
695704

696705
// put the Databricks context where the agent reads it, returning any extra argv.
697706
func injectAgentContext(ctx context.Context, home string, agent agentlauncher.AgentSpec) ([]string, error) {
698707
serverless := dbr.RuntimeVersion(ctx).Type == dbr.ClusterTypeServerless
699708
systemContext := agentSystemContext(env.Get(ctx, workspaceHomeEnv), serverless)
700-
switch {
701-
case agent.ContextFlag != "":
702-
// Keep the scratch context file under the shim's own directory so it never
703-
// clobbers an unrelated file the user happens to have in their home.
704-
dir := filepath.Join(home, agentRootDir)
705-
if err := os.MkdirAll(dir, 0o755); err != nil {
706-
return nil, fmt.Errorf("failed to create %s: %w", dir, err)
707-
}
708-
f := filepath.Join(dir, contextFile)
709-
if err := os.WriteFile(f, []byte(systemContext), 0o644); err != nil {
710-
return nil, fmt.Errorf("failed to write agent context file: %w", err)
711-
}
712-
return []string{agent.ContextFlag, f}, nil
713-
case agent.ContextHomeFile != "":
714-
f := filepath.Join(home, agent.ContextHomeFile)
715-
if _, err := os.Stat(f); err == nil {
716-
return nil, nil // already present — don't clobber
717-
} else if !errors.Is(err, os.ErrNotExist) {
718-
return nil, fmt.Errorf("failed to stat %s: %w", f, err)
719-
}
720-
if err := os.MkdirAll(filepath.Dir(f), 0o755); err != nil {
721-
return nil, fmt.Errorf("failed to create %s: %w", filepath.Dir(f), err)
722-
}
723-
if err := os.WriteFile(f, []byte(systemContext), 0o644); err != nil {
724-
return nil, fmt.Errorf("failed to write %s: %w", f, err)
725-
}
726-
return nil, nil
727-
default:
728-
return nil, nil
729-
}
709+
return agent.ContextArg(home, systemContext)
730710
}
731711

732712
// fetch url to a temp file, failing unless its SHA256 matches the expected checksum.

‎experimental/ssh/internal/client/agentshim_test.go‎

Lines changed: 25 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,12 @@ import (
2020
"testing"
2121
"time"
2222

23+
"github.com/BurntSushi/toml"
2324
"github.com/databricks/cli/experimental/ssh/internal/agentlauncher"
2425

2526
"github.com/databricks/cli/libs/cmdio"
2627
"github.com/databricks/cli/libs/dbr"
28+
"github.com/databricks/cli/libs/env"
2729
"github.com/databricks/cli/libs/telemetry/protos"
2830
"github.com/databricks/databricks-sdk-go"
2931
"github.com/databricks/databricks-sdk-go/config"
@@ -360,9 +362,8 @@ func TestEnsureToolchain(t *testing.T) {
360362
func TestInjectAgentContext(t *testing.T) {
361363
claude, _ := agentlauncher.AgentByName("claude")
362364
codex, _ := agentlauncher.AgentByName("codex")
363-
noContext := agentlauncher.AgentSpec{Name: "none"} // an agent with neither mechanism
364365

365-
t.Run("flag agent writes a scratch file and returns the flag", func(t *testing.T) {
366+
t.Run("claude gets the context as a scratch file", func(t *testing.T) {
366367
home := t.TempDir()
367368
args, err := injectAgentContext(dbr.MockRuntime(t.Context(), serverlessRuntime), home, claude)
368369
require.NoError(t, err)
@@ -373,27 +374,20 @@ func TestInjectAgentContext(t *testing.T) {
373374
assert.Contains(t, string(data), "databricks ssh connect")
374375
})
375376

376-
t.Run("home-file agent writes the global instructions file, no args", func(t *testing.T) {
377+
t.Run("codex gets the context as a TOML config override", func(t *testing.T) {
377378
home := t.TempDir()
378-
args, err := injectAgentContext(dbr.MockRuntime(t.Context(), serverlessRuntime), home, codex)
379+
ctx := env.Set(dbr.MockRuntime(t.Context(), serverlessRuntime), workspaceHomeEnv, "/Workspace/Users/a\"b@example.com")
380+
args, err := injectAgentContext(ctx, home, codex)
379381
require.NoError(t, err)
380-
assert.Nil(t, args)
381-
data, err := os.ReadFile(filepath.Join(home, ".codex", "AGENTS.md"))
382-
require.NoError(t, err)
383-
assert.Contains(t, string(data), "databricks ssh connect")
384-
})
385-
386-
t.Run("home-file agent does not clobber an existing file", func(t *testing.T) {
387-
home := t.TempDir()
388-
target := filepath.Join(home, ".codex", "AGENTS.md")
389-
require.NoError(t, os.MkdirAll(filepath.Dir(target), 0o755))
390-
require.NoError(t, os.WriteFile(target, []byte("user's own instructions"), 0o644))
391-
args, err := injectAgentContext(dbr.MockRuntime(t.Context(), serverlessRuntime), home, codex)
392-
require.NoError(t, err)
393-
assert.Nil(t, args)
394-
data, err := os.ReadFile(target)
382+
require.Len(t, args, 2)
383+
assert.Equal(t, "-c", args[0])
384+
// codex splits the override on the first '=' and parses the rest as TOML
385+
var got map[string]string
386+
require.NoError(t, toml.Unmarshal([]byte(args[1]), &got))
387+
assert.Equal(t, map[string]string{"developer_instructions": agentSystemContext("/Workspace/Users/a\"b@example.com", true)}, got)
388+
entries, err := os.ReadDir(home)
395389
require.NoError(t, err)
396-
assert.Equal(t, "user's own instructions", string(data))
390+
assert.Empty(t, entries)
397391
})
398392

399393
t.Run("context describes the compute the shim runs on", func(t *testing.T) {
@@ -413,16 +407,6 @@ func TestInjectAgentContext(t *testing.T) {
413407
assert.Contains(t, string(data), c.want)
414408
}
415409
})
416-
417-
t.Run("agent without a mechanism writes nothing", func(t *testing.T) {
418-
home := t.TempDir()
419-
args, err := injectAgentContext(dbr.MockRuntime(t.Context(), serverlessRuntime), home, noContext)
420-
require.NoError(t, err)
421-
assert.Nil(t, args)
422-
entries, err := os.ReadDir(home)
423-
require.NoError(t, err)
424-
assert.Empty(t, entries)
425-
})
426410
}
427411

428412
func TestAgentSystemContext(t *testing.T) {
@@ -796,12 +780,21 @@ func TestPrepareAgentLaunch(t *testing.T) {
796780
agent, ok := agentlauncher.AgentByName("claude")
797781
require.True(t, ok)
798782

799-
t.Run("passes the context args before the user's agent args", func(t *testing.T) {
783+
t.Run("passes the context args after the user's args", func(t *testing.T) {
800784
home := t.TempDir()
801785
argv, environ, err := prepareAgentLaunch(dbr.MockRuntime(t.Context(), serverlessRuntime), home, agent, []string{"--resume"})
802786
require.NoError(t, err)
803787
require.NotEmpty(t, argv)
804-
assert.Equal(t, []string{"claude", "--", "--append-system-prompt-file", filepath.Join(home, agentRootDir, contextFile), "--resume"}, argv[1:])
788+
assert.Equal(t, []string{"claude", "--", "--resume", "--append-system-prompt-file", filepath.Join(home, agentRootDir, contextFile)}, argv[1:])
789+
assert.Equal(t, os.Environ(), environ)
790+
})
791+
792+
t.Run("passes the context args before a '--' arg", func(t *testing.T) {
793+
home := t.TempDir()
794+
argv, environ, err := prepareAgentLaunch(dbr.MockRuntime(t.Context(), serverlessRuntime), home, agent, []string{"--max-budget-usd", "1", "--", "hello there"})
795+
require.NoError(t, err)
796+
require.NotEmpty(t, argv)
797+
assert.Equal(t, []string{"claude", "--", "--max-budget-usd", "1", "--append-system-prompt-file", filepath.Join(home, agentRootDir, contextFile), "--", "hello there"}, argv[1:])
805798
assert.Equal(t, os.Environ(), environ)
806799
})
807800

0 commit comments

Comments
 (0)