From e8734169041da65b5047b04806df2e851e5dd76d Mon Sep 17 00:00:00 2001 From: John McBride Date: Sat, 28 Feb 2026 08:22:49 -0500 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20support=20"extra=5Fpackages?= =?UTF-8?q?"=20for=20agents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: John McBride --- jcard.toml | 1 + pkg/config/config.go | 49 +++++++++++++++ pkg/config/config_test.go | 128 ++++++++++++++++++++++++++++++++++++++ pkg/config/marshal.go | 10 +++ 4 files changed, 188 insertions(+) diff --git a/jcard.toml b/jcard.toml index aa22e4f..e95978d 100644 --- a/jcard.toml +++ b/jcard.toml @@ -3,3 +3,4 @@ mixtape = "opencode-mixtape:latest" [agent] harness = "opencode" prompt = "Hello world!" +extra_packages = [ "cowsay" ] diff --git a/pkg/config/config.go b/pkg/config/config.go index 909c219..f8b6085 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -13,6 +13,19 @@ import ( "github.com/BurntSushi/toml" ) +// AgentType defines how the agent process is executed inside the guest. +type AgentType string + +const ( + // AgentTypeSandboxed runs the agent in a gVisor (runsc) sandbox with + // read-only /nix/store bind mounts. This is the default. + AgentTypeSandboxed AgentType = "sandboxed" + + // AgentTypeNative runs the agent directly on the host in a tmux + // session as the agent user (the original agentd behavior). + AgentTypeNative AgentType = "native" +) + // JcardConfig is the top-level configuration parsed from a jcard.toml file. type JcardConfig struct { // Mixtape is the StereOS image to boot, in "name:tag" format. @@ -88,6 +101,11 @@ type SharedMount struct { // AgentConfig defines what agent harness to run and how agentd manages it. // This section is passed through to agentd on the guest. type AgentConfig struct { + // Type selects the agent execution mode. + // "sandboxed" (default) runs in a gVisor container with /nix/store sharing. + // "native" runs directly on the host in a tmux session. + Type AgentType `toml:"type,omitempty"` + // Harness is the agent harness to use: "claude-code", "opencode", // "gemini-cli", or "custom". Harness string `toml:"harness"` @@ -116,8 +134,15 @@ type AgentConfig struct { GracePeriod string `toml:"grace_period"` // Session is the tmux session name. Defaults to the harness name. + // Only used for native agents. Session string `toml:"session"` + // ExtraPackages is a list of additional Nix package attribute names + // to install into the sandbox (e.g. ["ripgrep", "fd", "python311"]). + // These are resolved against the system's nixpkgs and materialized + // into /nix/store at agent launch time. Only used for sandboxed agents. + ExtraPackages []string `toml:"extra_packages,omitempty"` + // Env are environment variables set only for the agent process. Env map[string]string `toml:"env"` } @@ -204,6 +229,10 @@ func applyDefaults(cfg *JcardConfig) { } } + if cfg.Agent.Type == "" { + cfg.Agent.Type = AgentTypeSandboxed + } + if cfg.Agent.Restart == "" { cfg.Agent.Restart = "no" } @@ -270,6 +299,14 @@ func validate(cfg *JcardConfig) error { } } + // Validate agent type. + switch cfg.Agent.Type { + case AgentTypeSandboxed, AgentTypeNative: + // valid + default: + return fmt.Errorf("agent.type must be \"sandboxed\" or \"native\", got %q", cfg.Agent.Type) + } + if cfg.Agent.Harness != "" { validHarnesses := map[string]bool{ "claude-code": true, @@ -291,6 +328,18 @@ func validate(cfg *JcardConfig) error { return fmt.Errorf("agent.max_restarts must be >= 0, got %d", cfg.Agent.MaxRestarts) } + // Validate extra_packages entries are non-empty strings. + for i, pkg := range cfg.Agent.ExtraPackages { + if strings.TrimSpace(pkg) == "" { + return fmt.Errorf("agent.extra_packages[%d] is empty", i) + } + } + + // extra_packages is only valid for sandboxed agents. + if cfg.Agent.Type != AgentTypeSandboxed && len(cfg.Agent.ExtraPackages) > 0 { + return fmt.Errorf("agent.extra_packages is only supported for type=\"sandboxed\"") + } + return nil } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index df6d611..1066e0c 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -59,6 +59,10 @@ mixtape = "base" It("should apply default agent workdir", func() { Expect(cfg.Agent.Workdir).To(Equal("/workspace")) }) + + It("should apply default agent type as sandboxed", func() { + Expect(cfg.Agent.Type).To(Equal(AgentTypeSandboxed)) + }) }) Context("with a full config", func() { @@ -212,6 +216,25 @@ mode = "nat" forwards = [ { host = 0, guest = 80, proto = "tcp" }, ] +`), + Entry("invalid agent type", ` +mixtape = "base" + +[agent] +type = "docker" +`), + Entry("extra_packages with empty entry", ` +mixtape = "base" + +[agent] +extra_packages = ["ripgrep", "", "fd"] +`), + Entry("extra_packages on native agent", ` +mixtape = "base" + +[agent] +type = "native" +extra_packages = ["ripgrep"] `), ) }) @@ -320,4 +343,109 @@ guest = "/code" Expect(cfg.Agent.Workdir).To(Equal("/code")) }) }) + + Describe("Agent type", func() { + It("should parse type=sandboxed explicitly", func() { + dir := GinkgoT().TempDir() + tomlContent := ` +mixtape = "base" + +[agent] +type = "sandboxed" +` + cfgPath := filepath.Join(dir, "jcard.toml") + Expect(os.WriteFile(cfgPath, []byte(tomlContent), 0644)).To(Succeed()) + + cfg, err := Load(cfgPath) + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.Agent.Type).To(Equal(AgentTypeSandboxed)) + }) + + It("should parse type=native", func() { + dir := GinkgoT().TempDir() + tomlContent := ` +mixtape = "base" + +[agent] +type = "native" +` + cfgPath := filepath.Join(dir, "jcard.toml") + Expect(os.WriteFile(cfgPath, []byte(tomlContent), 0644)).To(Succeed()) + + cfg, err := Load(cfgPath) + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.Agent.Type).To(Equal(AgentTypeNative)) + }) + }) + + Describe("Extra packages", func() { + It("should parse extra_packages for sandboxed agents", func() { + dir := GinkgoT().TempDir() + tomlContent := ` +mixtape = "base" + +[agent] +type = "sandboxed" +extra_packages = ["ripgrep", "fd", "python311"] +` + cfgPath := filepath.Join(dir, "jcard.toml") + Expect(os.WriteFile(cfgPath, []byte(tomlContent), 0644)).To(Succeed()) + + cfg, err := Load(cfgPath) + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.Agent.ExtraPackages).To(Equal([]string{"ripgrep", "fd", "python311"})) + }) + + It("should accept empty extra_packages list", func() { + dir := GinkgoT().TempDir() + tomlContent := ` +mixtape = "base" + +[agent] +extra_packages = [] +` + cfgPath := filepath.Join(dir, "jcard.toml") + Expect(os.WriteFile(cfgPath, []byte(tomlContent), 0644)).To(Succeed()) + + cfg, err := Load(cfgPath) + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.Agent.ExtraPackages).To(BeEmpty()) + }) + + It("should accept sandboxed agent without extra_packages", func() { + dir := GinkgoT().TempDir() + tomlContent := ` +mixtape = "base" + +[agent] +type = "sandboxed" +` + cfgPath := filepath.Join(dir, "jcard.toml") + Expect(os.WriteFile(cfgPath, []byte(tomlContent), 0644)).To(Succeed()) + + cfg, err := Load(cfgPath) + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.Agent.ExtraPackages).To(BeNil()) + }) + + It("should round-trip extra_packages through Marshal", func() { + cfg := &JcardConfig{ + Mixtape: "base", + Agent: AgentConfig{ + Harness: "claude-code", + ExtraPackages: []string{"ripgrep", "fd"}, + }, + } + data, err := Marshal(cfg) + Expect(err).NotTo(HaveOccurred()) + + dir := GinkgoT().TempDir() + cfgPath := filepath.Join(dir, "jcard.toml") + Expect(os.WriteFile(cfgPath, data, 0644)).To(Succeed()) + + loaded, err := Load(cfgPath) + Expect(err).NotTo(HaveOccurred()) + Expect(loaded.Agent.ExtraPackages).To(Equal([]string{"ripgrep", "fd"})) + }) + }) }) diff --git a/pkg/config/marshal.go b/pkg/config/marshal.go index a4fb7d6..d79ec0a 100644 --- a/pkg/config/marshal.go +++ b/pkg/config/marshal.go @@ -84,6 +84,11 @@ mode = "nat" # Agent runtime configuration. # Defines what agent harness to run and how agentd manages it. [agent] +# Agent execution mode: +# "sandboxed" -> runs in a gVisor (runsc) container with /nix/store (default) +# "native" -> runs directly in a tmux session as the agent user +# type = "sandboxed" + # The agent harness to use. # Built-in harnesses: "claude-code", "opencode", "gemini-cli", "custom" harness = "claude-code" @@ -118,6 +123,11 @@ restart = "no" # Grace period for SIGTERM before SIGKILL on shutdown or timeout. # grace_period = "30s" +# Extra Nix packages to install into the gVisor sandbox. +# These are resolved against nixpkgs and materialized into /nix/store +# at agent launch time. Only used when type = "sandboxed". +# extra_packages = ["ripgrep", "fd", "python311"] + # Environment variables set *only* for the agent process. # [agent.env] # MY_VAR = "my_value"