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
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ airlock destroy my-project
| Command | Description |
|---------|-------------|
| `airlock setup [name]` | Create and provision a VM, then take a clean snapshot |
| `airlock init [path-or-url]` | Interactive wizard that walks you through sandbox creation |
| `airlock sandbox <path-or-url>` | Create an isolated sandbox |
| `airlock run <name> <command...>` | Run a command inside a sandbox |
| `airlock shell [name]` | Open an interactive shell inside the VM |
Expand Down Expand Up @@ -93,6 +94,18 @@ Runtime auto-detection checks for: `package.json` (node), `go.mod` (go), `Cargo.

GitHub URL formats: `gh:user/repo` or `https://github.com/user/repo` — the repo name becomes the sandbox name.

### Interactive wizard

If you'd rather not remember flags, `airlock init` walks you through the common choices:

```bash
airlock init # new sandbox in the current directory
airlock init ./my-project
airlock init gh:user/repo
```

The wizard prompts for name, trust level, resource size, mounts, **development runtimes** (Node.js, Bun, Docker) and **AI tools** (claude-code, gemini, codex, opencode, ollama), then offers to save the answers to `airlock.toml` so future runs are zero-prompt.

## Security profiles

Profiles encode security policies so you don't have to be an expert. The default is `cautious`.
Expand Down Expand Up @@ -131,6 +144,12 @@ docker = false
[services]
compose = "./docker-compose.yml" # Docker Compose file to start automatically

[tools]
node = true # install Node.js + npm + pnpm
bun = false # install Bun
docker = false # install Docker
ai_tools = ["claude-code"] # claude-code, gemini, codex, opencode, ollama

[[mounts]]
path = "./api" # required: path relative to config file

Expand Down Expand Up @@ -178,6 +197,10 @@ mounts:
| `mounts[]` | `path` | string | — | Required. Relative path to mount |
| `mounts[]` | `writable` | bool | `true` | Whether mount is writable in VM |
| `mounts[]` | `inotify` | bool | `false` | Enable inotify file-watch events |
| `tools` | `node` | bool | `false` | Install Node.js (npm + pnpm) in the sandbox |
| `tools` | `bun` | bool | `false` | Install Bun |
| `tools` | `docker` | bool | `false` | Install Docker |
| `tools` | `ai_tools` | []string | `[]` | AI CLIs to install: `claude-code`, `gemini`, `codex`, `opencode`, `ollama` |

## Network control

Expand Down
42 changes: 39 additions & 3 deletions cmd/airlock/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,13 +146,20 @@ func newRootCmd(stdout, stderr io.Writer, deps *Dependencies) *cobra.Command {

func newSetupCmd(deps *Dependencies) *cobra.Command {
var nodeVersion int
var installNode, installBun, installDocker bool
var aiToolsFlag []string

cmd := &cobra.Command{
Use: "setup [flags] [name]",
Short: "Create and provision the airlock VM",
Long: `Create a fresh Lima VM, install system packages and development tools,
then take a clean snapshot for future resets. This is the first command to run
before creating any sandboxes.`,
before creating any sandboxes.

By default, Node.js, Bun, and Docker are installed for the shared 'airlock' VM
so any sandbox can use them. Pass --node=false / --bun=false / --docker=false
to skip a runtime. Use --ai-tool repeatedly to install AI tools
(claude-code, gemini, codex, opencode, ollama).`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
Expand All @@ -174,6 +181,30 @@ before creating any sandboxes.`,
return fmt.Errorf("load config: %w", err)
}

// Flag values take precedence over config values when the flag
// was explicitly set. For the shared 'airlock' VM, flag defaults
// match historical behavior (node/bun/docker on) so existing
// users don't lose their runtimes.
provisionOpts := api.ProvisionOptions{
NodeVersion: nodeVersion,
InstallNode: installNode,
InstallBun: installBun,
InstallDocker: installDocker,
AITools: append([]string(nil), aiToolsFlag...),
}
if !cmd.Flags().Changed("node") {
provisionOpts.InstallNode = cfg.Tools.Node
}
if !cmd.Flags().Changed("bun") {
provisionOpts.InstallBun = cfg.Tools.Bun
}
if !cmd.Flags().Changed("docker") {
provisionOpts.InstallDocker = cfg.Tools.Docker
}
if !cmd.Flags().Changed("ai-tool") {
provisionOpts.AITools = append([]string(nil), cfg.Tools.AITools...)
}
Comment thread
muneebs marked this conversation as resolved.

spec := api.SandboxSpec{
Name: name,
Profile: cfg.Security.Profile,
Expand Down Expand Up @@ -211,7 +242,7 @@ before creating any sandboxes.`,
},
},
}
for _, step := range deps.Provisioner.ProvisionSteps(name, nodeVersion) {
for _, step := range deps.Provisioner.ProvisionSteps(name, provisionOpts) {
step := step
phases = append(phases, tui.Phase{
Label: step.Label,
Expand Down Expand Up @@ -244,6 +275,10 @@ before creating any sandboxes.`,
}

cmd.Flags().IntVar(&nodeVersion, "node-version", 22, "Node.js major version to install")
cmd.Flags().BoolVar(&installNode, "node", true, "Install Node.js, npm, and pnpm")
cmd.Flags().BoolVar(&installBun, "bun", true, "Install Bun")
cmd.Flags().BoolVar(&installDocker, "docker", true, "Install Docker")
cmd.Flags().StringSliceVar(&aiToolsFlag, "ai-tool", nil, "AI tool to install (repeatable): claude-code, gemini, codex, opencode, ollama")

return cmd
}
Expand Down Expand Up @@ -862,7 +897,8 @@ Examples:
}

