From 0ffc19bd769bcb6361746ab2de0642d9f48440e4 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Sun, 7 Jun 2026 18:48:56 -0700 Subject: [PATCH 1/2] feat: agent.yaml parser, payload renderer, and go.work workspace Phase 2 foundation: adds the agent config package that replaces profile.toml with a declarative YAML format. The payload renderer generates self-contained payload directories (env.sh, run.sh, bin/, task.md) uploaded to sandboxes. New: - internal/agent: AgentConfig struct, Parse/ParseFile, BuildEnvSh, BuildRunSh, RenderPayload with path traversal protection on include: paths - go.work: Go workspace linking root module and sandbox/launcher module, enabling the launcher to import internal/ packages in follow-up PRs - 18 tests covering parser validation, env generation, run.sh template, payload rendering, and security edge cases Also: - providers.toml: change gws type from custom to openshell (matches reality after GWS native provider in PR 44) --- go.work | 6 + internal/agent/agent.go | 180 +++++++++++++++++++ internal/agent/agent_test.go | 326 +++++++++++++++++++++++++++++++++++ providers.toml | 2 +- 4 files changed, 513 insertions(+), 1 deletion(-) create mode 100644 go.work create mode 100644 internal/agent/agent.go create mode 100644 internal/agent/agent_test.go diff --git a/go.work b/go.work new file mode 100644 index 0000000..f94cec8 --- /dev/null +++ b/go.work @@ -0,0 +1,6 @@ +go 1.22.4 + +use ( + . + ./sandbox/launcher +) diff --git a/internal/agent/agent.go b/internal/agent/agent.go new file mode 100644 index 0000000..e047d62 --- /dev/null +++ b/internal/agent/agent.go @@ -0,0 +1,180 @@ +package agent + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "gopkg.in/yaml.v3" +) + +type ProviderRef struct { + Type string `yaml:"type"` + Config map[string]string `yaml:"config,omitempty"` +} + +type AgentConfig struct { + Name string `yaml:"name"` + Providers []ProviderRef `yaml:"providers"` + Task string `yaml:"task,omitempty"` + Entrypoint string `yaml:"entrypoint,omitempty"` + TTY *bool `yaml:"tty,omitempty"` + Policy string `yaml:"policy,omitempty"` + Image string `yaml:"image,omitempty"` + Include []string `yaml:"include,omitempty"` +} + +func (c *AgentConfig) NoTTY() bool { + if c.TTY == nil { + return false + } + return !*c.TTY +} + +func (c *AgentConfig) EffectiveEntrypoint() string { + if c.Entrypoint == "" { + return "claude --bare" + } + return c.Entrypoint +} + +func (c *AgentConfig) ProviderNames() []string { + names := make([]string, len(c.Providers)) + for i, p := range c.Providers { + names[i] = p.Type + } + return names +} + +func ParseFile(path string) (*AgentConfig, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading agent config: %w", err) + } + return Parse(data) +} + +func Parse(data []byte) (*AgentConfig, error) { + var cfg AgentConfig + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parsing agent config: %w", err) + } + if cfg.Name == "" { + return nil, fmt.Errorf("agent config: name is required") + } + for i, p := range cfg.Providers { + if p.Type == "" { + return nil, fmt.Errorf("agent config: providers[%d].type is required", i) + } + } + return &cfg, nil +} + +func (c *AgentConfig) BuildEnvSh() string { + env := make(map[string]string) + for _, p := range c.Providers { + for k, v := range p.Config { + env[k] = v + } + } + if len(env) == 0 { + return "" + } + keys := make([]string, 0, len(env)) + for k := range env { + keys = append(keys, k) + } + sort.Strings(keys) + var b strings.Builder + for _, k := range keys { + fmt.Fprintf(&b, "export %s=%q\n", k, env[k]) + } + return b.String() +} + +func (c *AgentConfig) BuildRunSh() string { + var b strings.Builder + b.WriteString("#!/usr/bin/env bash\nset -euo pipefail\n\n") + b.WriteString("PAYLOAD_DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n\n") + b.WriteString("# Source environment\n") + b.WriteString("if [[ -f \"$PAYLOAD_DIR/env.sh\" ]]; then\n") + b.WriteString(" . \"$PAYLOAD_DIR/env.sh\"\n") + b.WriteString("fi\n\n") + b.WriteString("# Prepend payload bin to PATH\n") + b.WriteString("export PATH=\"$PAYLOAD_DIR/bin:$PATH\"\n\n") + b.WriteString("# Git auth\n") + b.WriteString("gh auth setup-git 2>/dev/null || true\n\n") + b.WriteString("# Validate entrypoint\n") + entrypoint := c.EffectiveEntrypoint() + epBin := strings.Fields(entrypoint)[0] + fmt.Fprintf(&b, "if ! command -v %q >/dev/null 2>&1; then\n", epBin) + fmt.Fprintf(&b, " echo \"ERROR: entrypoint %q not found in PATH\" >&2\n", epBin) + b.WriteString(" exit 1\n") + b.WriteString("fi\n\n") + b.WriteString("# Execute entrypoint\n") + if c.Task != "" { + fmt.Fprintf(&b, "exec %s \"$PAYLOAD_DIR/task.md\"\n", entrypoint) + } else { + fmt.Fprintf(&b, "exec %s\n", entrypoint) + } + return b.String() +} + +func RenderPayload(cfg *AgentConfig, baseDir, destDir string) error { + if err := os.MkdirAll(destDir, 0o755); err != nil { + return fmt.Errorf("creating payload dir: %w", err) + } + binDir := filepath.Join(destDir, "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + return fmt.Errorf("creating bin dir: %w", err) + } + + if envContent := cfg.BuildEnvSh(); envContent != "" { + if err := os.WriteFile(filepath.Join(destDir, "env.sh"), []byte(envContent), 0o644); err != nil { + return fmt.Errorf("writing env.sh: %w", err) + } + } + + runSh := cfg.BuildRunSh() + if err := os.WriteFile(filepath.Join(destDir, "run.sh"), []byte(runSh), 0o755); err != nil { + return fmt.Errorf("writing run.sh: %w", err) + } + + if cfg.Task != "" { + taskSrc := cfg.Task + if !filepath.IsAbs(taskSrc) { + taskSrc = filepath.Join(baseDir, taskSrc) + } + data, err := os.ReadFile(taskSrc) + if err != nil { + return fmt.Errorf("reading task file %s: %w", cfg.Task, err) + } + expanded := os.ExpandEnv(string(data)) + if err := os.WriteFile(filepath.Join(destDir, "task.md"), []byte(expanded), 0o644); err != nil { + return fmt.Errorf("writing task.md: %w", err) + } + } + + for _, inc := range cfg.Include { + src := inc + if !filepath.IsAbs(src) { + src = filepath.Join(baseDir, src) + } + clean := filepath.Clean(src) + if !strings.HasPrefix(clean, filepath.Clean(baseDir)) && !filepath.IsAbs(inc) { + return fmt.Errorf("include path %q escapes base directory", inc) + } + data, err := os.ReadFile(clean) + if err != nil { + return fmt.Errorf("reading include %s: %w", inc, err) + } + dst := filepath.Join(destDir, filepath.Base(inc)) + if err := os.WriteFile(dst, data, 0o644); err != nil { + return fmt.Errorf("writing include %s: %w", filepath.Base(inc), err) + } + } + + return nil +} diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go new file mode 100644 index 0000000..f3844cd --- /dev/null +++ b/internal/agent/agent_test.go @@ -0,0 +1,326 @@ +package agent + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestParse_Valid(t *testing.T) { + data := []byte(` +name: daily-standup +providers: + - type: atlassian + config: + JIRA_USERNAME: alice@example.com + JIRA_URL: https://issues.redhat.com + - type: github +task: tasks/daily-standup.md +entrypoint: claude --bare +`) + cfg, err := Parse(data) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if cfg.Name != "daily-standup" { + t.Errorf("Name = %q, want daily-standup", cfg.Name) + } + if len(cfg.Providers) != 2 { + t.Fatalf("Providers = %d, want 2", len(cfg.Providers)) + } + if cfg.Providers[0].Type != "atlassian" { + t.Errorf("Providers[0].Type = %q, want atlassian", cfg.Providers[0].Type) + } + if cfg.Providers[0].Config["JIRA_USERNAME"] != "alice@example.com" { + t.Errorf("JIRA_USERNAME = %q", cfg.Providers[0].Config["JIRA_USERNAME"]) + } + if cfg.Task != "tasks/daily-standup.md" { + t.Errorf("Task = %q", cfg.Task) + } +} + +func TestParse_MissingName(t *testing.T) { + data := []byte(`providers: [{type: github}]`) + _, err := Parse(data) + if err == nil { + t.Fatal("expected error for missing name") + } + if !strings.Contains(err.Error(), "name is required") { + t.Errorf("error = %q, want 'name is required'", err) + } +} + +func TestParse_MissingProviderType(t *testing.T) { + data := []byte(` +name: test +providers: + - config: + FOO: bar +`) + _, err := Parse(data) + if err == nil { + t.Fatal("expected error for missing provider type") + } + if !strings.Contains(err.Error(), "type is required") { + t.Errorf("error = %q, want 'type is required'", err) + } +} + +func TestParse_InvalidYAML(t *testing.T) { + data := []byte(`name: [invalid yaml`) + _, err := Parse(data) + if err == nil { + t.Fatal("expected error for invalid YAML") + } + if !strings.Contains(err.Error(), "parsing agent config") { + t.Errorf("error = %q, want 'parsing agent config'", err) + } +} + +func TestParseFile_NotFound(t *testing.T) { + _, err := ParseFile("/nonexistent/agent.yaml") + if err == nil { + t.Fatal("expected error for missing file") + } +} + +func TestParse_EmptyProviders(t *testing.T) { + data := []byte(` +name: minimal +providers: [] +`) + cfg, err := Parse(data) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if len(cfg.Providers) != 0 { + t.Errorf("Providers = %d, want 0", len(cfg.Providers)) + } +} + +func TestProviderNames(t *testing.T) { + cfg := &AgentConfig{ + Providers: []ProviderRef{ + {Type: "github"}, + {Type: "atlassian"}, + {Type: "gws"}, + }, + } + names := cfg.ProviderNames() + if len(names) != 3 { + t.Fatalf("len = %d, want 3", len(names)) + } + if names[0] != "github" || names[1] != "atlassian" || names[2] != "gws" { + t.Errorf("names = %v", names) + } +} + +func TestEffectiveEntrypoint(t *testing.T) { + cfg := &AgentConfig{} + if ep := cfg.EffectiveEntrypoint(); ep != "claude --bare" { + t.Errorf("default = %q, want 'claude --bare'", ep) + } + cfg.Entrypoint = "codex" + if ep := cfg.EffectiveEntrypoint(); ep != "codex" { + t.Errorf("custom = %q, want 'codex'", ep) + } +} + +func TestNoTTY(t *testing.T) { + cfg := &AgentConfig{} + if cfg.NoTTY() { + t.Error("nil TTY should default to false (interactive)") + } + f := false + cfg.TTY = &f + if !cfg.NoTTY() { + t.Error("TTY=false should return NoTTY=true") + } + tr := true + cfg.TTY = &tr + if cfg.NoTTY() { + t.Error("TTY=true should return NoTTY=false") + } +} + +func TestBuildEnvSh(t *testing.T) { + cfg := &AgentConfig{ + Providers: []ProviderRef{ + {Type: "atlassian", Config: map[string]string{ + "JIRA_URL": "https://issues.redhat.com", + "JIRA_USERNAME": "alice", + }}, + }, + } + env := cfg.BuildEnvSh() + if !strings.Contains(env, `export JIRA_URL="https://issues.redhat.com"`) { + t.Errorf("missing JIRA_URL in:\n%s", env) + } + if !strings.Contains(env, `export JIRA_USERNAME="alice"`) { + t.Errorf("missing JIRA_USERNAME in:\n%s", env) + } +} + +func TestBuildEnvSh_Empty(t *testing.T) { + cfg := &AgentConfig{Providers: []ProviderRef{{Type: "github"}}} + if env := cfg.BuildEnvSh(); env != "" { + t.Errorf("expected empty env.sh, got:\n%s", env) + } +} + +func TestBuildEnvSh_Sorted(t *testing.T) { + cfg := &AgentConfig{ + Providers: []ProviderRef{ + {Type: "test", Config: map[string]string{"Z_VAR": "z", "A_VAR": "a"}}, + }, + } + env := cfg.BuildEnvSh() + aIdx := strings.Index(env, "A_VAR") + zIdx := strings.Index(env, "Z_VAR") + if aIdx > zIdx { + t.Error("env.sh should be sorted alphabetically") + } +} + +func TestBuildRunSh(t *testing.T) { + cfg := &AgentConfig{ + Entrypoint: "claude --bare", + Task: "tasks/standup.md", + } + runSh := cfg.BuildRunSh() + if !strings.Contains(runSh, "#!/usr/bin/env bash") { + t.Error("missing shebang") + } + if !strings.Contains(runSh, ". \"$PAYLOAD_DIR/env.sh\"") { + t.Error("missing env.sh source") + } + if !strings.Contains(runSh, "gh auth setup-git") { + t.Error("missing gh auth setup-git") + } + if !strings.Contains(runSh, `command -v "claude"`) { + t.Error("missing entrypoint validation") + } + if !strings.Contains(runSh, `exec claude --bare "$PAYLOAD_DIR/task.md"`) { + t.Errorf("missing task exec in:\n%s", runSh) + } +} + +func TestBuildRunSh_NoTask(t *testing.T) { + cfg := &AgentConfig{Entrypoint: "codex"} + runSh := cfg.BuildRunSh() + if !strings.Contains(runSh, "exec codex\n") { + t.Errorf("expected bare exec, got:\n%s", runSh) + } + if strings.Contains(runSh, "task.md") { + t.Error("should not reference task.md when no task set") + } +} + +func TestRenderPayload(t *testing.T) { + baseDir := t.TempDir() + os.WriteFile(filepath.Join(baseDir, "my-task.md"), []byte("Do the thing: ${USER}"), 0o644) + + cfg := &AgentConfig{ + Name: "test-agent", + Providers: []ProviderRef{ + {Type: "atlassian", Config: map[string]string{"JIRA_URL": "https://jira.example.com"}}, + }, + Task: "my-task.md", + Entrypoint: "claude --bare", + } + + destDir := filepath.Join(t.TempDir(), "payload") + if err := RenderPayload(cfg, baseDir, destDir); err != nil { + t.Fatalf("RenderPayload: %v", err) + } + + if _, err := os.Stat(filepath.Join(destDir, "env.sh")); err != nil { + t.Error("missing env.sh") + } + if _, err := os.Stat(filepath.Join(destDir, "run.sh")); err != nil { + t.Error("missing run.sh") + } + if _, err := os.Stat(filepath.Join(destDir, "task.md")); err != nil { + t.Error("missing task.md") + } + if _, err := os.Stat(filepath.Join(destDir, "bin")); err != nil { + t.Error("missing bin/ directory") + } + + envData, _ := os.ReadFile(filepath.Join(destDir, "env.sh")) + if !strings.Contains(string(envData), "JIRA_URL") { + t.Errorf("env.sh missing JIRA_URL:\n%s", envData) + } + + runData, _ := os.ReadFile(filepath.Join(destDir, "run.sh")) + if !strings.Contains(string(runData), "exec claude") { + t.Errorf("run.sh missing entrypoint:\n%s", runData) + } + + taskData, _ := os.ReadFile(filepath.Join(destDir, "task.md")) + if strings.Contains(string(taskData), "${USER}") { + t.Error("task.md should have envsubst applied") + } +} + +func TestRenderPayload_NoEnv(t *testing.T) { + cfg := &AgentConfig{ + Name: "minimal", + Providers: []ProviderRef{{Type: "github"}}, + } + + destDir := filepath.Join(t.TempDir(), "payload") + if err := RenderPayload(cfg, t.TempDir(), destDir); err != nil { + t.Fatalf("RenderPayload: %v", err) + } + + if _, err := os.Stat(filepath.Join(destDir, "env.sh")); !os.IsNotExist(err) { + t.Error("env.sh should not exist when no config vars") + } + if _, err := os.Stat(filepath.Join(destDir, "run.sh")); err != nil { + t.Error("run.sh should always be created") + } +} + +func TestRenderPayload_Include(t *testing.T) { + baseDir := t.TempDir() + os.WriteFile(filepath.Join(baseDir, "helper.sh"), []byte("echo hi"), 0o644) + + cfg := &AgentConfig{ + Name: "with-include", + Providers: []ProviderRef{{Type: "github"}}, + Include: []string{"helper.sh"}, + } + + destDir := filepath.Join(t.TempDir(), "payload") + if err := RenderPayload(cfg, baseDir, destDir); err != nil { + t.Fatalf("RenderPayload: %v", err) + } + + data, err := os.ReadFile(filepath.Join(destDir, "helper.sh")) + if err != nil { + t.Fatal("missing included file") + } + if string(data) != "echo hi" { + t.Errorf("include content = %q", data) + } +} + +func TestRenderPayload_IncludePathTraversal(t *testing.T) { + baseDir := t.TempDir() + cfg := &AgentConfig{ + Name: "evil", + Providers: []ProviderRef{{Type: "github"}}, + Include: []string{"../../etc/passwd"}, + } + + destDir := filepath.Join(t.TempDir(), "payload") + err := RenderPayload(cfg, baseDir, destDir) + if err == nil { + t.Fatal("expected error for path traversal") + } + if !strings.Contains(err.Error(), "escapes base directory") { + t.Errorf("error = %q, want 'escapes base directory'", err) + } +} diff --git a/providers.toml b/providers.toml index 41b71de..88429fa 100644 --- a/providers.toml +++ b/providers.toml @@ -37,7 +37,7 @@ inputs = [ [[providers]] name = "gws" -type = "custom" +type = "openshell" description = "Google Workspace (Gmail, Calendar, Drive)" upstream = "https://github.com/NVIDIA/OpenShell/issues/1268" inputs = [ From a5b2565e9c1699e98583fb107e56b50e1f07c39a Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Sun, 7 Jun 2026 18:57:40 -0700 Subject: [PATCH 2/2] remove go.work: pivot to single-binary launcher approach The launcher will be replaced by the harness binary in a container image, eliminating the need for a Go workspace. The separate sandbox/launcher module will be deleted when the harness gains in-cluster support. --- go.work | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 go.work diff --git a/go.work b/go.work deleted file mode 100644 index f94cec8..0000000 --- a/go.work +++ /dev/null @@ -1,6 +0,0 @@ -go 1.22.4 - -use ( - . - ./sandbox/launcher -)