Skip to content
Draft
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
3 changes: 3 additions & 0 deletions cmd/root/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,9 @@ func (b *remoteBackend) CreateSession(ctx context.Context, _ *teamloader.LoadRes
sessTemplate := session.New(
session.WithToolsApproved(req.ToolsApproved),
session.WithSafetyPolicy(req.SafetyPolicy),
// WorkingDir is intentionally not sent: the client checkout is not the
// remote server's workspace. The server establishes its own workspace
// provenance for the session it creates (see server.SessionManager).
)

sess, err := client.CreateSession(ctx, sessTemplate)
Expand Down
14 changes: 10 additions & 4 deletions cmd/root/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,17 @@ func sessionDBPath(flagValue string) string {
return filepath.Join(paths.GetDataDir(), "session.db")
}

func setupWorkingDirectory(workingDir string) error {
if workingDir == "" {
// setupWorkingDirectory applies a --working-dir override: it chdirs into the
// directory and stores the absolute path back into runConfig.WorkingDir so
// downstream consumers (session workspace provenance, toolsets, servers) see
// an absolute root — after the chdir a relative flag value would otherwise
// resolve against itself.
func setupWorkingDirectory(runConfig *config.RuntimeConfig) error {
if runConfig.WorkingDir == "" {
return nil
}

absWd, err := filepath.Abs(workingDir)
absWd, err := filepath.Abs(runConfig.WorkingDir)
if err != nil {
return fmt.Errorf("invalid working directory: %w", err)
}
Expand All @@ -79,6 +84,7 @@ func setupWorkingDirectory(workingDir string) error {
}

_ = os.Setenv("PWD", absWd)
runConfig.WorkingDir = absWd
slog.Debug("Working directory set", "path", absWd)

return nil
Expand Down Expand Up @@ -164,7 +170,7 @@ func addGatewayFlags(cmd *cobra.Command, runConfig *config.RuntimeConfig, loadUs
runConfig.Providers = userCfg.GetProviders()
}

return setupWorkingDirectory(runConfig.WorkingDir)
return setupWorkingDirectory(runConfig)
}
}

Expand Down
28 changes: 28 additions & 0 deletions cmd/root/flags_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -409,3 +409,31 @@ func TestEnvFromFileErrorsAbortPreRun(t *testing.T) {
assert.Contains(t, err.Error(), "bad.env")
})
}

// TestSetupWorkingDirectory_StoresAbsolutePath pins that a relative
// --working-dir value is absolutized back into the runtime config: after the
// chdir, downstream consumers (session workspace provenance, servers) must
// see the absolute root, not a path relative to itself.
func TestSetupWorkingDirectory_StoresAbsolutePath(t *testing.T) {
base := t.TempDir()
base, err := filepath.EvalSymlinks(base)
require.NoError(t, err)
require.NoError(t, os.Mkdir(filepath.Join(base, "sub"), 0o755))
t.Chdir(base)

runConfig := &config.RuntimeConfig{Config: config.Config{WorkingDir: "sub"}}
require.NoError(t, setupWorkingDirectory(runConfig))

assert.Equal(t, filepath.Join(base, "sub"), runConfig.WorkingDir)
cwd, err := os.Getwd()
require.NoError(t, err)
cwd, err = filepath.EvalSymlinks(cwd)
require.NoError(t, err)
assert.Equal(t, filepath.Join(base, "sub"), cwd)
}

func TestSetupWorkingDirectory_EmptyIsNoop(t *testing.T) {
runConfig := &config.RuntimeConfig{}
require.NoError(t, setupWorkingDirectory(runConfig))
assert.Empty(t, runConfig.WorkingDir)
}
7 changes: 7 additions & 0 deletions cmd/root/new.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,17 @@ func (f *newFlags) runNewCommand(cmd *cobra.Command, args []string) (commandErr
}

