From 08e70127b51c99a2442d6021c0b4a5874d5b3cfe Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Thu, 28 May 2026 23:06:36 +0000 Subject: [PATCH 1/9] ci(e2e): scope agynd centralized coverage --- .github/workflows/e2e.yml | 30 +++++++++++++++++++++++++++++- README.md | 15 +++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 688c3f4..7ad2d04 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -26,7 +26,35 @@ jobs: - name: Setup DevSpace uses: agynio/e2e/.github/actions/setup-devspace@main + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.26.x' + + - name: Install buf + uses: bufbuild/buf-setup-action@v1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + version: 1.66.0 + + - name: Generate protobuf bindings + run: | + buf generate buf.build/agynio/api \ + --path agynio/api/gateway/v1 \ + --include-imports + + - name: Build agynd binary + env: + CGO_ENABLED: 0 + run: | + mkdir -p dist + go build -trimpath -ldflags="-s -w" -o dist/agynd ./cmd/agynd + - name: Run E2E tests uses: agynio/e2e/.github/actions/run-tests@main + env: + AGYND_BINARY: ${{ github.workspace }}/dist/agynd + E2E_SUITES: playwright-tracing-app with: - service: agynd_cli + tag: svc_agents_orchestrator + include_smoke: false diff --git a/README.md b/README.md index 11fd569..b03a70e 100644 --- a/README.md +++ b/README.md @@ -16,3 +16,18 @@ Full setup: https://github.com/agynio/architecture/blob/main/architecture/operat devspace dev devspace dev -w ``` + +## E2E validation + +The GitHub E2E workflow is a consumer of the centralized +`agynio/e2e` harness. It validates agynd-specific integration coverage only: +the workflow builds this repository's `dist/agynd` binary, provisions the +standard platform cluster, and runs the agent-orchestrator E2E coverage where +agynd participates in agent workload execution and tracing. The workflow limits +the centralized harness to the `playwright-tracing-app` suite and disables the +shared smoke tag. + +This keeps agynd-cli coverage focused on daemon behavior and avoids failing this +repository for unrelated centralized smoke coverage, such as go-core Gateway +smoke tests. Broader platform smoke coverage remains owned by the centralized +E2E repository and service-specific workflows. From 41a8151c8ef03e3fd6e922c8f4d6cfe9315244a6 Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Thu, 28 May 2026 23:17:52 +0000 Subject: [PATCH 2/9] ci(e2e): run repo-local coverage --- .github/workflows/e2e.yml | 30 ++++++++++++------------------ README.md | 26 ++++++++++++++------------ 2 files changed, 26 insertions(+), 30 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 7ad2d04..ffd15fa 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -20,11 +20,12 @@ jobs: - name: Configure Git for private modules run: git config --global url."https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/".insteadOf "https://github.com/" - - name: Provision cluster - uses: agynio/bootstrap/.github/actions/provision@main - - - name: Setup DevSpace - uses: agynio/e2e/.github/actions/setup-devspace@main + - name: Checkout agn-cli + uses: actions/checkout@v4 + with: + repository: agynio/agn-cli + token: ${{ secrets.GITHUB_TOKEN }} + path: .agn-cli - name: Set up Go uses: actions/setup-go@v5 @@ -43,18 +44,11 @@ jobs: --path agynio/api/gateway/v1 \ --include-imports - - name: Build agynd binary - env: - CGO_ENABLED: 0 - run: | - mkdir -p dist - go build -trimpath -ldflags="-s -w" -o dist/agynd ./cmd/agynd + - name: Install Codex CLI + run: npm install -g @openai/codex - - name: Run E2E tests - uses: agynio/e2e/.github/actions/run-tests@main + - name: Run repo-local E2E tests + run: go test -v -count=1 -tags e2e ./test/e2e/ env: - AGYND_BINARY: ${{ github.workspace }}/dist/agynd - E2E_SUITES: playwright-tracing-app - with: - tag: svc_agents_orchestrator - include_smoke: false + OPENAI_API_KEY: test-key + AGN_REPO_PATH: ${{ github.workspace }}/.agn-cli diff --git a/README.md b/README.md index b03a70e..49d0e71 100644 --- a/README.md +++ b/README.md @@ -19,15 +19,17 @@ devspace dev -w ## E2E validation -The GitHub E2E workflow is a consumer of the centralized -`agynio/e2e` harness. It validates agynd-specific integration coverage only: -the workflow builds this repository's `dist/agynd` binary, provisions the -standard platform cluster, and runs the agent-orchestrator E2E coverage where -agynd participates in agent workload execution and tracing. The workflow limits -the centralized harness to the `playwright-tracing-app` suite and disables the -shared smoke tag. - -This keeps agynd-cli coverage focused on daemon behavior and avoids failing this -repository for unrelated centralized smoke coverage, such as go-core Gateway -smoke tests. Broader platform smoke coverage remains owned by the centralized -E2E repository and service-specific workflows. +The GitHub E2E workflow runs this repository's local E2E tests with: + +```bash +go test -v -count=1 -tags e2e ./test/e2e/ +``` + +Those tests validate the local agent CLI bridge behavior against deterministic +TestLLM endpoints through the Codex and AGN SDK flows. The workflow checks out +`agynio/agn-cli` so the AGN coverage builds and runs the current AGN CLI during +the test. + +This repository does not run the centralized `agynio/e2e` smoke suite. Broader +platform and service smoke coverage remains owned by the centralized E2E +repository and service-specific workflows. From 03ecf1961be56cb638510c370f7b6d7dd43a7a03 Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Thu, 28 May 2026 23:29:47 +0000 Subject: [PATCH 3/9] test(e2e): exercise agynd startup --- README.md | 4 +- test/e2e/agynd_daemon_test.go | 262 ++++++++++++++++++++++++++++++++++ 2 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 test/e2e/agynd_daemon_test.go diff --git a/README.md b/README.md index 49d0e71..4f6e7ed 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,9 @@ go test -v -count=1 -tags e2e ./test/e2e/ Those tests validate the local agent CLI bridge behavior against deterministic TestLLM endpoints through the Codex and AGN SDK flows. The workflow checks out `agynio/agn-cli` so the AGN coverage builds and runs the current AGN CLI during -the test. +the test. They also build and execute this repository's `cmd/agynd` binary +against a stub Gateway to verify daemon startup, platform initialization, and +the expected post-initialization failure path without requiring a full cluster. This repository does not run the centralized `agynio/e2e` smoke suite. Broader platform and service smoke coverage remains owned by the centralized E2E diff --git a/test/e2e/agynd_daemon_test.go b/test/e2e/agynd_daemon_test.go new file mode 100644 index 0000000..5fa0876 --- /dev/null +++ b/test/e2e/agynd_daemon_test.go @@ -0,0 +1,262 @@ +//go:build e2e + +package e2e + +import ( + "context" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + agentsv1 "github.com/agynio/agynd-cli/.gen/go/agynio/api/agents/v1" + gatewayv1 "github.com/agynio/agynd-cli/.gen/go/agynio/api/gateway/v1" + notificationsv1 "github.com/agynio/agynd-cli/.gen/go/agynio/api/notifications/v1" + runnersv1 "github.com/agynio/agynd-cli/.gen/go/agynio/api/runners/v1" + threadsv1 "github.com/agynio/agynd-cli/.gen/go/agynio/api/threads/v1" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const ( + agyndE2EAgentID = "550e8400-e29b-41d4-a716-446655440000" + agyndE2EThreadID = "550e8400-e29b-41d4-a716-446655440001" + agyndE2EWorkloadID = "550e8400-e29b-41d4-a716-446655440002" +) + +func TestAgyndBinaryInitializesWithStubGateway(t *testing.T) { + binary := buildAgynd(t) + server := startAgyndGatewayStub(t) + workDir := t.TempDir() + writeAgentRuntimeConfig(t, `{"sdk":"agn","bin":"/bin/false"}`) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, binary) + cmd.Dir = workDir + cmd.Env = append(cleanAgyndEnv(), + "AGENT_ID="+agyndE2EAgentID, + "THREAD_ID="+agyndE2EThreadID, + "WORKLOAD_ID="+agyndE2EWorkloadID, + "GATEWAY_ADDRESS="+server.address, + "TRACING_ADDRESS=127.0.0.1:1", + "LLM_BASE_URL=https://testllm.dev/v1/org/agynio/suite/agn", + "LLM_API_TOKEN=test-token", + "AGN_TOKEN_COUNTING_ADDRESS=127.0.0.1:1", + "HOME="+t.TempDir(), + "WORKSPACE_DIR="+workDir, + ) + output, err := cmd.CombinedOutput() + if ctx.Err() != nil { + t.Fatalf("agynd did not reach expected failure before timeout; output:\n%s", output) + } + if err == nil { + t.Fatalf("expected agynd to fail when fake agent binary exits; output:\n%s", output) + } + text := string(output) + if !strings.Contains(text, "daemon init failed") { + t.Fatalf("expected daemon init failure in agynd output:\n%s", text) + } + if !strings.Contains(text, "start agn client") && !strings.Contains(text, "listen on 127.0.0.1:4317") { + t.Fatalf("expected agynd to fail after gateway-backed initialization; output:\n%s", text) + } + server.assertInitialized(t) +} + +func cleanAgyndEnv() []string { + blocked := map[string]struct{}{ + "AGENT_MCP_SERVERS": {}, + "MCP_PORT": {}, + } + env := os.Environ() + cleaned := make([]string, 0, len(env)) + for _, entry := range env { + key, _, ok := strings.Cut(entry, "=") + if !ok { + continue + } + if _, skip := blocked[key]; skip { + continue + } + cleaned = append(cleaned, entry) + } + return cleaned +} + +func buildAgynd(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "agynd") + cmd := exec.Command("go", "build", "-trimpath", "-o", path, "./cmd/agynd") + cmd.Dir = repoRoot(t) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("build agynd: %v\n%s", err, output) + } + return path +} + +func writeAgentRuntimeConfig(t *testing.T, payload string) { + t.Helper() + if err := os.MkdirAll("/agyn-bin", 0o755); err != nil { + t.Fatalf("create /agyn-bin: %v", err) + } + if err := os.WriteFile("/agyn-bin/config.json", []byte(payload), 0o644); err != nil { + t.Fatalf("write /agyn-bin/config.json: %v", err) + } +} + +func repoRoot(t *testing.T) string { + t.Helper() + wd, err := os.Getwd() + if err != nil { + t.Fatalf("get working directory: %v", err) + } + return filepath.Clean(filepath.Join(wd, "..", "..")) +} + +type agyndGatewayStub struct { + address string + server *grpc.Server + + mu sync.Mutex + getAgentCalls int + listSkillsCalls int + listMCPsCalls int + listInitScriptsCalls int +} + +type agyndAgentsGatewayStub struct { + gatewayv1.UnimplementedAgentsGatewayServer + *agyndGatewayStub +} + +type agyndThreadsGatewayStub struct { + gatewayv1.UnimplementedThreadsGatewayServer + *agyndGatewayStub +} + +type agyndNotificationsGatewayStub struct { + gatewayv1.UnimplementedNotificationsGatewayServer + *agyndGatewayStub +} + +type agyndRunnersGatewayStub struct { + gatewayv1.UnimplementedRunnersGatewayServer + *agyndGatewayStub +} + +func startAgyndGatewayStub(t *testing.T) *agyndGatewayStub { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen gateway stub: %v", err) + } + stub := &agyndGatewayStub{address: listener.Addr().String()} + stub.server = grpc.NewServer() + gatewayv1.RegisterAgentsGatewayServer(stub.server, agyndAgentsGatewayStub{agyndGatewayStub: stub}) + gatewayv1.RegisterThreadsGatewayServer(stub.server, agyndThreadsGatewayStub{agyndGatewayStub: stub}) + gatewayv1.RegisterNotificationsGatewayServer(stub.server, agyndNotificationsGatewayStub{agyndGatewayStub: stub}) + gatewayv1.RegisterRunnersGatewayServer(stub.server, agyndRunnersGatewayStub{agyndGatewayStub: stub}) + + serveErr := make(chan error, 1) + go func() { + serveErr <- stub.server.Serve(listener) + }() + t.Cleanup(func() { + stub.server.Stop() + _ = listener.Close() + select { + case err := <-serveErr: + if err != nil && !strings.Contains(err.Error(), "use of closed network connection") { + t.Errorf("gateway stub stopped with error: %v", err) + } + default: + } + }) + return stub +} + +func (s agyndAgentsGatewayStub) GetAgent(_ context.Context, req *agentsv1.GetAgentRequest) (*agentsv1.GetAgentResponse, error) { + if req.GetId() != agyndE2EAgentID { + return nil, status.Errorf(codes.InvalidArgument, "unexpected agent id %q", req.GetId()) + } + s.mu.Lock() + s.getAgentCalls++ + s.mu.Unlock() + return &agentsv1.GetAgentResponse{Agent: &agentsv1.Agent{ + Meta: &agentsv1.EntityMeta{Id: agyndE2EAgentID}, + Name: "agynd-e2e-agent", + Model: "simple-hello", + }}, nil +} + +func (s agyndAgentsGatewayStub) ListSkills(_ context.Context, req *agentsv1.ListSkillsRequest) (*agentsv1.ListSkillsResponse, error) { + if req.GetAgentId() != agyndE2EAgentID { + return nil, status.Errorf(codes.InvalidArgument, "unexpected skills agent id %q", req.GetAgentId()) + } + s.mu.Lock() + s.listSkillsCalls++ + s.mu.Unlock() + return &agentsv1.ListSkillsResponse{}, nil +} + +func (s agyndAgentsGatewayStub) ListMcps(_ context.Context, req *agentsv1.ListMcpsRequest) (*agentsv1.ListMcpsResponse, error) { + if req.GetAgentId() != agyndE2EAgentID { + return nil, status.Errorf(codes.InvalidArgument, "unexpected mcps agent id %q", req.GetAgentId()) + } + s.mu.Lock() + s.listMCPsCalls++ + s.mu.Unlock() + return &agentsv1.ListMcpsResponse{}, nil +} + +func (s agyndAgentsGatewayStub) ListInitScripts(_ context.Context, req *agentsv1.ListInitScriptsRequest) (*agentsv1.ListInitScriptsResponse, error) { + if req.GetAgentId() != agyndE2EAgentID { + return nil, status.Errorf(codes.InvalidArgument, "unexpected init scripts agent id %q", req.GetAgentId()) + } + s.mu.Lock() + s.listInitScriptsCalls++ + s.mu.Unlock() + return &agentsv1.ListInitScriptsResponse{}, nil +} + +func (s agyndThreadsGatewayStub) GetUnackedMessages(context.Context, *threadsv1.GetUnackedMessagesRequest) (*threadsv1.GetUnackedMessagesResponse, error) { + return &threadsv1.GetUnackedMessagesResponse{}, nil +} + +func (s agyndNotificationsGatewayStub) Subscribe(_ *notificationsv1.SubscribeRequest, stream grpc.ServerStreamingServer[notificationsv1.SubscribeResponse]) error { + <-stream.Context().Done() + return stream.Context().Err() +} + +func (s agyndRunnersGatewayStub) TouchWorkload(context.Context, *runnersv1.TouchWorkloadRequest) (*runnersv1.TouchWorkloadResponse, error) { + return &runnersv1.TouchWorkloadResponse{}, nil +} + +func (s *agyndGatewayStub) assertInitialized(t *testing.T) { + t.Helper() + s.mu.Lock() + defer s.mu.Unlock() + checks := map[string]int{ + "GetAgent": s.getAgentCalls, + "ListSkills": s.listSkillsCalls, + "ListMcps": s.listMCPsCalls, + "ListInitScripts": s.listInitScriptsCalls, + } + for name, calls := range checks { + if calls == 0 { + t.Fatalf("expected agynd to call %s during initialization; calls: %s", name, formatCalls(checks)) + } + } +} + +func formatCalls(calls map[string]int) string { + return fmt.Sprintf("GetAgent=%d ListSkills=%d ListMcps=%d ListInitScripts=%d", calls["GetAgent"], calls["ListSkills"], calls["ListMcps"], calls["ListInitScripts"]) +} From 20650a9b192d0522fe3496d359f8ba51575a1b1c Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Thu, 28 May 2026 23:32:57 +0000 Subject: [PATCH 4/9] test(e2e): use temp agynd config --- internal/config/config.go | 11 +++++++++-- internal/config/config_test.go | 3 ++- test/e2e/agynd_daemon_test.go | 14 +++++++------- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index a7b70b0..8992691 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,7 +12,10 @@ import ( "github.com/google/uuid" ) -const agentConfigPath = "/agyn-bin/config.json" +const ( + agentConfigPath = "/agyn-bin/config.json" + agentConfigPathEnvVar = "AGYND_CONFIG_PATH" +) var mcpServerNamePattern = regexp.MustCompile(`^[a-z][a-z0-9_]*$`) @@ -42,7 +45,11 @@ type Config struct { } func FromEnv() (Config, error) { - return fromEnv(agentConfigPath) + configPath := strings.TrimSpace(os.Getenv(agentConfigPathEnvVar)) + if configPath == "" { + configPath = agentConfigPath + } + return fromEnv(configPath) } func fromEnv(configPath string) (Config, error) { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 575097f..dfe4a9f 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -41,6 +41,7 @@ func writeAgentConfigRaw(t *testing.T, payload string) string { func TestFromEnvValid(t *testing.T) { setRequiredEnv(t) configPath := writeAgentConfig(t, "codex", "/opt/bin/codex") + t.Setenv("AGYND_CONFIG_PATH", configPath) t.Setenv("WORKSPACE_DIR", "/tmp/workdir") t.Setenv("GATEWAY_ADDRESS", "gateway:1234") t.Setenv("TRACING_ADDRESS", "tracing:5678") @@ -48,7 +49,7 @@ func TestFromEnvValid(t *testing.T) { t.Setenv("LLM_BASE_URL", "https://llm.example") t.Setenv("LLM_API_TOKEN", "token-123") - cfg, err := fromEnv(configPath) + cfg, err := FromEnv() if err != nil { t.Fatalf("expected no error, got %v", err) } diff --git a/test/e2e/agynd_daemon_test.go b/test/e2e/agynd_daemon_test.go index 5fa0876..056baf3 100644 --- a/test/e2e/agynd_daemon_test.go +++ b/test/e2e/agynd_daemon_test.go @@ -34,7 +34,7 @@ func TestAgyndBinaryInitializesWithStubGateway(t *testing.T) { binary := buildAgynd(t) server := startAgyndGatewayStub(t) workDir := t.TempDir() - writeAgentRuntimeConfig(t, `{"sdk":"agn","bin":"/bin/false"}`) + agentConfigPath := writeAgentRuntimeConfig(t, `{"sdk":"agn","bin":"/bin/false"}`) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -49,6 +49,7 @@ func TestAgyndBinaryInitializesWithStubGateway(t *testing.T) { "TRACING_ADDRESS=127.0.0.1:1", "LLM_BASE_URL=https://testllm.dev/v1/org/agynio/suite/agn", "LLM_API_TOKEN=test-token", + "AGYND_CONFIG_PATH="+agentConfigPath, "AGN_TOKEN_COUNTING_ADDRESS=127.0.0.1:1", "HOME="+t.TempDir(), "WORKSPACE_DIR="+workDir, @@ -102,14 +103,13 @@ func buildAgynd(t *testing.T) string { return path } -func writeAgentRuntimeConfig(t *testing.T, payload string) { +func writeAgentRuntimeConfig(t *testing.T, payload string) string { t.Helper() - if err := os.MkdirAll("/agyn-bin", 0o755); err != nil { - t.Fatalf("create /agyn-bin: %v", err) - } - if err := os.WriteFile("/agyn-bin/config.json", []byte(payload), 0o644); err != nil { - t.Fatalf("write /agyn-bin/config.json: %v", err) + path := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(path, []byte(payload), 0o600); err != nil { + t.Fatalf("write agynd config: %v", err) } + return path } func repoRoot(t *testing.T) string { From fd982b5773e51ec8fb0ec764c763ebcbf75ed61b Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Thu, 28 May 2026 23:36:41 +0000 Subject: [PATCH 5/9] test(e2e): stop agynd deterministically --- test/e2e/agynd_daemon_test.go | 112 +++++++++++++++++++++++++++++----- 1 file changed, 97 insertions(+), 15 deletions(-) diff --git a/test/e2e/agynd_daemon_test.go b/test/e2e/agynd_daemon_test.go index 056baf3..a1f7c40 100644 --- a/test/e2e/agynd_daemon_test.go +++ b/test/e2e/agynd_daemon_test.go @@ -11,6 +11,7 @@ import ( "path/filepath" "strings" "sync" + "syscall" "testing" "time" @@ -36,10 +37,7 @@ func TestAgyndBinaryInitializesWithStubGateway(t *testing.T) { workDir := t.TempDir() agentConfigPath := writeAgentRuntimeConfig(t, `{"sdk":"agn","bin":"/bin/false"}`) - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - cmd := exec.CommandContext(ctx, binary) + cmd := exec.Command(binary) cmd.Dir = workDir cmd.Env = append(cleanAgyndEnv(), "AGENT_ID="+agyndE2EAgentID, @@ -54,23 +52,80 @@ func TestAgyndBinaryInitializesWithStubGateway(t *testing.T) { "HOME="+t.TempDir(), "WORKSPACE_DIR="+workDir, ) - output, err := cmd.CombinedOutput() - if ctx.Err() != nil { - t.Fatalf("agynd did not reach expected failure before timeout; output:\n%s", output) + + var output strings.Builder + cmd.Stdout = &output + cmd.Stderr = &output + if err := cmd.Start(); err != nil { + t.Fatalf("start agynd: %v", err) } - if err == nil { - t.Fatalf("expected agynd to fail when fake agent binary exits; output:\n%s", output) + + waitCh := make(chan error, 1) + go func() { + waitCh <- cmd.Wait() + }() + + if err := server.waitInitialized(10 * time.Second); err != nil { + terminateProcess(t, cmd.Process) + <-waitCh + t.Fatalf("agynd did not initialize against stub gateway: %v\noutput:\n%s", err, output.String()) + } + + select { + case err := <-waitCh: + assertAgyndExit(t, err, output.String()) + server.assertInitialized(t) + return + default: } - text := string(output) - if !strings.Contains(text, "daemon init failed") { - t.Fatalf("expected daemon init failure in agynd output:\n%s", text) + + if err := cmd.Process.Signal(os.Interrupt); err != nil { + select { + case waitErr := <-waitCh: + assertAgyndExit(t, waitErr, output.String()) + server.assertInitialized(t) + return + default: + } + terminateProcess(t, cmd.Process) + <-waitCh + t.Fatalf("interrupt agynd: %v\noutput:\n%s", err, output.String()) } - if !strings.Contains(text, "start agn client") && !strings.Contains(text, "listen on 127.0.0.1:4317") { - t.Fatalf("expected agynd to fail after gateway-backed initialization; output:\n%s", text) + select { + case err := <-waitCh: + assertAgyndExit(t, err, output.String()) + case <-time.After(5 * time.Second): + terminateProcess(t, cmd.Process) + <-waitCh + t.Fatalf("agynd did not stop after interrupt; output:\n%s", output.String()) } + server.assertInitialized(t) } +func assertAgyndExit(t *testing.T, err error, output string) { + t.Helper() + if err == nil { + return + } + for _, expected := range []string{"daemon stopped", "daemon exited", "daemon init failed", "signal: interrupt"} { + if strings.Contains(output, expected) { + return + } + } + t.Fatalf("agynd exited unexpectedly: %v\noutput:\n%s", err, output) +} + +func terminateProcess(t *testing.T, process *os.Process) { + t.Helper() + if process == nil { + return + } + _ = process.Signal(os.Interrupt) + time.Sleep(100 * time.Millisecond) + _ = process.Signal(syscall.SIGKILL) +} + func cleanAgyndEnv() []string { blocked := map[string]struct{}{ "AGENT_MCP_SERVERS": {}, @@ -124,6 +179,8 @@ func repoRoot(t *testing.T) string { type agyndGatewayStub struct { address string server *grpc.Server + ready chan struct{} + once sync.Once mu sync.Mutex getAgentCalls int @@ -158,7 +215,7 @@ func startAgyndGatewayStub(t *testing.T) *agyndGatewayStub { if err != nil { t.Fatalf("listen gateway stub: %v", err) } - stub := &agyndGatewayStub{address: listener.Addr().String()} + stub := &agyndGatewayStub{address: listener.Addr().String(), ready: make(chan struct{})} stub.server = grpc.NewServer() gatewayv1.RegisterAgentsGatewayServer(stub.server, agyndAgentsGatewayStub{agyndGatewayStub: stub}) gatewayv1.RegisterThreadsGatewayServer(stub.server, agyndThreadsGatewayStub{agyndGatewayStub: stub}) @@ -224,9 +281,23 @@ func (s agyndAgentsGatewayStub) ListInitScripts(_ context.Context, req *agentsv1 s.mu.Lock() s.listInitScriptsCalls++ s.mu.Unlock() + s.markInitialized() return &agentsv1.ListInitScriptsResponse{}, nil } +func (s *agyndGatewayStub) markInitialized() { + s.once.Do(func() { close(s.ready) }) +} + +func (s *agyndGatewayStub) waitInitialized(timeout time.Duration) error { + select { + case <-s.ready: + return nil + case <-time.After(timeout): + return fmt.Errorf("timed out waiting for ListInitScripts; calls: %s", s.callsSummary()) + } +} + func (s agyndThreadsGatewayStub) GetUnackedMessages(context.Context, *threadsv1.GetUnackedMessagesRequest) (*threadsv1.GetUnackedMessagesResponse, error) { return &threadsv1.GetUnackedMessagesResponse{}, nil } @@ -257,6 +328,17 @@ func (s *agyndGatewayStub) assertInitialized(t *testing.T) { } } +func (s *agyndGatewayStub) callsSummary() string { + s.mu.Lock() + defer s.mu.Unlock() + return formatCalls(map[string]int{ + "GetAgent": s.getAgentCalls, + "ListSkills": s.listSkillsCalls, + "ListMcps": s.listMCPsCalls, + "ListInitScripts": s.listInitScriptsCalls, + }) +} + func formatCalls(calls map[string]int) string { return fmt.Sprintf("GetAgent=%d ListSkills=%d ListMcps=%d ListInitScripts=%d", calls["GetAgent"], calls["ListSkills"], calls["ListMcps"], calls["ListInitScripts"]) } From 6a6aa4415cbe6c4be963909d73e5255c2f0d3ce4 Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Fri, 29 May 2026 00:06:34 +0000 Subject: [PATCH 6/9] test(e2e): run agynd in container --- README.md | 7 +- internal/config/config.go | 11 +- internal/config/config_test.go | 3 +- test/e2e/agynd_daemon_test.go | 256 +++++++++++++++++++-------------- 4 files changed, 158 insertions(+), 119 deletions(-) diff --git a/README.md b/README.md index 4f6e7ed..0295d5b 100644 --- a/README.md +++ b/README.md @@ -28,9 +28,10 @@ go test -v -count=1 -tags e2e ./test/e2e/ Those tests validate the local agent CLI bridge behavior against deterministic TestLLM endpoints through the Codex and AGN SDK flows. The workflow checks out `agynio/agn-cli` so the AGN coverage builds and runs the current AGN CLI during -the test. They also build and execute this repository's `cmd/agynd` binary -against a stub Gateway to verify daemon startup, platform initialization, and -the expected post-initialization failure path without requiring a full cluster. +the test. They also build and execute this repository's `cmd/agynd` binary in a +container with `/agyn-bin/config.json` mounted from test data. The containerized +test uses a stub Gateway and fake AGN agent to verify daemon startup, platform +initialization, and subscriber startup without requiring a full cluster. This repository does not run the centralized `agynio/e2e` smoke suite. Broader platform and service smoke coverage remains owned by the centralized E2E diff --git a/internal/config/config.go b/internal/config/config.go index 8992691..a7b70b0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,10 +12,7 @@ import ( "github.com/google/uuid" ) -const ( - agentConfigPath = "/agyn-bin/config.json" - agentConfigPathEnvVar = "AGYND_CONFIG_PATH" -) +const agentConfigPath = "/agyn-bin/config.json" var mcpServerNamePattern = regexp.MustCompile(`^[a-z][a-z0-9_]*$`) @@ -45,11 +42,7 @@ type Config struct { } func FromEnv() (Config, error) { - configPath := strings.TrimSpace(os.Getenv(agentConfigPathEnvVar)) - if configPath == "" { - configPath = agentConfigPath - } - return fromEnv(configPath) + return fromEnv(agentConfigPath) } func fromEnv(configPath string) (Config, error) { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index dfe4a9f..575097f 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -41,7 +41,6 @@ func writeAgentConfigRaw(t *testing.T, payload string) string { func TestFromEnvValid(t *testing.T) { setRequiredEnv(t) configPath := writeAgentConfig(t, "codex", "/opt/bin/codex") - t.Setenv("AGYND_CONFIG_PATH", configPath) t.Setenv("WORKSPACE_DIR", "/tmp/workdir") t.Setenv("GATEWAY_ADDRESS", "gateway:1234") t.Setenv("TRACING_ADDRESS", "tracing:5678") @@ -49,7 +48,7 @@ func TestFromEnvValid(t *testing.T) { t.Setenv("LLM_BASE_URL", "https://llm.example") t.Setenv("LLM_API_TOKEN", "token-123") - cfg, err := FromEnv() + cfg, err := fromEnv(configPath) if err != nil { t.Fatalf("expected no error, got %v", err) } diff --git a/test/e2e/agynd_daemon_test.go b/test/e2e/agynd_daemon_test.go index a1f7c40..bffc7e6 100644 --- a/test/e2e/agynd_daemon_test.go +++ b/test/e2e/agynd_daemon_test.go @@ -11,7 +11,6 @@ import ( "path/filepath" "strings" "sync" - "syscall" "testing" "time" @@ -33,124 +32,144 @@ const ( func TestAgyndBinaryInitializesWithStubGateway(t *testing.T) { binary := buildAgynd(t) + agentBinary := buildFakeAgnAgent(t) server := startAgyndGatewayStub(t) workDir := t.TempDir() - agentConfigPath := writeAgentRuntimeConfig(t, `{"sdk":"agn","bin":"/bin/false"}`) - - cmd := exec.Command(binary) - cmd.Dir = workDir - cmd.Env = append(cleanAgyndEnv(), - "AGENT_ID="+agyndE2EAgentID, - "THREAD_ID="+agyndE2EThreadID, - "WORKLOAD_ID="+agyndE2EWorkloadID, - "GATEWAY_ADDRESS="+server.address, - "TRACING_ADDRESS=127.0.0.1:1", - "LLM_BASE_URL=https://testllm.dev/v1/org/agynio/suite/agn", - "LLM_API_TOKEN=test-token", - "AGYND_CONFIG_PATH="+agentConfigPath, - "AGN_TOKEN_COUNTING_ADDRESS=127.0.0.1:1", - "HOME="+t.TempDir(), - "WORKSPACE_DIR="+workDir, - ) - - var output strings.Builder - cmd.Stdout = &output - cmd.Stderr = &output - if err := cmd.Start(); err != nil { - t.Fatalf("start agynd: %v", err) - } + runtimeDir := t.TempDir() + writeAgentRuntimeConfig(t, runtimeDir) - waitCh := make(chan error, 1) - go func() { - waitCh <- cmd.Wait() - }() + container := startAgyndContainer(t, binary, agentBinary, runtimeDir, workDir, server.address) + defer container.cleanup() - if err := server.waitInitialized(10 * time.Second); err != nil { - terminateProcess(t, cmd.Process) - <-waitCh - t.Fatalf("agynd did not initialize against stub gateway: %v\noutput:\n%s", err, output.String()) - } - - select { - case err := <-waitCh: - assertAgyndExit(t, err, output.String()) - server.assertInitialized(t) - return - default: + if err := server.waitRunStarted(10 * time.Second); err != nil { + container.dumpLogs(t) + t.Fatalf("agynd did not reach daemon.Run/subscriber startup: %v", err) } - if err := cmd.Process.Signal(os.Interrupt); err != nil { - select { - case waitErr := <-waitCh: - assertAgyndExit(t, waitErr, output.String()) - server.assertInitialized(t) - return - default: - } - terminateProcess(t, cmd.Process) - <-waitCh - t.Fatalf("interrupt agynd: %v\noutput:\n%s", err, output.String()) + if err := container.interrupt(); err != nil { + container.dumpLogs(t) + t.Fatalf("interrupt agynd container: %v", err) } - select { - case err := <-waitCh: - assertAgyndExit(t, err, output.String()) - case <-time.After(5 * time.Second): - terminateProcess(t, cmd.Process) - <-waitCh - t.Fatalf("agynd did not stop after interrupt; output:\n%s", output.String()) + if err := container.wait(5 * time.Second); err != nil { + container.kill() + container.dumpLogs(t) + t.Fatalf("agynd container did not stop after interrupt: %v", err) } server.assertInitialized(t) + server.assertRunStarted(t) } -func assertAgyndExit(t *testing.T, err error, output string) { +type agyndContainer struct { + id string +} + +func startAgyndContainer(t *testing.T, binary string, agentBinary string, runtimeDir string, workDir string, gatewayAddress string) *agyndContainer { t.Helper() - if err == nil { - return + args := []string{ + "create", + "--add-host=host.docker.internal:host-gateway", + "--workdir", "/workspace", + "--env", "AGENT_ID=" + agyndE2EAgentID, + "--env", "THREAD_ID=" + agyndE2EThreadID, + "--env", "WORKLOAD_ID=" + agyndE2EWorkloadID, + "--env", "GATEWAY_ADDRESS=" + hostGatewayAddress(gatewayAddress), + "--env", "TRACING_ADDRESS=host.docker.internal:1", + "--env", "LLM_BASE_URL=https://testllm.dev/v1/org/agynio/suite/agn", + "--env", "LLM_API_TOKEN=test-token", + "--env", "AGN_TOKEN_COUNTING_ADDRESS=host.docker.internal:1", + "--env", "HOME=/tmp/agynd-home", + "--env", "WORKSPACE_DIR=/workspace", + "golang:1.26", + "/agyn-test-bin/agynd", } - for _, expected := range []string{"daemon stopped", "daemon exited", "daemon init failed", "signal: interrupt"} { - if strings.Contains(output, expected) { - return - } + output, err := exec.Command("docker", args...).CombinedOutput() + if err != nil { + t.Fatalf("create agynd container: %v\n%s", err, output) + } + id := strings.TrimSpace(string(output)) + if id == "" { + t.Fatalf("docker returned empty container id") } - t.Fatalf("agynd exited unexpectedly: %v\noutput:\n%s", err, output) + container := &agyndContainer{id: id} + t.Cleanup(container.cleanup) + + copyToContainer(t, container, filepath.Dir(binary), "/agyn-test-bin") + copyToContainer(t, container, agentBinary, "/agyn-test-bin/fake-agn") + copyToContainer(t, container, runtimeDir, "/agyn-bin") + copyToContainer(t, container, workDir, "/workspace") + + if output, err := exec.Command("docker", "start", container.id).CombinedOutput(); err != nil { + t.Fatalf("start agynd container: %v\n%s", err, output) + } + return container } -func terminateProcess(t *testing.T, process *os.Process) { +func copyToContainer(t *testing.T, container *agyndContainer, source string, destination string) { t.Helper() - if process == nil { - return + if output, err := exec.Command("docker", "cp", source, container.id+":"+destination).CombinedOutput(); err != nil { + t.Fatalf("copy %s to container %s: %v\n%s", source, destination, err, output) } - _ = process.Signal(os.Interrupt) - time.Sleep(100 * time.Millisecond) - _ = process.Signal(syscall.SIGKILL) } -func cleanAgyndEnv() []string { - blocked := map[string]struct{}{ - "AGENT_MCP_SERVERS": {}, - "MCP_PORT": {}, +func hostGatewayAddress(address string) string { + host, port, err := net.SplitHostPort(address) + if err != nil || host == "" || port == "" { + panic(fmt.Sprintf("invalid gateway address %q", address)) } - env := os.Environ() - cleaned := make([]string, 0, len(env)) - for _, entry := range env { - key, _, ok := strings.Cut(entry, "=") - if !ok { - continue - } - if _, skip := blocked[key]; skip { - continue - } - cleaned = append(cleaned, entry) + return net.JoinHostPort("host.docker.internal", port) +} + +func (c *agyndContainer) interrupt() error { + cmd := exec.Command("docker", "kill", "--signal=SIGINT", c.id) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("docker interrupt: %w: %s", err, strings.TrimSpace(string(output))) } - return cleaned + return nil +} + +func (c *agyndContainer) kill() { + _ = exec.Command("docker", "kill", c.id).Run() +} + +func (c *agyndContainer) cleanup() { + _ = exec.Command("docker", "rm", "-f", c.id).Run() +} + +func (c *agyndContainer) wait(timeout time.Duration) error { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + cmd := exec.CommandContext(ctx, "docker", "wait", c.id) + output, err := cmd.CombinedOutput() + if ctx.Err() != nil { + return ctx.Err() + } + if err != nil { + return fmt.Errorf("docker wait: %w: %s", err, strings.TrimSpace(string(output))) + } + return nil +} + +func (c *agyndContainer) dumpLogs(t *testing.T) { + t.Helper() + output, err := exec.Command("docker", "logs", c.id).CombinedOutput() + if err != nil { + t.Logf("docker logs failed: %v", err) + } + t.Logf("agynd container logs:\n%s", output) } func buildAgynd(t *testing.T) string { t.Helper() - path := filepath.Join(t.TempDir(), "agynd") + dir := filepath.Join(repoRoot(t), ".tmp-e2e", "agynd-"+strings.ReplaceAll(t.Name(), "/", "-")) + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatalf("create agynd build dir: %v", err) + } + path := filepath.Join(dir, "agynd") cmd := exec.Command("go", "build", "-trimpath", "-o", path, "./cmd/agynd") cmd.Dir = repoRoot(t) + cmd.Env = append(os.Environ(), "CGO_ENABLED=0") output, err := cmd.CombinedOutput() if err != nil { t.Fatalf("build agynd: %v\n%s", err, output) @@ -158,15 +177,33 @@ func buildAgynd(t *testing.T) string { return path } -func writeAgentRuntimeConfig(t *testing.T, payload string) string { +func writeAgentRuntimeConfig(t *testing.T, runtimeDir string) { t.Helper() - path := filepath.Join(t.TempDir(), "config.json") - if err := os.WriteFile(path, []byte(payload), 0o600); err != nil { + configPath := filepath.Join(runtimeDir, "config.json") + payload := `{"sdk":"agn","bin":"/agyn-test-bin/fake-agn"}` + if err := os.WriteFile(configPath, []byte(payload), 0o600); err != nil { t.Fatalf("write agynd config: %v", err) } +} + +func buildFakeAgnAgent(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "fake-agn") + source := filepath.Join(t.TempDir(), "main.go") + if err := os.WriteFile(source, []byte(fakeAgnAgentSource), 0o600); err != nil { + t.Fatalf("write fake agn source: %v", err) + } + cmd := exec.Command("go", "build", "-trimpath", "-o", path, source) + cmd.Env = append(os.Environ(), "CGO_ENABLED=0") + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("build fake agn agent: %v\n%s", err, output) + } return path } +const fakeAgnAgentSource = "package main\n\nimport (\n\t\"bufio\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n)\n\ntype request struct {\n\tJSONRPC string `json:\"jsonrpc\"`\n\tID json.RawMessage `json:\"id\"`\n\tMethod string `json:\"method\"`\n\tParams json.RawMessage `json:\"params\"`\n}\n\ntype response struct {\n\tJSONRPC string `json:\"jsonrpc\"`\n\tID json.RawMessage `json:\"id\"`\n\tResult json.RawMessage `json:\"result\"`\n}\n\nfunc main() {\n\tif len(os.Args) < 2 || os.Args[1] != \"serve\" {\n\t\tos.Exit(2)\n\t}\n\tscanner := bufio.NewScanner(os.Stdin)\n\tfor scanner.Scan() {\n\t\tvar req request\n\t\tif err := json.Unmarshal(scanner.Bytes(), &req); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tresult := json.RawMessage(`{\"thread_id\":\"thread-from-fake-agent\",\"response\":\"ok\"}`)\n\t\tif req.Method == \"thread/list\" {\n\t\t\tresult = json.RawMessage(`{\"data\":[],\"next_cursor\":null}`)\n\t\t}\n\t\tpayload, _ := json.Marshal(response{JSONRPC: \"2.0\", ID: req.ID, Result: result})\n\t\tfmt.Println(string(payload))\n\t}\n}\n" + func repoRoot(t *testing.T) string { t.Helper() wd, err := os.Getwd() @@ -177,10 +214,10 @@ func repoRoot(t *testing.T) string { } type agyndGatewayStub struct { - address string - server *grpc.Server - ready chan struct{} - once sync.Once + address string + server *grpc.Server + runStarted chan struct{} + runOnce sync.Once mu sync.Mutex getAgentCalls int @@ -211,11 +248,11 @@ type agyndRunnersGatewayStub struct { func startAgyndGatewayStub(t *testing.T) *agyndGatewayStub { t.Helper() - listener, err := net.Listen("tcp", "127.0.0.1:0") + listener, err := net.Listen("tcp", "0.0.0.0:0") if err != nil { t.Fatalf("listen gateway stub: %v", err) } - stub := &agyndGatewayStub{address: listener.Addr().String(), ready: make(chan struct{})} + stub := &agyndGatewayStub{address: listener.Addr().String(), runStarted: make(chan struct{})} stub.server = grpc.NewServer() gatewayv1.RegisterAgentsGatewayServer(stub.server, agyndAgentsGatewayStub{agyndGatewayStub: stub}) gatewayv1.RegisterThreadsGatewayServer(stub.server, agyndThreadsGatewayStub{agyndGatewayStub: stub}) @@ -281,20 +318,19 @@ func (s agyndAgentsGatewayStub) ListInitScripts(_ context.Context, req *agentsv1 s.mu.Lock() s.listInitScriptsCalls++ s.mu.Unlock() - s.markInitialized() return &agentsv1.ListInitScriptsResponse{}, nil } -func (s *agyndGatewayStub) markInitialized() { - s.once.Do(func() { close(s.ready) }) +func (s *agyndGatewayStub) markRunStarted() { + s.runOnce.Do(func() { close(s.runStarted) }) } -func (s *agyndGatewayStub) waitInitialized(timeout time.Duration) error { +func (s *agyndGatewayStub) waitRunStarted(timeout time.Duration) error { select { - case <-s.ready: + case <-s.runStarted: return nil case <-time.After(timeout): - return fmt.Errorf("timed out waiting for ListInitScripts; calls: %s", s.callsSummary()) + return fmt.Errorf("timed out waiting for Subscribe; calls: %s", s.callsSummary()) } } @@ -303,6 +339,7 @@ func (s agyndThreadsGatewayStub) GetUnackedMessages(context.Context, *threadsv1. } func (s agyndNotificationsGatewayStub) Subscribe(_ *notificationsv1.SubscribeRequest, stream grpc.ServerStreamingServer[notificationsv1.SubscribeResponse]) error { + s.markRunStarted() <-stream.Context().Done() return stream.Context().Err() } @@ -328,6 +365,15 @@ func (s *agyndGatewayStub) assertInitialized(t *testing.T) { } } +func (s *agyndGatewayStub) assertRunStarted(t *testing.T) { + t.Helper() + select { + case <-s.runStarted: + default: + t.Fatal("expected agynd to enter daemon.Run and subscribe to notifications") + } +} + func (s *agyndGatewayStub) callsSummary() string { s.mu.Lock() defer s.mu.Unlock() From 7fb38874128a8090de5fd0c4bb2445b250d6a812 Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Fri, 29 May 2026 00:13:31 +0000 Subject: [PATCH 7/9] test(e2e): remove agynd docker dependency --- README.md | 8 +- internal/config/config.go | 20 +++- internal/config/config_test.go | 30 ++++++ internal/daemon/agn.go | 3 +- internal/daemon/claude.go | 3 +- internal/daemon/daemon.go | 4 +- test/e2e/agynd_daemon_test.go | 180 +++++++++++++-------------------- 7 files changed, 130 insertions(+), 118 deletions(-) diff --git a/README.md b/README.md index 0295d5b..50f5442 100644 --- a/README.md +++ b/README.md @@ -28,10 +28,10 @@ go test -v -count=1 -tags e2e ./test/e2e/ Those tests validate the local agent CLI bridge behavior against deterministic TestLLM endpoints through the Codex and AGN SDK flows. The workflow checks out `agynio/agn-cli` so the AGN coverage builds and runs the current AGN CLI during -the test. They also build and execute this repository's `cmd/agynd` binary in a -container with `/agyn-bin/config.json` mounted from test data. The containerized -test uses a stub Gateway and fake AGN agent to verify daemon startup, platform -initialization, and subscriber startup without requiring a full cluster. +the test. They also build and execute this repository's `cmd/agynd` binary with +a local `config.json` in the test workdir. That test uses a stub Gateway and fake +AGN agent to verify daemon startup, platform initialization, and subscriber +startup without requiring a full cluster. This repository does not run the centralized `agynio/e2e` smoke suite. Broader platform and service smoke coverage remains owned by the centralized E2E diff --git a/internal/config/config.go b/internal/config/config.go index a7b70b0..19aa849 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,6 +2,7 @@ package config import ( "encoding/json" + "errors" "fmt" "os" "regexp" @@ -12,7 +13,10 @@ import ( "github.com/google/uuid" ) -const agentConfigPath = "/agyn-bin/config.json" +const ( + agentConfigPath = "/agyn-bin/config.json" + localAgentConfigPath = "config.json" +) var mcpServerNamePattern = regexp.MustCompile(`^[a-z][a-z0-9_]*$`) @@ -42,7 +46,19 @@ type Config struct { } func FromEnv() (Config, error) { - return fromEnv(agentConfigPath) + return fromEnv(selectAgentConfigPath(agentConfigPath, localAgentConfigPath)) +} + +func selectAgentConfigPath(primaryPath string, localPath string) string { + if _, err := os.Stat(primaryPath); err == nil { + return primaryPath + } else if !errors.Is(err, os.ErrNotExist) { + return primaryPath + } + if _, err := os.Stat(localPath); err == nil { + return localPath + } + return primaryPath } func fromEnv(configPath string) (Config, error) { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 575097f..12d4b23 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -38,6 +38,36 @@ func writeAgentConfigRaw(t *testing.T, payload string) string { return configPath } +func TestSelectAgentConfigPathFallsBackToLocal(t *testing.T) { + dir := t.TempDir() + localPath := filepath.Join(dir, "config.json") + if err := os.WriteFile(localPath, []byte(`{"sdk":"agn","bin":"/bin/agn"}`), 0o600); err != nil { + t.Fatalf("write local config: %v", err) + } + + selected := selectAgentConfigPath(filepath.Join(dir, "missing.json"), localPath) + if selected != localPath { + t.Fatalf("expected local config path %q, got %q", localPath, selected) + } +} + +func TestSelectAgentConfigPathPrefersPrimary(t *testing.T) { + dir := t.TempDir() + primaryPath := filepath.Join(dir, "primary.json") + localPath := filepath.Join(dir, "config.json") + if err := os.WriteFile(primaryPath, []byte(`{"sdk":"agn","bin":"/bin/agn"}`), 0o600); err != nil { + t.Fatalf("write primary config: %v", err) + } + if err := os.WriteFile(localPath, []byte(`{"sdk":"codex","bin":"/bin/codex"}`), 0o600); err != nil { + t.Fatalf("write local config: %v", err) + } + + selected := selectAgentConfigPath(primaryPath, localPath) + if selected != primaryPath { + t.Fatalf("expected primary config path %q, got %q", primaryPath, selected) + } +} + func TestFromEnvValid(t *testing.T) { setRequiredEnv(t) configPath := writeAgentConfig(t, "codex", "/opt/bin/codex") diff --git a/internal/daemon/agn.go b/internal/daemon/agn.go index 5fc498f..47739c0 100644 --- a/internal/daemon/agn.go +++ b/internal/daemon/agn.go @@ -59,6 +59,7 @@ func newAgnDaemon(ctx context.Context, cfg config.Config, version string) (*Daem tracingProxy, err := tracingproxy.Start(ctx, tracingproxy.Config{ TracingAddress: cfg.TracingAddress, + ListenAddress: tracingProxyListenAddress, ThreadID: cfg.ThreadID, WorkloadID: cfg.WorkloadID, }) @@ -67,7 +68,7 @@ func newAgnDaemon(ctx context.Context, cfg config.Config, version string) (*Daem return nil, err } - otlpEndpoint := "http://" + tracingproxy.ListenAddress + otlpEndpoint := "http://" + tracingProxy.Address() agnClient, err := agnsdk.Start(ctx, agnsdk.Options{ BinaryPath: cfg.AgentBinary, Env: []string{ diff --git a/internal/daemon/claude.go b/internal/daemon/claude.go index 0907da2..8d871a1 100644 --- a/internal/daemon/claude.go +++ b/internal/daemon/claude.go @@ -39,6 +39,7 @@ func newClaudeDaemon(ctx context.Context, cfg config.Config, version string) (*D tracingProxy, err := tracingproxy.Start(ctx, tracingproxy.Config{ TracingAddress: cfg.TracingAddress, + ListenAddress: tracingProxyListenAddress, ThreadID: cfg.ThreadID, WorkloadID: cfg.WorkloadID, }) @@ -47,7 +48,7 @@ func newClaudeDaemon(ctx context.Context, cfg config.Config, version string) (*D return nil, err } - otlpEndpoint := "http://" + tracingproxy.ListenAddress + otlpEndpoint := "http://" + tracingProxy.Address() options := claude.Options{ BinaryPath: cfg.AgentBinary, WorkDir: cfg.WorkDir, diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 5d408f0..4b0159c 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -42,6 +42,7 @@ const ( opAgnTurn = "agn_turn" opClaudeTurn = "claude_turn" opProcessSignalShutdown = "process_signal/shutdown" + tracingProxyListenAddress = "127.0.0.1:0" ) const ( @@ -268,6 +269,7 @@ func newCodexDaemon(ctx context.Context, cfg config.Config, version string) (*Da tracingProxy, err := tracingproxy.Start(ctx, tracingproxy.Config{ TracingAddress: cfg.TracingAddress, + ListenAddress: tracingProxyListenAddress, ThreadID: cfg.ThreadID, WorkloadID: cfg.WorkloadID, }) @@ -275,7 +277,7 @@ func newCodexDaemon(ctx context.Context, cfg config.Config, version string) (*Da _ = setup.gatewayConn.Close() return nil, err } - otlpEndpoint := "http://" + tracingproxy.ListenAddress + otlpEndpoint := "http://" + tracingProxy.Address() options := []codex.Option{ codex.WithBinary(cfg.AgentBinary), codex.WithWorkDir(cfg.WorkDir), diff --git a/test/e2e/agynd_daemon_test.go b/test/e2e/agynd_daemon_test.go index bffc7e6..405694e 100644 --- a/test/e2e/agynd_daemon_test.go +++ b/test/e2e/agynd_daemon_test.go @@ -11,6 +11,7 @@ import ( "path/filepath" "strings" "sync" + "syscall" "testing" "time" @@ -35,128 +36,89 @@ func TestAgyndBinaryInitializesWithStubGateway(t *testing.T) { agentBinary := buildFakeAgnAgent(t) server := startAgyndGatewayStub(t) workDir := t.TempDir() - runtimeDir := t.TempDir() - writeAgentRuntimeConfig(t, runtimeDir) + writeAgentRuntimeConfig(t, workDir, agentBinary) + + cmd := exec.Command(binary) + cmd.Dir = workDir + cmd.Env = append(cleanAgyndEnv(), + "AGENT_ID="+agyndE2EAgentID, + "THREAD_ID="+agyndE2EThreadID, + "WORKLOAD_ID="+agyndE2EWorkloadID, + "GATEWAY_ADDRESS="+server.address, + "TRACING_ADDRESS=127.0.0.1:1", + "LLM_BASE_URL=https://testllm.dev/v1/org/agynio/suite/agn", + "LLM_API_TOKEN=test-token", + "AGN_TOKEN_COUNTING_ADDRESS=127.0.0.1:1", + "HOME="+t.TempDir(), + "WORKSPACE_DIR="+workDir, + ) + + var output strings.Builder + cmd.Stdout = &output + cmd.Stderr = &output + if err := cmd.Start(); err != nil { + t.Fatalf("start agynd: %v", err) + } - container := startAgyndContainer(t, binary, agentBinary, runtimeDir, workDir, server.address) - defer container.cleanup() + waitCh := make(chan error, 1) + go func() { + waitCh <- cmd.Wait() + }() - if err := server.waitRunStarted(10 * time.Second); err != nil { - container.dumpLogs(t) - t.Fatalf("agynd did not reach daemon.Run/subscriber startup: %v", err) + select { + case <-server.runStarted: + case err := <-waitCh: + t.Fatalf("agynd exited before daemon.Run/subscriber startup: %v\noutput:\n%s", err, output.String()) + case <-time.After(10 * time.Second): + terminateProcess(cmd.Process) + <-waitCh + t.Fatalf("agynd did not reach daemon.Run/subscriber startup; calls: %s\noutput:\n%s", server.callsSummary(), output.String()) } - if err := container.interrupt(); err != nil { - container.dumpLogs(t) - t.Fatalf("interrupt agynd container: %v", err) + if err := cmd.Process.Signal(os.Interrupt); err != nil { + terminateProcess(cmd.Process) + <-waitCh + t.Fatalf("interrupt agynd: %v\noutput:\n%s", err, output.String()) } - if err := container.wait(5 * time.Second); err != nil { - container.kill() - container.dumpLogs(t) - t.Fatalf("agynd container did not stop after interrupt: %v", err) + select { + case <-waitCh: + case <-time.After(5 * time.Second): + terminateProcess(cmd.Process) + <-waitCh + t.Fatalf("agynd did not stop after interrupt; output:\n%s", output.String()) } server.assertInitialized(t) server.assertRunStarted(t) } -type agyndContainer struct { - id string -} - -func startAgyndContainer(t *testing.T, binary string, agentBinary string, runtimeDir string, workDir string, gatewayAddress string) *agyndContainer { - t.Helper() - args := []string{ - "create", - "--add-host=host.docker.internal:host-gateway", - "--workdir", "/workspace", - "--env", "AGENT_ID=" + agyndE2EAgentID, - "--env", "THREAD_ID=" + agyndE2EThreadID, - "--env", "WORKLOAD_ID=" + agyndE2EWorkloadID, - "--env", "GATEWAY_ADDRESS=" + hostGatewayAddress(gatewayAddress), - "--env", "TRACING_ADDRESS=host.docker.internal:1", - "--env", "LLM_BASE_URL=https://testllm.dev/v1/org/agynio/suite/agn", - "--env", "LLM_API_TOKEN=test-token", - "--env", "AGN_TOKEN_COUNTING_ADDRESS=host.docker.internal:1", - "--env", "HOME=/tmp/agynd-home", - "--env", "WORKSPACE_DIR=/workspace", - "golang:1.26", - "/agyn-test-bin/agynd", - } - output, err := exec.Command("docker", args...).CombinedOutput() - if err != nil { - t.Fatalf("create agynd container: %v\n%s", err, output) +func terminateProcess(process *os.Process) { + if process == nil { + return } - id := strings.TrimSpace(string(output)) - if id == "" { - t.Fatalf("docker returned empty container id") - } - container := &agyndContainer{id: id} - t.Cleanup(container.cleanup) - - copyToContainer(t, container, filepath.Dir(binary), "/agyn-test-bin") - copyToContainer(t, container, agentBinary, "/agyn-test-bin/fake-agn") - copyToContainer(t, container, runtimeDir, "/agyn-bin") - copyToContainer(t, container, workDir, "/workspace") - - if output, err := exec.Command("docker", "start", container.id).CombinedOutput(); err != nil { - t.Fatalf("start agynd container: %v\n%s", err, output) - } - return container + _ = process.Signal(os.Interrupt) + time.Sleep(100 * time.Millisecond) + _ = process.Signal(syscall.SIGKILL) } -func copyToContainer(t *testing.T, container *agyndContainer, source string, destination string) { - t.Helper() - if output, err := exec.Command("docker", "cp", source, container.id+":"+destination).CombinedOutput(); err != nil { - t.Fatalf("copy %s to container %s: %v\n%s", source, destination, err, output) +func cleanAgyndEnv() []string { + blocked := map[string]struct{}{ + "AGENT_MCP_SERVERS": {}, + "MCP_PORT": {}, } -} - -func hostGatewayAddress(address string) string { - host, port, err := net.SplitHostPort(address) - if err != nil || host == "" || port == "" { - panic(fmt.Sprintf("invalid gateway address %q", address)) - } - return net.JoinHostPort("host.docker.internal", port) -} - -func (c *agyndContainer) interrupt() error { - cmd := exec.Command("docker", "kill", "--signal=SIGINT", c.id) - if output, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("docker interrupt: %w: %s", err, strings.TrimSpace(string(output))) - } - return nil -} - -func (c *agyndContainer) kill() { - _ = exec.Command("docker", "kill", c.id).Run() -} - -func (c *agyndContainer) cleanup() { - _ = exec.Command("docker", "rm", "-f", c.id).Run() -} - -func (c *agyndContainer) wait(timeout time.Duration) error { - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - cmd := exec.CommandContext(ctx, "docker", "wait", c.id) - output, err := cmd.CombinedOutput() - if ctx.Err() != nil { - return ctx.Err() - } - if err != nil { - return fmt.Errorf("docker wait: %w: %s", err, strings.TrimSpace(string(output))) - } - return nil -} - -func (c *agyndContainer) dumpLogs(t *testing.T) { - t.Helper() - output, err := exec.Command("docker", "logs", c.id).CombinedOutput() - if err != nil { - t.Logf("docker logs failed: %v", err) + env := os.Environ() + cleaned := make([]string, 0, len(env)) + for _, entry := range env { + key, _, ok := strings.Cut(entry, "=") + if !ok { + continue + } + if _, skip := blocked[key]; skip { + continue + } + cleaned = append(cleaned, entry) } - t.Logf("agynd container logs:\n%s", output) + return cleaned } func buildAgynd(t *testing.T) string { @@ -177,10 +139,10 @@ func buildAgynd(t *testing.T) string { return path } -func writeAgentRuntimeConfig(t *testing.T, runtimeDir string) { +func writeAgentRuntimeConfig(t *testing.T, runtimeDir string, agentBinary string) { t.Helper() configPath := filepath.Join(runtimeDir, "config.json") - payload := `{"sdk":"agn","bin":"/agyn-test-bin/fake-agn"}` + payload := fmt.Sprintf(`{"sdk":"agn","bin":%q}`, agentBinary) if err := os.WriteFile(configPath, []byte(payload), 0o600); err != nil { t.Fatalf("write agynd config: %v", err) } @@ -248,7 +210,7 @@ type agyndRunnersGatewayStub struct { func startAgyndGatewayStub(t *testing.T) *agyndGatewayStub { t.Helper() - listener, err := net.Listen("tcp", "0.0.0.0:0") + listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("listen gateway stub: %v", err) } From 096d1521daf20e8cc19fa706ad86c9ea4618d6db Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Fri, 29 May 2026 00:19:49 +0000 Subject: [PATCH 8/9] test(e2e): install agynd runtime config --- README.md | 6 ++-- internal/config/config.go | 20 ++---------- internal/config/config_test.go | 30 ----------------- test/e2e/agynd_daemon_test.go | 59 +++++++++++++++++++++++++++++++--- 4 files changed, 60 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index 50f5442..aa3a420 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,9 @@ Those tests validate the local agent CLI bridge behavior against deterministic TestLLM endpoints through the Codex and AGN SDK flows. The workflow checks out `agynio/agn-cli` so the AGN coverage builds and runs the current AGN CLI during the test. They also build and execute this repository's `cmd/agynd` binary with -a local `config.json` in the test workdir. That test uses a stub Gateway and fake -AGN agent to verify daemon startup, platform initialization, and subscriber -startup without requiring a full cluster. +`/agyn-bin/config.json` installed by the test harness. That test uses a stub +Gateway and fake AGN agent to verify daemon startup, platform initialization, +and subscriber startup without requiring a full cluster. This repository does not run the centralized `agynio/e2e` smoke suite. Broader platform and service smoke coverage remains owned by the centralized E2E diff --git a/internal/config/config.go b/internal/config/config.go index 19aa849..a7b70b0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,7 +2,6 @@ package config import ( "encoding/json" - "errors" "fmt" "os" "regexp" @@ -13,10 +12,7 @@ import ( "github.com/google/uuid" ) -const ( - agentConfigPath = "/agyn-bin/config.json" - localAgentConfigPath = "config.json" -) +const agentConfigPath = "/agyn-bin/config.json" var mcpServerNamePattern = regexp.MustCompile(`^[a-z][a-z0-9_]*$`) @@ -46,19 +42,7 @@ type Config struct { } func FromEnv() (Config, error) { - return fromEnv(selectAgentConfigPath(agentConfigPath, localAgentConfigPath)) -} - -func selectAgentConfigPath(primaryPath string, localPath string) string { - if _, err := os.Stat(primaryPath); err == nil { - return primaryPath - } else if !errors.Is(err, os.ErrNotExist) { - return primaryPath - } - if _, err := os.Stat(localPath); err == nil { - return localPath - } - return primaryPath + return fromEnv(agentConfigPath) } func fromEnv(configPath string) (Config, error) { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 12d4b23..575097f 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -38,36 +38,6 @@ func writeAgentConfigRaw(t *testing.T, payload string) string { return configPath } -func TestSelectAgentConfigPathFallsBackToLocal(t *testing.T) { - dir := t.TempDir() - localPath := filepath.Join(dir, "config.json") - if err := os.WriteFile(localPath, []byte(`{"sdk":"agn","bin":"/bin/agn"}`), 0o600); err != nil { - t.Fatalf("write local config: %v", err) - } - - selected := selectAgentConfigPath(filepath.Join(dir, "missing.json"), localPath) - if selected != localPath { - t.Fatalf("expected local config path %q, got %q", localPath, selected) - } -} - -func TestSelectAgentConfigPathPrefersPrimary(t *testing.T) { - dir := t.TempDir() - primaryPath := filepath.Join(dir, "primary.json") - localPath := filepath.Join(dir, "config.json") - if err := os.WriteFile(primaryPath, []byte(`{"sdk":"agn","bin":"/bin/agn"}`), 0o600); err != nil { - t.Fatalf("write primary config: %v", err) - } - if err := os.WriteFile(localPath, []byte(`{"sdk":"codex","bin":"/bin/codex"}`), 0o600); err != nil { - t.Fatalf("write local config: %v", err) - } - - selected := selectAgentConfigPath(primaryPath, localPath) - if selected != primaryPath { - t.Fatalf("expected primary config path %q, got %q", primaryPath, selected) - } -} - func TestFromEnvValid(t *testing.T) { setRequiredEnv(t) configPath := writeAgentConfig(t, "codex", "/opt/bin/codex") diff --git a/test/e2e/agynd_daemon_test.go b/test/e2e/agynd_daemon_test.go index 405694e..e11bc77 100644 --- a/test/e2e/agynd_daemon_test.go +++ b/test/e2e/agynd_daemon_test.go @@ -36,7 +36,7 @@ func TestAgyndBinaryInitializesWithStubGateway(t *testing.T) { agentBinary := buildFakeAgnAgent(t) server := startAgyndGatewayStub(t) workDir := t.TempDir() - writeAgentRuntimeConfig(t, workDir, agentBinary) + installAgentRuntimeConfig(t, agentBinary) cmd := exec.Command(binary) cmd.Dir = workDir @@ -139,15 +139,66 @@ func buildAgynd(t *testing.T) string { return path } -func writeAgentRuntimeConfig(t *testing.T, runtimeDir string, agentBinary string) { +func installAgentRuntimeConfig(t *testing.T, agentBinary string) { t.Helper() - configPath := filepath.Join(runtimeDir, "config.json") + runner := newPrivilegedRunner(t) + runner.run(t, "mkdir", "-p", "/agyn-bin") + + backupPath := filepath.Join(t.TempDir(), "config.json.backup") + hadExisting := fileExists("/agyn-bin/config.json") + if hadExisting { + runner.run(t, "cp", "/agyn-bin/config.json", backupPath) + } + t.Cleanup(func() { + if hadExisting { + runner.run(t, "cp", backupPath, "/agyn-bin/config.json") + return + } + runner.run(t, "rm", "-f", "/agyn-bin/config.json") + }) + + configPath := filepath.Join(t.TempDir(), "config.json") payload := fmt.Sprintf(`{"sdk":"agn","bin":%q}`, agentBinary) if err := os.WriteFile(configPath, []byte(payload), 0o600); err != nil { - t.Fatalf("write agynd config: %v", err) + t.Fatalf("write test config: %v", err) + } + runner.run(t, "cp", configPath, "/agyn-bin/config.json") + runner.run(t, "chmod", "0644", "/agyn-bin/config.json") +} + +type privilegedRunner struct { + sudo string +} + +func newPrivilegedRunner(t *testing.T) privilegedRunner { + t.Helper() + if sudo, err := exec.LookPath("sudo"); err == nil { + return privilegedRunner{sudo: sudo} + } + if os.Geteuid() == 0 { + return privilegedRunner{} + } + t.Fatal("sudo is required to install /agyn-bin/config.json for agynd e2e") + return privilegedRunner{} +} + +func (r privilegedRunner) run(t *testing.T, name string, args ...string) { + t.Helper() + cmdArgs := args + if r.sudo != "" { + cmdArgs = append([]string{name}, args...) + name = r.sudo + } + if output, err := exec.Command(name, cmdArgs...).CombinedOutput(); err != nil { + t.Fatalf("run %s %s: %v\n%s", name, strings.Join(cmdArgs, " "), err, output) } } +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + func buildFakeAgnAgent(t *testing.T) string { t.Helper() path := filepath.Join(t.TempDir(), "fake-agn") From ad0bb5700cebec267803253065e52c0e1cece614 Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Fri, 29 May 2026 00:26:39 +0000 Subject: [PATCH 9/9] fix(daemon): use resolved codex telemetry endpoint --- internal/daemon/codexconfig.go | 8 +++----- internal/daemon/codexconfig_test.go | 28 ++++++++++++++-------------- internal/daemon/daemon.go | 26 ++++++++++++++------------ 3 files changed, 31 insertions(+), 31 deletions(-) diff --git a/internal/daemon/codexconfig.go b/internal/daemon/codexconfig.go index 51a0cdf..f9a0ebf 100644 --- a/internal/daemon/codexconfig.go +++ b/internal/daemon/codexconfig.go @@ -10,7 +10,6 @@ import ( "sync" "github.com/agynio/agynd-cli/internal/config" - "github.com/agynio/agynd-cli/internal/tracingproxy" codex "github.com/agynio/codex-sdk-go" ) @@ -52,21 +51,20 @@ var codexAuthEnvVars = []string{ codexEnvCodexAccessToken, } -func writeCodexConfig(llmBaseURL string, mcpServers []config.MCPServer) (string, error) { +func writeCodexConfig(llmBaseURL string, mcpServers []config.MCPServer, otlpEndpoint string) (string, error) { codexHome := filepath.Join(codexHomeEnv(), ".codex") if err := os.MkdirAll(codexHome, 0o700); err != nil { return "", fmt.Errorf("create codex home dir: %w", err) } configPath := filepath.Join(codexHome, "config.toml") - payload := codexConfig(llmBaseURL, mcpServers) + payload := codexConfig(llmBaseURL, mcpServers, otlpEndpoint) if err := os.WriteFile(configPath, []byte(payload), 0o600); err != nil { return "", fmt.Errorf("write codex config: %w", err) } return codexHome, nil } -func codexConfig(llmBaseURL string, mcpServers []config.MCPServer) string { - otlpEndpoint := "http://" + tracingproxy.ListenAddress +func codexConfig(llmBaseURL string, mcpServers []config.MCPServer, otlpEndpoint string) string { apiKeyEnv := codexAPIKeyEnv if isZitiLLMBaseURL(llmBaseURL) { apiKeyEnv = "" diff --git a/internal/daemon/codexconfig_test.go b/internal/daemon/codexconfig_test.go index dede3b2..f633f5f 100644 --- a/internal/daemon/codexconfig_test.go +++ b/internal/daemon/codexconfig_test.go @@ -9,7 +9,6 @@ import ( "testing" "github.com/agynio/agynd-cli/internal/config" - "github.com/agynio/agynd-cli/internal/tracingproxy" codex "github.com/agynio/codex-sdk-go" ) @@ -31,12 +30,14 @@ func (noopCodexClient) Close() error { return nil } +const testCodexOTLPEndpoint = "http://127.0.0.1:54321" + func TestWriteCodexConfig(t *testing.T) { tmpHome := t.TempDir() t.Setenv("HOME", tmpHome) baseURL := "https://example.com" - codexHome, err := writeCodexConfig(baseURL, nil) + codexHome, err := writeCodexConfig(baseURL, nil, testCodexOTLPEndpoint) if err != nil { t.Fatalf("expected config to be written, got %v", err) } @@ -62,7 +63,7 @@ func TestWriteCodexConfigHomeFallback(t *testing.T) { t.Setenv("HOME", "") baseURL := "https://example.com" - codexHome, err := writeCodexConfig(baseURL, nil) + codexHome, err := writeCodexConfig(baseURL, nil, testCodexOTLPEndpoint) if err != nil { t.Fatalf("expected config to be written, got %v", err) } @@ -89,7 +90,7 @@ func TestWriteCodexConfigForZitiOmitsAPIKeyEnv(t *testing.T) { t.Setenv("HOME", tmpHome) baseURL := "http://llm-proxy.ziti:443/v1" - codexHome, err := writeCodexConfig(baseURL, nil) + codexHome, err := writeCodexConfig(baseURL, nil, testCodexOTLPEndpoint) if err != nil { t.Fatalf("expected config to be written, got %v", err) } @@ -115,7 +116,7 @@ func TestWriteCodexConfigWithMCPServers(t *testing.T) { {Name: "memory", Port: 8100}, {Name: "cache", Port: 8200}, } - codexHome, err := writeCodexConfig(baseURL, mcpServers) + codexHome, err := writeCodexConfig(baseURL, mcpServers, testCodexOTLPEndpoint) if err != nil { t.Fatalf("expected config to be written, got %v", err) } @@ -152,7 +153,7 @@ func TestCodexEnvIncludesAPIKeyForPublicLLM(t *testing.T) { LLMAPIToken: "token-123", } - env := codexEnv(cfg, "/tmp/.codex", "/tmp", "http://127.0.0.1:4317") + env := codexEnv(cfg, "/tmp/.codex", "/tmp", testCodexOTLPEndpoint) if env[codexEnvOpenAIAPIKey] != "token-123" { t.Fatalf("expected API key in codex env, got %q", env[codexEnvOpenAIAPIKey]) @@ -167,7 +168,7 @@ func TestCodexEnvOmitsAPIKeyForZitiLLM(t *testing.T) { LLMAPIToken: "user-token", } - env := codexEnv(cfg, "/tmp/.codex", "/tmp", "http://127.0.0.1:4317") + env := codexEnv(cfg, "/tmp/.codex", "/tmp", testCodexOTLPEndpoint) if _, ok := env[codexEnvOpenAIAPIKey]; ok { t.Fatalf("expected ziti codex env to omit %s", codexEnvOpenAIAPIKey) @@ -217,8 +218,8 @@ func TestZitiCodexProcessReceivesNoAuthEnvConfig(t *testing.T) { LLMAPIToken: "agent-env-token", } - env := codexEnv(cfg, "/tmp/.codex", "/tmp", "http://127.0.0.1:4317") - configPayload := codexConfig(cfg.LLMBaseURL, nil) + env := codexEnv(cfg, "/tmp/.codex", "/tmp", testCodexOTLPEndpoint) + configPayload := codexConfig(cfg.LLMBaseURL, nil, testCodexOTLPEndpoint) seenEnv := map[string]bool{} _, err := withoutCodexAuthEnv(func() (codexClient, error) { for _, key := range codexAuthEnvVars { @@ -277,22 +278,21 @@ func assertCodexBaseEnv(t *testing.T, env map[string]string) { if env[codexEnvHome] != "/tmp" { t.Fatalf("expected HOME, got %q", env[codexEnvHome]) } - if env[codexEnvOTELExporterOTLPEndpoint] != "http://127.0.0.1:4317" { + if env[codexEnvOTELExporterOTLPEndpoint] != testCodexOTLPEndpoint { t.Fatalf("expected OTLP endpoint, got %q", env[codexEnvOTELExporterOTLPEndpoint]) } } func codexConfigWithAPIKey(baseURL string) string { - return codexConfigPayload(baseURL, `env_key = "OPENAI_API_KEY" + return codexConfigPayload(baseURL, testCodexOTLPEndpoint, `env_key = "OPENAI_API_KEY" `) } func codexConfigWithoutAPIKey(baseURL string) string { - return codexConfigPayload(baseURL, "") + return codexConfigPayload(baseURL, testCodexOTLPEndpoint, "") } -func codexConfigPayload(baseURL, apiKeyEnv string) string { - otlpEndpoint := "http://" + tracingproxy.ListenAddress +func codexConfigPayload(baseURL, otlpEndpoint, apiKeyEnv string) string { return fmt.Sprintf(`model_provider = "platform" approval_policy = "never" sandbox_mode = "danger-full-access" diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 4b0159c..8726f02 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -254,18 +254,6 @@ func newCodexDaemon(ctx context.Context, cfg config.Config, version string) (*Da tracker := codexbridge.NewTurnTracker() bridge := codexbridge.New(tracker) threadsMapping := codexbridge.NewThreadMapping() - codexHome, err := writeCodexConfig(cfg.LLMBaseURL, cfg.MCPServers) - if err != nil { - _ = setup.gatewayConn.Close() - return nil, err - } - - if err := runInitScripts(ctx, setup.agents, cfg.AgentID.String(), cfg.WorkDir); err != nil { - _ = setup.gatewayConn.Close() - return nil, err - } - codexHomeValue := codexHomeEnv() - mappingStore := codexbridge.NewThreadMappingStore(codexHomeValue) tracingProxy, err := tracingproxy.Start(ctx, tracingproxy.Config{ TracingAddress: cfg.TracingAddress, @@ -278,6 +266,20 @@ func newCodexDaemon(ctx context.Context, cfg config.Config, version string) (*Da return nil, err } otlpEndpoint := "http://" + tracingProxy.Address() + codexHome, err := writeCodexConfig(cfg.LLMBaseURL, cfg.MCPServers, otlpEndpoint) + if err != nil { + tracingProxy.Close() + _ = setup.gatewayConn.Close() + return nil, err + } + + if err := runInitScripts(ctx, setup.agents, cfg.AgentID.String(), cfg.WorkDir); err != nil { + tracingProxy.Close() + _ = setup.gatewayConn.Close() + return nil, err + } + codexHomeValue := codexHomeEnv() + mappingStore := codexbridge.NewThreadMappingStore(codexHomeValue) options := []codex.Option{ codex.WithBinary(cfg.AgentBinary), codex.WithWorkDir(cfg.WorkDir),