// Add provisioning steps
for _, step := range deps.Provisioner.ProvisionSteps(spec.Name, wizardCfg.VM.NodeVersion) {
provisionOpts := result.ToProvisionOptions(wizardCfg.VM.NodeVersion)
for _, step := range deps.Provisioner.ProvisionSteps(spec.Name, provisionOpts) {
step := step
phases = append(phases, tui.Phase{
Label: step.Label,
Expand Down
110 changes: 91 additions & 19 deletions cmd/airlock/cli/tui/styles.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,34 +183,106 @@ func LockColor(locked bool) lipgloss.Style {
func CleanError(err error) string {
msg := err.Error()
var clean []string

for _, line := range strings.Split(msg, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "time=") {
if idx := strings.Index(line, "msg="); idx != -1 {
msg := strings.Trim(strings.TrimPrefix(line[idx:], "msg="), `"`)
if msg != "" {
clean = append(clean, msg)
}
}
if cleaned := cleanErrorLine(line); cleaned != "" {
clean = append(clean, cleaned)
}
}

if len(clean) == 0 {
return msg
}
return strings.Join(clean, ": ")
}

func cleanErrorLine(line string) string {
line = strings.TrimSpace(line)
if line == "" {
return ""
}

if strings.HasPrefix(line, "time=") || strings.HasPrefix(line, "level=") {
return extractLogMessage(line)
}

return line
}

// indexLogField finds `key=` at a token boundary in a logfmt-style line,
// ignoring occurrences inside quoted values. Returns -1 if the key is absent.
func indexLogField(line, key string) int {
needle := key + "="
inQuote := false
escaped := false
atTokenStart := true

for i := 0; i < len(line); i++ {
c := line[i]
if escaped {
escaped = false
continue
}
if strings.HasPrefix(line, "level=") {
if idx := strings.Index(line, "msg="); idx != -1 {
msg := strings.Trim(strings.TrimPrefix(line[idx:], "msg="), `"`)
if msg != "" {
clean = append(clean, msg)
}
if inQuote {
switch c {
case '\\':
escaped = true
case '"':
inQuote = false
}
continue
}
if line != "" {
clean = append(clean, line)
switch c {
case ' ', '\t':
atTokenStart = true
continue
case '"':
inQuote = true
atTokenStart = false
continue
}
if atTokenStart && strings.HasPrefix(line[i:], needle) {
return i
}
atTokenStart = false
}
if len(clean) == 0 {
return msg
return -1
}

func extractLogMessage(line string) string {
idx := indexLogField(line, "msg")
if idx == -1 {
return ""
}
return strings.Join(clean, ": ")
rest := line[idx+len("msg="):]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if len(rest) == 0 {
return ""
}
if rest[0] == '"' {
// Quoted value: scan for the next unescaped quote so trailing
// key=value fields after the message don't bleed in.
escaped := false
for i := 1; i < len(rest); i++ {
c := rest[i]
if escaped {
escaped = false
continue
}
if c == '\\' {
escaped = true
continue
}
if c == '"' {
return strings.ReplaceAll(rest[1:i], `\"`, `"`)
}
}
// Unterminated quote — fall back to stripping the leading quote.
return rest[1:]
}
if j := strings.IndexAny(rest, " \t"); j != -1 {
return rest[:j]
}
return rest
}

func init() {
Expand Down
80 changes: 80 additions & 0 deletions cmd/airlock/cli/wizard/mappings.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,18 +185,92 @@ func IsInsecureNetwork(level NetworkLevel) bool {
return level == NetworkOngoing
}

// Runtime keys match ProvisionOptions booleans and ToolsConfig fields.
const (
RuntimeNode = "node"
RuntimeBun = "bun"
RuntimeDocker = "docker"
)

// AI tool short-name keys. These must match the strings accepted by
// api.ProvisionOptions.AITools and lima provisioner's aiToolStep switch.
const (
AIToolClaudeCode = "claude-code"
AIToolGemini = "gemini"
AIToolCodex = "codex"
AIToolOpenCode = "opencode"
AIToolOllama = "ollama"
)

// AIToolInfo describes an AI tool option shown in the wizard.
type AIToolInfo struct {
Key string
Label string // shown in multi-select
ShortLabel string // shown in summary
}

// AIToolRequiresNpm reports whether an AI tool key is installed via npm and
// therefore implies Node.js must be installed. Must stay in sync with the
// provisioner registry in internal/vm/lima.
func AIToolRequiresNpm(key string) bool {
switch key {
case AIToolClaudeCode, AIToolGemini, AIToolCodex:
return true
}
return false
}

// AITools returns all AI tool options the wizard offers. Declared once here
// so the wizard UI and tests share the same source of truth.
func AITools() []AIToolInfo {
return []AIToolInfo{
{Key: AIToolClaudeCode, Label: "Claude Code (Anthropic)", ShortLabel: "Claude Code"},
{Key: AIToolGemini, Label: "Gemini CLI (Google)", ShortLabel: "Gemini CLI"},
{Key: AIToolCodex, Label: "Codex CLI (OpenAI)", ShortLabel: "Codex CLI"},
{Key: AIToolOpenCode, Label: "OpenCode", ShortLabel: "OpenCode"},
{Key: AIToolOllama, Label: "Ollama (local LLM runtime)", ShortLabel: "Ollama"},
}
}

// WizardResult contains the final configuration from the wizard.
type WizardResult struct {
Source string
Name string
TrustLevel TrustLevel
ResourceLevel ResourceLevel
NetworkLevel NetworkLevel
InstallNode bool
InstallBun bool
InstallDocker bool
AITools []string
StartAtLogin bool
SaveConfig bool
CreateNow bool
}

// ToProvisionOptions converts the wizard tool selections into the
// api.ProvisionOptions the Provisioner consumes. AI tools are deduplicated
// preserving first-seen order.
func (r *WizardResult) ToProvisionOptions(nodeVersion int) api.ProvisionOptions {
// Deduplicate AI tools preserving first-seen order
seen := make(map[string]bool)
uniqueAITools := make([]string, 0, len(r.AITools))
for _, tool := range r.AITools {
if !seen[tool] {
seen[tool] = true
uniqueAITools = append(uniqueAITools, tool)
}
}

return api.ProvisionOptions{
NodeVersion: nodeVersion,
InstallNode: r.InstallNode,
InstallBun: r.InstallBun,
InstallDocker: r.InstallDocker,
AITools: uniqueAITools,
}
}
Comment thread
muneebs marked this conversation as resolved.

// ToSandboxSpec converts wizard result to API sandbox spec.
func (r *WizardResult) ToSandboxSpec(runtime string) api.SandboxSpec {
cpu, memory := MapResourceLevel(r.ResourceLevel)
Expand Down Expand Up @@ -231,6 +305,12 @@ func (r *WizardResult) ToConfig(runtime string) config.Config {
cfg.Security.Profile = profile
cfg.Runtime.Type = runtime
cfg.StartAtLogin = r.StartAtLogin
cfg.Tools = config.ToolsConfig{
Node: r.InstallNode,
Bun: r.InstallBun,
Docker: r.InstallDocker,
AITools: append([]string(nil), r.AITools...),
}
return cfg
}

Expand Down
Loading
Loading