Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions jcard.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ mixtape = "opencode-mixtape:latest"
[agent]
harness = "opencode"
prompt = "Hello world!"
extra_packages = [ "cowsay" ]
49 changes: 49 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -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"`
}
Expand Down Expand Up @@ -204,6 +229,10 @@ func applyDefaults(cfg *JcardConfig) {
}
}

if cfg.Agent.Type == "" {
cfg.Agent.Type = AgentTypeSandboxed
}

if cfg.Agent.Restart == "" {
cfg.Agent.Restart = "no"
}
Expand Down Expand Up @@ -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,
Expand All @@ -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
}

Expand Down
128 changes: 128 additions & 0 deletions pkg/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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"]
`),
)
})
Expand Down Expand Up @@ -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"}))
})
})
})
10 changes: 10 additions & 0 deletions pkg/config/marshal.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down