var appOpts []app.Opt
// The creator runs in the user's checkout and writes the generated agent
// YAML there; capture that workspace as the session's provenance.
workingDir, err := session.CaptureLocalWorkingDir(f.runConfig.WorkingDir)
if err != nil {
return err
}
sessOpts := []session.Opt{
session.WithTitle("New agent"),
session.WithMaxIterations(f.maxIterationsParam),
session.WithToolsApproved(true),
session.WithWorkingDir(workingDir),
}
if len(args) > 0 {
arg := strings.Join(args, " ")
Expand Down
8 changes: 8 additions & 0 deletions cmd/root/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -1272,6 +1272,14 @@ func (f *runExecFlags) scopedSafetyDefault(safety latestcfg.SafetyMode, legacyYo
// createSessionSpawner creates a function that can spawn new sessions with different working directories.
func (f *runExecFlags) createSessionSpawner(agentSource config.Source, sessStore session.Store) tui.SessionSpawner {
return func(spawnCtx context.Context, workingDir string) (*app.App, *session.Session, func(), error) {
// The spawn dialog may hand us a relative or empty path; pin the
// spawned session's workspace provenance to an absolute root now,
// before anything below captures it.
workingDir, err := session.CaptureLocalWorkingDir(workingDir)
if err != nil {
return nil, nil, nil, err
}

// Create a copy of the runtime config with the new working directory
runConfigCopy := f.runConfig.Clone()
runConfigCopy.WorkingDir = workingDir
Expand Down
4 changes: 2 additions & 2 deletions e2e/mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ func TestMCP_SingleAgent(t *testing.T) {
require.NoError(t, team.StopToolSets(ctx))
})

handler := mcp.CreateToolHandler(team, "root", session.SafetyPolicyAutonomous)
handler := mcp.CreateToolHandler(team, "root", session.SafetyPolicyAutonomous, t.TempDir())
_, output, err := handler(ctx, nil, mcp.ToolInput{
Message: "What is 2+2? Answer in one sentence.",
})
Expand All @@ -50,7 +50,7 @@ func TestMCP_MultiAgent(t *testing.T) {
require.NoError(t, team.StopToolSets(ctx))
})

handler := mcp.CreateToolHandler(team, "web", session.SafetyPolicyAutonomous)
handler := mcp.CreateToolHandler(team, "web", session.SafetyPolicyAutonomous, t.TempDir())
_, output, err := handler(ctx, nil, mcp.ToolInput{
Message: "Say hello in one sentence.",
})
Expand Down
8 changes: 3 additions & 5 deletions pkg/a2a/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"fmt"
"iter"
"log/slog"
"os"
"strings"

"go.opentelemetry.io/otel"
Expand All @@ -29,7 +28,7 @@ import (
// newDockerAgentAdapter creates a new ADK agent adapter from a docker agent team and agent name.
// When agentName is empty, the team's default agent (one explicitly named "root" if it
// exists, otherwise the first agent declared) is used.
func newDockerAgentAdapter(t *team.Team, agentName string, sessStore session.Store, safety servesafety.Resolved) (agent.Agent, error) {
func newDockerAgentAdapter(t *team.Team, agentName string, sessStore session.Store, safety servesafety.Resolved, workingDir string) (agent.Agent, error) {
a, err := t.AgentOrDefault(agentName)
if err != nil {
return nil, fmt.Errorf("failed to get agent %s: %w", agentName, err)
Expand All @@ -42,13 +41,13 @@ func newDockerAgentAdapter(t *team.Team, agentName string, sessStore session.Sto
Name: agentName,
Description: desc,
Run: func(ctx agent.InvocationContext) iter.Seq2[*adksession.Event, error] {
return runDockerAgent(ctx, t, agentName, a, sessStore, safety)
return runDockerAgent(ctx, t, agentName, a, sessStore, safety, workingDir)
},
})
}

// runDockerAgent executes a docker agent and returns ADK session events
func runDockerAgent(ctx agent.InvocationContext, t *team.Team, agentName string, a *dagent.Agent, sessStore session.Store, safety servesafety.Resolved) iter.Seq2[*adksession.Event, error] {
func runDockerAgent(ctx agent.InvocationContext, t *team.Team, agentName string, a *dagent.Agent, sessStore session.Store, safety servesafety.Resolved, workingDir string) iter.Seq2[*adksession.Event, error] {
return func(yield func(*adksession.Event, error) bool) {
// Decorate the inbound `a2a.message` SERVER span (created by
// otelhttp.NewHandler in server.go) with the GenAI semconv
Expand Down Expand Up @@ -94,7 +93,6 @@ func runDockerAgent(ctx agent.InvocationContext, t *team.Team, agentName string,
yield(nil, fmt.Errorf("check A2A context ID: %w", err))
return
default:
workingDir, _ := os.Getwd()
sess = session.New(
session.WithID(sessionID),
session.WithOrigin("a2a"),
Expand Down
18 changes: 8 additions & 10 deletions pkg/a2a/adapter_run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"context"
"errors"
"io"
"os"
"slices"
"sync"
"sync/atomic"
Expand Down Expand Up @@ -175,14 +174,16 @@ func (s *recordingStore) updatedSessions() []*session.Session {
return slices.Clone(s.updated)
}

const testWorkspaceRoot = "/srv/a2a-workspace"

type yieldedEvent struct {
event *adksession.Event
err error
}

func collectRunEvents(ctx agent.InvocationContext, tm *team.Team, a *dagent.Agent, store session.Store, policy session.SafetyPolicy) []yieldedEvent {
var out []yieldedEvent
for ev, err := range runDockerAgent(ctx, tm, a.Name(), a, store, servesafety.Resolved{Policy: policy}) {
for ev, err := range runDockerAgent(ctx, tm, a.Name(), a, store, servesafety.Resolved{Policy: policy}, testWorkspaceRoot) {
out = append(out, yieldedEvent{event: ev, err: err})
}
return out
Expand Down Expand Up @@ -272,7 +273,7 @@ func TestRunDockerAgent_ConsumerStopsEarly(t *testing.T) {
ctx := newFakeInvocationContext(t.Context(), "a2a-ctx-early-stop", "Hi")

var events []*adksession.Event
for ev, err := range runDockerAgent(ctx, tm, root.Name(), root, store, servesafety.Resolved{Policy: session.SafetyPolicyRestricted}) {
for ev, err := range runDockerAgent(ctx, tm, root.Name(), root, store, servesafety.Resolved{Policy: session.SafetyPolicyRestricted}, testWorkspaceRoot) {
require.NoError(t, err)
events = append(events, ev)
break
Expand All @@ -291,7 +292,7 @@ func TestRunDockerAgent_EndedInvocationStopsIteration(t *testing.T) {
ctx := newFakeInvocationContext(t.Context(), "a2a-ctx-ended", "Hi")

var events []*adksession.Event
for ev, err := range runDockerAgent(ctx, tm, root.Name(), root, store, servesafety.Resolved{Policy: session.SafetyPolicyRestricted}) {
for ev, err := range runDockerAgent(ctx, tm, root.Name(), root, store, servesafety.Resolved{Policy: session.SafetyPolicyRestricted}, testWorkspaceRoot) {
require.NoError(t, err)
events = append(events, ev)
// Ending the invocation after the first chunk must stop the
Expand Down Expand Up @@ -324,12 +325,9 @@ func TestRunDockerAgent_NewSessionUsesA2ASettings(t *testing.T) {
assert.False(t, sess.ToolsApproved)
assert.True(t, sess.NonInteractive)

// runDockerAgent stamps new sessions with the process working directory
// via os.Getwd, so this assertion resolves the same value and relies on
// nothing in the test process changing directories.
workingDir, err := os.Getwd()
require.NoError(t, err)
assert.Equal(t, workingDir, sess.WorkingDir)
// runDockerAgent receives the server workspace at startup, so tests use a
// fixed value rather than reading the process working directory.
assert.Equal(t, testWorkspaceRoot, sess.WorkingDir)

msgs := sess.GetAllMessages()
require.NotEmpty(t, msgs)
Expand Down
4 changes: 2 additions & 2 deletions pkg/a2a/adapter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ func TestNewDockerAgentAdapter(t *testing.T) {
require.NoError(t, team.StopToolSets(t.Context()))
}()

adapter, err := newDockerAgentAdapter(team, "root", nil, servesafety.Resolved{Policy: session.SafetyPolicyRestricted})
adapter, err := newDockerAgentAdapter(team, "root", nil, servesafety.Resolved{Policy: session.SafetyPolicyRestricted}, "/srv/a2a-workspace")

require.NoError(t, err)
assert.Equal(t, "root", adapter.Name())
Expand All @@ -45,7 +45,7 @@ func TestNewCAgentAdapter_NonExistent(t *testing.T) {
require.NoError(t, team.StopToolSets(t.Context()))
}()

_, err = newDockerAgentAdapter(team, "nonexistent", nil, servesafety.Resolved{Policy: session.SafetyPolicyRestricted})
_, err = newDockerAgentAdapter(team, "nonexistent", nil, servesafety.Resolved{Policy: session.SafetyPolicyRestricted}, "/srv/a2a-workspace")

assert.Contains(t, err.Error(), "failed to get agent")
}
Expand Down
11 changes: 8 additions & 3 deletions pkg/a2a/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,12 @@ func Run(ctx context.Context, agentFilename, agentName, sessionDB string, runCon
baseURL := &url.URL{Scheme: "http", Host: routableAddr(ln.Addr().String())}
slog.DebugContext(ctx, "A2A server listening", "url", baseURL.String())

e, err := newServer(t, agentFilename, agentName, sessStore, resolvedSafety, ln.Addr().String(), options)
workingDir, err := session.CaptureLocalWorkingDir(runConfig.WorkingDir)
if err != nil {
return err
}

e, err := newServer(t, agentFilename, agentName, sessStore, resolvedSafety, workingDir, ln.Addr().String(), options)
if err != nil {
return fmt.Errorf("failed to create A2A server: %w", err)
}
Expand All @@ -118,8 +123,8 @@ func Run(ctx context.Context, agentFilename, agentName, sessionDB string, runCon
return nil
}

func newServer(t *team.Team, agentFilename, agentName string, sessStore session.Store, safety servesafety.Resolved, listenAddr string, options RunOptions) (*echo.Echo, error) {
adkAgent, err := newDockerAgentAdapter(t, agentName, sessStore, safety)
func newServer(t *team.Team, agentFilename, agentName string, sessStore session.Store, safety servesafety.Resolved, workingDir, listenAddr string, options RunOptions) (*echo.Echo, error) {
adkAgent, err := newDockerAgentAdapter(t, agentName, sessStore, safety, workingDir)
if err != nil {
return nil, err
}
Expand Down
2 changes: 1 addition & 1 deletion pkg/a2a/server_invoke_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ func startInvokeServer(t *testing.T, tm *team.Team, store session.Store, safety

ln, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", "127.0.0.1:0")
require.NoError(t, err)
e, err := newServer(tm, "test.yaml", "root", store, safety, ln.Addr().String(), RunOptions{})
e, err := newServer(tm, "test.yaml", "root", store, safety, testWorkspaceRoot, ln.Addr().String(), RunOptions{})
require.NoError(t, err)
go func() { _ = e.Server.Serve(ln) }()
t.Cleanup(func() { require.NoError(t, e.Server.Close()) })
Expand Down
2 changes: 1 addition & 1 deletion pkg/a2a/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ func TestServerSecurity(t *testing.T) {

tm := team.New(team.WithAgents(agent.New("root", "test")))
store := session.NewInMemorySessionStore()
server, err := newServer(tm, "test.yaml", "root", store, servesafety.Resolved{}, "127.0.0.1:0", RunOptions{AuthToken: "secret", CORSOrigin: "https://app.example.com"})
server, err := newServer(tm, "test.yaml", "root", store, servesafety.Resolved{}, testWorkspaceRoot, "127.0.0.1:0", RunOptions{AuthToken: "secret", CORSOrigin: "https://app.example.com"})
require.NoError(t, err)

request := func(method, path string, headers map[string]string) *httptest.ResponseRecorder {
Expand Down
2 changes: 2 additions & 0 deletions pkg/acp/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,8 @@ func (a *Agent) NewSession(ctx context.Context, params acp.NewSessionRequest) (a
// An empty cwd is allowed: clients (e.g. zed) may not always supply a
// working directory at session creation. We persist it as empty and
// later prompts/tools fall back to the agent's default working dir.
// The persisted WorkingDir stays empty too: workspace provenance must
// come from the client, never be inferred from the server's process cwd.
if err := validateWorkingDir(workingDir); err != nil {
return acp.NewSessionResponse{}, err
}
Expand Down
Loading