Implement layered sandbox configuration system
- Add global.yaml base config (renamed from sandbox.yaml) - Add backend-specific configs (claude.yaml, kiro-cli.yaml) - Add role configs (developer, reviewer, tester, architect, etc.) - Add task configs (git, docker, go, rust, python, nodejs, etc.) - Implement config merging: global + backend + role + tasks - Update workflows to use roles and tasks - Create tester role with read-only source, execute-only bins - Fix reviewer role to use rox for bin directories - Auto-create RW directories that don't exist - Make backend configs required (fail if missing) - Update mkfile to install cfg files to ~/.config/anvillm - Remove Role-Based Sessions from IDEAS.md (now implemented)
This commit is contained in:
parent
d149f5b595
commit
873f839278
|
|
@ -8,3 +8,4 @@ anvilweb
|
|||
.kiro
|
||||
*.elc
|
||||
Assist
|
||||
anvilsrv
|
||||
|
|
|
|||
162
IDEAS.md
162
IDEAS.md
|
|
@ -1,167 +1,5 @@
|
|||
# IDEAS.md
|
||||
|
||||
## Role-Based Sessions
|
||||
|
||||
Specialized session types with enforced capabilities via context and sandbox configuration.
|
||||
|
||||
### Current Implementation
|
||||
|
||||
Already possible using existing features:
|
||||
- `context` file defines role behavior
|
||||
- Sandbox config enforces filesystem boundaries
|
||||
- Scripts create role-specific sessions (see `scripts/DevReview`, `scripts/Planning`)
|
||||
|
||||
### Example Roles
|
||||
|
||||
**Developer**:
|
||||
- Context: implements features, stages changes, requests reviews
|
||||
- Sandbox: read-write in project directory
|
||||
- Can: modify code, run tests, commit to feature branches
|
||||
- Cannot: push to main, merge PRs
|
||||
|
||||
**Reviewer**:
|
||||
- Context: reviews staged changes, provides feedback
|
||||
- Sandbox: read-only filesystem
|
||||
- Can: read code, run git diff, analyze changes
|
||||
- Cannot: modify files, commit, push
|
||||
|
||||
**Planner**:
|
||||
- Context: analyzes requirements, creates task breakdowns
|
||||
- Sandbox: read-only filesystem, no git write
|
||||
- Can: read codebase, write plans to separate files
|
||||
- Cannot: modify code, commit, push
|
||||
|
||||
**Tester**:
|
||||
- Context: writes and runs tests
|
||||
- Sandbox: read-write in test directories only
|
||||
- Can: create test files, run test commands
|
||||
- Cannot: modify source code outside test dirs
|
||||
|
||||
### Implementation
|
||||
|
||||
**1. Role Templates**
|
||||
|
||||
Store role definitions in `~/.config/anvillm/roles/`:
|
||||
|
||||
```
|
||||
roles/
|
||||
├── developer.txt
|
||||
├── reviewer.txt
|
||||
├── planner.txt
|
||||
└── tester.txt
|
||||
```
|
||||
|
||||
Scripts load templates:
|
||||
```bash
|
||||
cat ~/.config/anvillm/roles/developer.txt | 9p write agent/$SESSION_ID/context
|
||||
```
|
||||
|
||||
**2. Role-Specific Sandbox Configs**
|
||||
|
||||
Create per-role sandbox configs in `~/.config/anvillm/sandbox-roles/`:
|
||||
|
||||
```yaml
|
||||
# sandbox-roles/reviewer.yaml
|
||||
filesystem:
|
||||
ro:
|
||||
- "{CWD}"
|
||||
- "/usr"
|
||||
- "/lib"
|
||||
rw: [] # No write access
|
||||
network:
|
||||
enabled: false
|
||||
```
|
||||
|
||||
```yaml
|
||||
# sandbox-roles/developer.yaml
|
||||
filesystem:
|
||||
ro:
|
||||
- "/usr"
|
||||
- "/lib"
|
||||
rw:
|
||||
- "{CWD}"
|
||||
- "{TMPDIR}"
|
||||
network:
|
||||
enabled: true
|
||||
```
|
||||
|
||||
Session creation with role:
|
||||
```bash
|
||||
echo 'new kiro /project reviewer' | 9p write agent/ctl
|
||||
# Daemon loads sandbox-roles/reviewer.yaml for this session
|
||||
```
|
||||
|
||||
**3. Role Metadata**
|
||||
|
||||
Add `agent/{session}/role` file to 9P filesystem:
|
||||
|
||||
```
|
||||
agent/<id>/role # "developer" | "reviewer" | "planner" | "tester"
|
||||
```
|
||||
|
||||
Benefits:
|
||||
- UI can filter/group by role
|
||||
- Scripts can discover sessions by role
|
||||
- Easier than parsing aliases
|
||||
|
||||
**4. Role Discovery**
|
||||
|
||||
Find sessions by role:
|
||||
```bash
|
||||
# List all reviewer sessions
|
||||
9p read agent/list | while read id alias state pid cwd; do
|
||||
role=$(9p read agent/$id/role)
|
||||
[ "$role" = "reviewer" ] && echo "$id $alias"
|
||||
done
|
||||
```
|
||||
|
||||
Or use peer-bot skill (already works):
|
||||
```
|
||||
Find the reviewer agent and send them: "Please review staged changes"
|
||||
```
|
||||
|
||||
### Enforcement Layers
|
||||
|
||||
**Soft enforcement** (current):
|
||||
- Context instructions tell bot what its role is
|
||||
- Bot follows instructions (usually)
|
||||
|
||||
**Hard enforcement** (with role-specific sandboxes):
|
||||
- Filesystem boundaries prevent unauthorized writes
|
||||
- Reviewer literally cannot modify code files
|
||||
- Planner cannot commit or push
|
||||
|
||||
### Example Workflow
|
||||
|
||||
```bash
|
||||
# Create developer session with role
|
||||
DEV_ID=$(echo 'new kiro /project developer' | 9p write agent/ctl && ...)
|
||||
cat ~/.config/anvillm/roles/developer.txt | 9p write agent/$DEV_ID/context
|
||||
|
||||
# Create reviewer session with role
|
||||
REV_ID=$(echo 'new kiro /project reviewer' | 9p write agent/ctl && ...)
|
||||
cat ~/.config/anvillm/roles/reviewer.txt | 9p write agent/$REV_ID/context
|
||||
|
||||
# Developer implements feature
|
||||
echo "Implement user authentication" | 9p write agent/$DEV_ID/in
|
||||
|
||||
# Developer requests review (via peer-bot skill)
|
||||
# Reviewer provides feedback
|
||||
# Loop until LGTM
|
||||
```
|
||||
|
||||
### Benefits
|
||||
|
||||
- **Separation of concerns**: Each role has clear responsibilities
|
||||
- **Safety**: Sandbox prevents accidental damage (reviewer can't modify code)
|
||||
- **Reusability**: Role templates used across projects
|
||||
- **Discoverability**: Find sessions by role, not just alias
|
||||
- **Scalability**: Spawn multiple workers of same role
|
||||
|
||||
### Already Working
|
||||
|
||||
See `scripts/DevReview` and `scripts/Planning` for working examples of role-based multi-agent workflows using context system.
|
||||
|
||||
## Persistent Work Tracking (Beads)
|
||||
|
||||
Git-backed work units that survive orchestrator/bot crashes and preserve workflow state.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
# Claude backend requirements
|
||||
filesystem:
|
||||
rw:
|
||||
- "{HOME}/.claude"
|
||||
- "{HOME}/.claude.json"
|
||||
- "{HOME}/.config/anvillm"
|
||||
network:
|
||||
unrestricted: true
|
||||
env:
|
||||
- ANTHROPIC_API_KEY
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
# Kiro backend requirements
|
||||
filesystem:
|
||||
rw:
|
||||
- "{HOME}/.kiro"
|
||||
- "{XDG_DATA_HOME}/kiro-cli"
|
||||
- "{CWD}/.kiro"
|
||||
network:
|
||||
unrestricted: true
|
||||
env:
|
||||
- KIRO_API_KEY
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
# AnviLLM Sandbox Configuration
|
||||
# This file controls landrun sandboxing for all backend sessions
|
||||
# Documentation: https://github.com/landlock-lsm/landrun
|
||||
#
|
||||
# Path templates:
|
||||
# {CWD} - Session working directory
|
||||
# {HOME} - User home directory
|
||||
# {TMPDIR} - Temporary directory (/tmp or $TMPDIR)
|
||||
#
|
||||
# WARNING: CLI tools need network access to function!
|
||||
# - Claude requires connect_tcp for claude.com
|
||||
# - Kiro requires connect_tcp for kiro.ai
|
||||
#
|
||||
# Changes apply to NEW sessions only. Existing sessions are not affected.
|
||||
|
||||
general:
|
||||
enabled: true
|
||||
best_effort: true
|
||||
log_level: error
|
||||
filesystem:
|
||||
ro:
|
||||
- /etc/passwd
|
||||
- /etc/group
|
||||
- /etc/localtime
|
||||
- /etc/nsswitch.conf
|
||||
- /etc/hosts
|
||||
- /etc/resolv.conf
|
||||
- /etc/services
|
||||
- /etc/ssl/certs
|
||||
- /proc
|
||||
- '{XDG_CONFIG_HOME}/anvillm'
|
||||
- '{HOME}/env'
|
||||
rox:
|
||||
- /usr
|
||||
- /lib
|
||||
- /lib64
|
||||
- /bin
|
||||
- /sbin
|
||||
rw:
|
||||
- '{TMPDIR}'
|
||||
- '{XDG_CACHE_HOME}'
|
||||
- /dev/null
|
||||
- /dev/zero
|
||||
- /dev/urandom
|
||||
- /dev/random
|
||||
rwx: []
|
||||
network:
|
||||
enabled: false
|
||||
unrestricted: false
|
||||
bind_tcp: []
|
||||
connect_tcp: []
|
||||
env:
|
||||
- HOME
|
||||
- USER
|
||||
- PATH
|
||||
- LANG
|
||||
- TERM
|
||||
- NAMESPACE
|
||||
- DISPLAY
|
||||
- WAYLAND_DISPLAY
|
||||
advanced:
|
||||
ldd: false
|
||||
add_exec: true
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
# Admin role: system administration tasks
|
||||
filesystem:
|
||||
ro:
|
||||
- "{CWD}"
|
||||
- "/var/log"
|
||||
rw:
|
||||
- "/etc"
|
||||
- "/usr/local/etc"
|
||||
- "{HOME}"
|
||||
network:
|
||||
enabled: true
|
||||
unrestricted: true
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
# Architect role: access to knowledge base and planning documents
|
||||
filesystem:
|
||||
ro:
|
||||
- "{XDG_DOCUMENTS_DIR}/agent-kb"
|
||||
- "{HOME}/doc/agent-kb"
|
||||
rw:
|
||||
- "{XDG_DOCUMENTS_DIR}/plans"
|
||||
- "{HOME}/doc/plans"
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
# Default role: permissive settings for ad-hoc usage
|
||||
# Equivalent to current global sandbox.yaml behavior
|
||||
filesystem:
|
||||
rw:
|
||||
- "{CWD}"
|
||||
network:
|
||||
unrestricted: true
|
||||
env:
|
||||
- ANTHROPIC_API_KEY
|
||||
- KIRO_API_KEY
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
# Developer role configuration
|
||||
filesystem:
|
||||
rw:
|
||||
- "{CWD}"
|
||||
rwx:
|
||||
- "{HOME}/bin"
|
||||
- "{HOME}/.local/bin"
|
||||
- "{HOME}/src"
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
# Research role: access to documentation and knowledge base
|
||||
filesystem:
|
||||
ro:
|
||||
- "{CWD}"
|
||||
- "{HOME}/src"
|
||||
- "{XDG_DOCUMENTS_DIR}"
|
||||
- "{HOME}/doc"
|
||||
rw:
|
||||
- "{XDG_DOCUMENTS_DIR}/agent-kb"
|
||||
- "{HOME}/doc/agent-kb"
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
# Reviewer role: read-only access to project and dev directories
|
||||
filesystem:
|
||||
ro:
|
||||
- "{CWD}"
|
||||
- "{HOME}/src"
|
||||
rox:
|
||||
- "{HOME}/bin"
|
||||
- "{HOME}/.local/bin"
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
# Technical editor role: documentation with code access
|
||||
filesystem:
|
||||
ro:
|
||||
- "{HOME}/src"
|
||||
rw:
|
||||
- "{CWD}"
|
||||
- "{XDG_DOCUMENTS_DIR}"
|
||||
- "{HOME}/doc"
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
# Tester role: execute binaries and read source, no write access
|
||||
filesystem:
|
||||
ro:
|
||||
- "{CWD}"
|
||||
- "{HOME}/src"
|
||||
rox:
|
||||
- "{CWD}"
|
||||
- "{HOME}/bin"
|
||||
- "{HOME}/.local/bin"
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
# Writer role: documentation and content creation
|
||||
filesystem:
|
||||
ro:
|
||||
- "{CWD}"
|
||||
- "{HOME}/src"
|
||||
rw:
|
||||
- "{XDG_DOCUMENTS_DIR}"
|
||||
- "{HOME}/doc"
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
# Ansible configuration management tool
|
||||
filesystem:
|
||||
ro:
|
||||
- "{HOME}/.ansible"
|
||||
- "{HOME}/.ansible.cfg"
|
||||
- "{HOME}/.ssh/config"
|
||||
- "{HOME}/.ssh/id_*"
|
||||
- "{HOME}/.ssh/id_*.pub"
|
||||
rw:
|
||||
- "{HOME}/.ssh/known_hosts"
|
||||
- "{HOME}/.ansible/tmp"
|
||||
network:
|
||||
enabled: true
|
||||
connect_tcp:
|
||||
- "22" # SSH
|
||||
- "443" # Galaxy, collections
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
# AWS CLI configuration
|
||||
filesystem:
|
||||
ro:
|
||||
- "{HOME}/.aws"
|
||||
network:
|
||||
enabled: true
|
||||
unrestricted: true
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
# curl/wget network tools
|
||||
network:
|
||||
enabled: true
|
||||
unrestricted: true
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
# Docker container tool configuration
|
||||
filesystem:
|
||||
rw:
|
||||
- "/var/run/docker.sock"
|
||||
- "{HOME}/.docker"
|
||||
network:
|
||||
enabled: true
|
||||
connect_tcp:
|
||||
- "443" # Docker Hub, registries
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
# Text editor configuration (vim, emacs, nano, etc.)
|
||||
filesystem:
|
||||
rw:
|
||||
- "{XDG_CONFIG_HOME}/nvim"
|
||||
- "{XDG_CONFIG_HOME}/vim"
|
||||
- "{XDG_DATA_HOME}/nvim"
|
||||
- "{XDG_STATE_HOME}/nvim"
|
||||
- "{HOME}/.vimrc"
|
||||
- "{HOME}/.vim"
|
||||
- "{HOME}/.emacs.d"
|
||||
- "{HOME}/.config/emacs"
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
# Git task: access to git configuration and system resources
|
||||
filesystem:
|
||||
ro:
|
||||
- "{HOME}/.gitconfig"
|
||||
- "{XDG_CONFIG_HOME}/git/config"
|
||||
- "{XDG_CONFIG_HOME}/git/ignore"
|
||||
- "{XDG_CONFIG_HOME}/git/attributes"
|
||||
- "{HOME}/.ssh"
|
||||
- "{HOME}/.netrc"
|
||||
- "/etc/gitconfig"
|
||||
- "/etc/gitattributes"
|
||||
- "/etc/ssh/ssh_config"
|
||||
- "/etc/ssh/ssh_known_hosts"
|
||||
rw:
|
||||
- "{HOME}/.ssh/known_hosts"
|
||||
- "{HOME}/.git-credentials"
|
||||
- "{XDG_CONFIG_HOME}/git/credentials"
|
||||
- "{XDG_RUNTIME_DIR}/git/"
|
||||
network:
|
||||
enabled: true
|
||||
connect_tcp:
|
||||
- "22" # SSH (git@...)
|
||||
- "443" # HTTPS (https://...)
|
||||
- "9418" # Git protocol (git://...)
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
# GitHub CLI (gh) tool configuration
|
||||
filesystem:
|
||||
ro:
|
||||
- "{XDG_CONFIG_HOME}/gh"
|
||||
- "{XDG_DATA_HOME}/gh/extensions"
|
||||
network:
|
||||
enabled: true
|
||||
connect_tcp:
|
||||
- "443" # HTTPS API
|
||||
- "22" # SSH (optional)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
# Go toolchain configuration
|
||||
filesystem:
|
||||
rw:
|
||||
- "{HOME}/go"
|
||||
network:
|
||||
enabled: true
|
||||
connect_tcp:
|
||||
- "443" # pkg.go.dev, proxy.golang.org
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
# GPG encryption tool
|
||||
filesystem:
|
||||
ro:
|
||||
- "{HOME}/.gnupg"
|
||||
rw:
|
||||
- "{HOME}/.gnupg/trustdb.gpg"
|
||||
- "{HOME}/.gnupg/pubring.kbx"
|
||||
- "{HOME}/.gnupg/random_seed"
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
# Gradle (Java/Kotlin build tool) configuration
|
||||
filesystem:
|
||||
rw:
|
||||
- "{HOME}/.gradle"
|
||||
network:
|
||||
enabled: true
|
||||
connect_tcp:
|
||||
- "443" # services.gradle.org, Maven Central, other repos
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
# Jira CLI configuration
|
||||
filesystem:
|
||||
ro:
|
||||
- "{HOME}/.jira.d"
|
||||
- "{XDG_CONFIG_HOME}/jira-cli"
|
||||
network:
|
||||
enabled: true
|
||||
connect_tcp:
|
||||
- "443" # Jira API
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
# Maven (Java build tool) configuration
|
||||
filesystem:
|
||||
rw:
|
||||
- "{HOME}/.m2"
|
||||
network:
|
||||
enabled: true
|
||||
connect_tcp:
|
||||
- "443" # repo.maven.apache.org, Maven Central
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
# MySQL client configuration
|
||||
filesystem:
|
||||
ro:
|
||||
- "{HOME}/.my.cnf"
|
||||
- "{HOME}/.mylogin.cnf"
|
||||
network:
|
||||
enabled: true
|
||||
connect_tcp:
|
||||
- "3306" # MySQL default port
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
# Node.js package managers (npm, yarn, pnpm) configuration
|
||||
filesystem:
|
||||
rw:
|
||||
- "{HOME}/.npm"
|
||||
- "{HOME}/.yarn"
|
||||
- "{HOME}/.pnpm-store"
|
||||
- "{XDG_CONFIG_HOME}/yarn"
|
||||
- "{HOME}/.yarnrc.yml"
|
||||
- "{HOME}/.npmrc"
|
||||
network:
|
||||
enabled: true
|
||||
connect_tcp:
|
||||
- "443" # registry.npmjs.org, registry.yarnpkg.com
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
# Plan 9 from User Space toolchain configuration
|
||||
filesystem:
|
||||
rwx:
|
||||
- "{PLAN9}"
|
||||
network:
|
||||
enabled: true
|
||||
connect_tcp:
|
||||
- "564" # 9P protocol
|
||||
env:
|
||||
- PLAN9
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
# Podman container tool configuration
|
||||
filesystem:
|
||||
rw:
|
||||
- "{XDG_RUNTIME_DIR}/podman"
|
||||
- "{XDG_DATA_HOME}/containers"
|
||||
- "{XDG_CONFIG_HOME}/containers"
|
||||
network:
|
||||
enabled: true
|
||||
connect_tcp:
|
||||
- "443" # Container registries
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
# PostgreSQL client configuration
|
||||
filesystem:
|
||||
ro:
|
||||
- "{HOME}/.psqlrc"
|
||||
- "{HOME}/.pgpass"
|
||||
network:
|
||||
enabled: true
|
||||
connect_tcp:
|
||||
- "5432" # PostgreSQL default port
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
# Python pip package manager configuration
|
||||
filesystem:
|
||||
rw:
|
||||
- "{XDG_CONFIG_HOME}/pip"
|
||||
- "{HOME}/.pip"
|
||||
network:
|
||||
enabled: true
|
||||
connect_tcp:
|
||||
- "443" # pypi.org, files.pythonhosted.org
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
# rsync tool configuration
|
||||
filesystem:
|
||||
ro:
|
||||
- "{HOME}/.ssh/config"
|
||||
- "{HOME}/.ssh/id_*"
|
||||
- "{HOME}/.ssh/id_*.pub"
|
||||
rw:
|
||||
- "{HOME}/.ssh/known_hosts"
|
||||
network:
|
||||
enabled: true
|
||||
connect_tcp:
|
||||
- "22" # SSH transport
|
||||
- "873" # rsync daemon
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
# Rust toolchain (cargo, rustc, rustup) configuration
|
||||
filesystem:
|
||||
rw:
|
||||
- "{HOME}/.cargo"
|
||||
- "{HOME}/.rustup"
|
||||
network:
|
||||
enabled: true
|
||||
connect_tcp:
|
||||
- "443" # crates.io, static.rust-lang.org
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
# SSH tool configuration
|
||||
filesystem:
|
||||
ro:
|
||||
- "{HOME}/.ssh/config"
|
||||
- "{HOME}/.ssh/id_*"
|
||||
- "{HOME}/.ssh/id_*.pub"
|
||||
rw:
|
||||
- "{HOME}/.ssh/known_hosts"
|
||||
network:
|
||||
enabled: true
|
||||
connect_tcp:
|
||||
- "22"
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
# systemd tools (systemctl, journalctl, etc.)
|
||||
filesystem:
|
||||
ro:
|
||||
- "/run/systemd"
|
||||
- "/var/log/journal"
|
||||
- "/etc/systemd"
|
||||
network:
|
||||
enabled: true
|
||||
connect_tcp:
|
||||
- "9001" # systemd socket activation (if needed)
|
||||
|
|
@ -13,14 +13,20 @@ var (
|
|||
ErrBackendNotFound = errors.New("backend not found")
|
||||
)
|
||||
|
||||
// SessionOptions contains options for creating a session
|
||||
type SessionOptions struct {
|
||||
CWD string
|
||||
Role string
|
||||
Tasks []string
|
||||
}
|
||||
|
||||
// Backend represents any chat backend (CLI tool via PTY, or direct API)
|
||||
type Backend interface {
|
||||
// Name returns the backend name
|
||||
Name() string
|
||||
|
||||
// CreateSession creates a new session instance
|
||||
// cwd is the working directory (may be ignored by API backends)
|
||||
CreateSession(ctx context.Context, cwd string) (Session, error)
|
||||
CreateSession(ctx context.Context, opts SessionOptions) (Session, error)
|
||||
}
|
||||
|
||||
// Session represents an active conversation session
|
||||
|
|
@ -66,6 +72,12 @@ type Session interface {
|
|||
|
||||
// SetAlias sets a user-friendly alias for the session
|
||||
SetAlias(alias string)
|
||||
|
||||
// Role returns the session role (empty if not specified)
|
||||
Role() string
|
||||
|
||||
// Tasks returns the session tasks (empty if not specified)
|
||||
Tasks() []string
|
||||
}
|
||||
|
||||
// SessionMetadata provides information about a session
|
||||
|
|
@ -75,6 +87,7 @@ type SessionMetadata struct {
|
|||
Alias string // User-assigned alias
|
||||
Backend string // Backend name (e.g., "kiro-cli", "claude")
|
||||
CreatedAt time.Time // Session creation time
|
||||
WinID int // Acme window ID (0 if not applicable)
|
||||
Extra map[string]string // Backend-specific metadata
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@ type Session struct {
|
|||
windowName string // window name within tmux session (same as id)
|
||||
cwd string
|
||||
alias string
|
||||
role string // session role (e.g., "developer", "reviewer")
|
||||
tasks []string // session tasks (e.g., ["git", "docker"])
|
||||
winID int // Acme window ID (0 if not applicable)
|
||||
pid int
|
||||
state string
|
||||
context string // prepended to every prompt
|
||||
|
|
@ -182,6 +185,7 @@ func (s *Session) Metadata() backend.SessionMetadata {
|
|||
Alias: s.alias,
|
||||
Backend: s.backendName,
|
||||
CreatedAt: s.createdAt,
|
||||
WinID: s.winID,
|
||||
Extra: map[string]string{
|
||||
"tmux_session": s.tmuxSession,
|
||||
"tmux_window": s.windowName,
|
||||
|
|
@ -193,6 +197,20 @@ func (s *Session) Commands() backend.CommandHandler {
|
|||
return s.commands
|
||||
}
|
||||
|
||||
// Role returns the session role
|
||||
func (s *Session) Role() string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.role
|
||||
}
|
||||
|
||||
// Tasks returns the session tasks
|
||||
func (s *Session) Tasks() []string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.tasks
|
||||
}
|
||||
|
||||
// GetPid gets the process ID
|
||||
func (s *Session) GetPid() int {
|
||||
s.mu.Lock()
|
||||
|
|
@ -214,6 +232,13 @@ func (s *Session) GetContext() string {
|
|||
return s.context
|
||||
}
|
||||
|
||||
// SetWinID sets the Acme window ID
|
||||
func (s *Session) SetWinID(winID int) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.winID = winID
|
||||
}
|
||||
|
||||
// reader continuously reads from FIFO and sends to dataCh
|
||||
func (s *Session) reader() {
|
||||
// Capture the reader generation at start to detect if we've been superseded
|
||||
|
|
|
|||
|
|
@ -109,19 +109,63 @@ func (b *Backend) Name() string {
|
|||
return b.cfg.Name
|
||||
}
|
||||
|
||||
func (b *Backend) CreateSession(ctx context.Context, cwd string) (backend.Session, error) {
|
||||
func (b *Backend) CreateSession(ctx context.Context, opts backend.SessionOptions) (backend.Session, error) {
|
||||
id := generateID()
|
||||
windowName := id // Use session ID as window name
|
||||
|
||||
debug.Log("[session %s] creating window in tmux session %s", id, b.tmuxSession)
|
||||
debug.Log("[session %s] creating window in tmux session %s (role=%s, tasks=%v)", id, b.tmuxSession, opts.Role, opts.Tasks)
|
||||
|
||||
// Load sandbox configuration fresh for each session
|
||||
sandboxCfg, err := sandbox.Load()
|
||||
// Build layered sandbox configuration
|
||||
// Start with global.yaml as base
|
||||
baseCfg, err := sandbox.Load()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error loading sandbox config: %v (using defaults)\n", err)
|
||||
debug.Log("[session %s] failed to load sandbox config: %v, using defaults", id, err)
|
||||
sandboxCfg = sandbox.DefaultConfig()
|
||||
return nil, fmt.Errorf("failed to load global config: %w", err)
|
||||
}
|
||||
|
||||
// Convert base config to layered format
|
||||
baseLayer := sandbox.LayeredConfig{
|
||||
Filesystem: baseCfg.Filesystem,
|
||||
Network: baseCfg.Network,
|
||||
Env: baseCfg.Env,
|
||||
}
|
||||
layers := []sandbox.LayeredConfig{baseLayer}
|
||||
|
||||
backendLayer, err := sandbox.LoadBackend(b.cfg.Name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load backend config %q: %w", b.cfg.Name, err)
|
||||
}
|
||||
layers = append(layers, backendLayer)
|
||||
|
||||
// Add role layer if specified, otherwise use "default" role
|
||||
role := opts.Role
|
||||
if role == "" {
|
||||
role = sandbox.DefaultRole
|
||||
}
|
||||
roleLayer, err := sandbox.LoadRole(role)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load role %q: %w", role, err)
|
||||
}
|
||||
layers = append(layers, roleLayer)
|
||||
|
||||
// Add task layers
|
||||
for _, taskName := range opts.Tasks {
|
||||
taskLayer, err := sandbox.LoadTask(taskName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load task %q: %w", taskName, err)
|
||||
}
|
||||
layers = append(layers, taskLayer)
|
||||
}
|
||||
|
||||
// Merge layers into final config
|
||||
general := sandbox.GeneralConfig{
|
||||
BestEffort: false,
|
||||
LogLevel: "error",
|
||||
}
|
||||
advanced := sandbox.AdvancedConfig{
|
||||
LDD: false,
|
||||
AddExec: true,
|
||||
}
|
||||
sandboxCfg := sandbox.Merge(general, advanced, layers...)
|
||||
|
||||
// 1. Ensure persistent tmux session exists
|
||||
if err := b.ensureTmuxSession(); err != nil {
|
||||
|
|
@ -187,11 +231,11 @@ func (b *Backend) CreateSession(ctx context.Context, cwd string) (backend.Sessio
|
|||
killWindow(b.tmuxSession, windowName)
|
||||
fmt.Fprintf(os.Stderr, "ERROR: landrun not available and sandboxing is required\n")
|
||||
fmt.Fprintf(os.Stderr, "Install landrun: go install github.com/landlock-lsm/landrun@latest\n")
|
||||
fmt.Fprintf(os.Stderr, "Or enable best_effort mode in ~/.config/anvillm/sandbox.yaml (NOT RECOMMENDED)\n")
|
||||
fmt.Fprintf(os.Stderr, "Or enable best_effort mode in ~/.config/anvillm/global.yaml (NOT RECOMMENDED)\n")
|
||||
return nil, fmt.Errorf("landrun not available")
|
||||
}
|
||||
}
|
||||
command = sandbox.WrapCommand(sandboxCfg, command, cwd)
|
||||
command = sandbox.WrapCommand(sandboxCfg, command, opts.CWD)
|
||||
debug.Log("[session %s] sandboxed command: %v", id, command)
|
||||
|
||||
// 8. Build command string for tmux
|
||||
|
|
@ -209,7 +253,7 @@ func (b *Backend) CreateSession(ctx context.Context, cwd string) (backend.Sessio
|
|||
}
|
||||
|
||||
// Change to working directory first
|
||||
if err := sendKeys(target, fmt.Sprintf("cd \"%s\"", cwd), "C-m"); err != nil {
|
||||
if err := sendKeys(target, fmt.Sprintf("cd \"%s\"", opts.CWD), "C-m"); err != nil {
|
||||
os.Remove(fifoPath)
|
||||
killWindow(b.tmuxSession, windowName)
|
||||
return nil, fmt.Errorf("failed to change directory: %w", err)
|
||||
|
|
@ -247,7 +291,7 @@ func (b *Backend) CreateSession(ctx context.Context, cwd string) (backend.Sessio
|
|||
// Find the actual backend process (child of bash shell)
|
||||
pid := 0
|
||||
if panePID != 0 {
|
||||
time.Sleep(200 * time.Millisecond) // Brief delay for backend to start
|
||||
time.Sleep(500 * time.Millisecond) // Brief delay for backend to start
|
||||
pid = FindBackendPID(panePID)
|
||||
if pid == 0 {
|
||||
debug.Log("[session %s] warning: backend process not found for pane PID %d", id, panePID)
|
||||
|
|
@ -261,7 +305,9 @@ func (b *Backend) CreateSession(ctx context.Context, cwd string) (backend.Sessio
|
|||
backendName: b.cfg.Name,
|
||||
tmuxSession: b.tmuxSession,
|
||||
windowName: windowName,
|
||||
cwd: cwd,
|
||||
cwd: opts.CWD,
|
||||
role: role,
|
||||
tasks: opts.Tasks,
|
||||
pid: pid,
|
||||
state: "starting",
|
||||
createdAt: time.Now(),
|
||||
|
|
|
|||
|
|
@ -72,16 +72,19 @@ const (
|
|||
fileAlias
|
||||
fileBackend
|
||||
fileContext
|
||||
fileRole
|
||||
fileTasks
|
||||
fileCount
|
||||
)
|
||||
|
||||
var fileNames = []string{"ctl", "in", "out", "log", "state", "pid", "cwd", "alias", "backend", "context"}
|
||||
var fileNames = []string{"ctl", "in", "out", "log", "state", "pid", "cwd", "alias", "backend", "context", "role", "tasks"}
|
||||
|
||||
type Server struct {
|
||||
mgr *session.Manager
|
||||
listener net.Listener
|
||||
socketPath string
|
||||
mu sync.RWMutex
|
||||
mgr *session.Manager
|
||||
listener net.Listener
|
||||
socketPath string
|
||||
OnAliasChange func(backend.Session) // Called when session alias changes
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
type connState struct {
|
||||
|
|
@ -322,12 +325,32 @@ func (s *Server) write(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
|
|||
fmt.Fprintf(os.Stderr, "[DEBUG] ctl write: input=%q args=%v len=%d fc.Count=%d len(fc.Data)=%d\n",
|
||||
input, args, len(args), fc.Count, len(fc.Data))
|
||||
if len(args) < 2 || args[0] != "new" {
|
||||
return errFcall(fc, "usage: new <backend> <cwd>")
|
||||
return errFcall(fc, "usage: new <backend> <cwd> [role=<role>] [tasks=<task1,task2>]")
|
||||
}
|
||||
|
||||
backendName := args[1]
|
||||
cwd, _ := os.Getwd()
|
||||
if len(args) > 2 {
|
||||
cwd = strings.Trim(args[2], `"`)
|
||||
var role string
|
||||
var tasks []string
|
||||
|
||||
// Parse remaining arguments: first non-key=value is cwd, rest are options
|
||||
cwdSet := false
|
||||
for i := 2; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
if strings.HasPrefix(arg, "role=") {
|
||||
role = strings.TrimPrefix(arg, "role=")
|
||||
} else if strings.HasPrefix(arg, "tasks=") {
|
||||
taskStr := strings.TrimPrefix(arg, "tasks=")
|
||||
if taskStr != "" {
|
||||
tasks = strings.Split(taskStr, ",")
|
||||
}
|
||||
} else if !cwdSet {
|
||||
// First positional argument is cwd
|
||||
cwd = strings.Trim(arg, `"`)
|
||||
cwdSet = true
|
||||
} else {
|
||||
return errFcall(fc, fmt.Sprintf("unexpected argument: %s", arg))
|
||||
}
|
||||
}
|
||||
|
||||
// Validate and clean the path
|
||||
|
|
@ -349,7 +372,12 @@ func (s *Server) write(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
|
|||
return errFcall(fc, fmt.Sprintf("path is not a directory: %s", cleanPath))
|
||||
}
|
||||
|
||||
_, err := s.mgr.New(backendName, cleanPath)
|
||||
opts := backend.SessionOptions{
|
||||
CWD: cleanPath,
|
||||
Role: role,
|
||||
Tasks: tasks,
|
||||
}
|
||||
_, err := s.mgr.New(opts, backendName)
|
||||
if err != nil {
|
||||
return errFcall(fc, err.Error())
|
||||
}
|
||||
|
|
@ -425,6 +453,9 @@ func (s *Server) write(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
|
|||
return errFcall(fc, "invalid alias: must match [A-Za-z0-9_-]+")
|
||||
}
|
||||
sess.SetAlias(input)
|
||||
if s.OnAliasChange != nil {
|
||||
s.OnAliasChange(sess)
|
||||
}
|
||||
return &plan9.Fcall{Type: plan9.Rwrite, Tag: fc.Tag, Count: uint32(len(fc.Data))}
|
||||
}
|
||||
|
||||
|
|
@ -651,6 +682,10 @@ func (s *Server) getSessionFile(sess backend.Session, idx int) string {
|
|||
return tmuxSess.GetContext()
|
||||
}
|
||||
return ""
|
||||
case fileRole:
|
||||
return sess.Role()
|
||||
case fileTasks:
|
||||
return strings.Join(sess.Tasks(), ",")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
package sandbox
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
|
@ -17,6 +19,13 @@ type Config struct {
|
|||
Advanced AdvancedConfig `yaml:"advanced"`
|
||||
}
|
||||
|
||||
// LayeredConfig represents a single layer (system/backend/role/task)
|
||||
type LayeredConfig struct {
|
||||
Filesystem FilesystemConfig `yaml:"filesystem"`
|
||||
Network NetworkConfig `yaml:"network"`
|
||||
Env []string `yaml:"env"`
|
||||
}
|
||||
|
||||
// GeneralConfig contains general sandbox settings
|
||||
type GeneralConfig struct {
|
||||
BestEffort bool `yaml:"best_effort"`
|
||||
|
|
@ -101,7 +110,7 @@ func DefaultConfig() *Config {
|
|||
// ConfigPath returns the path to the config file
|
||||
func ConfigPath() string {
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".config", "anvillm", "sandbox.yaml")
|
||||
return filepath.Join(home, ".config", "anvillm", "global.yaml")
|
||||
}
|
||||
|
||||
// Load loads the config from disk, creating defaults if it doesn't exist
|
||||
|
|
@ -167,11 +176,303 @@ func Save(cfg *Config) error {
|
|||
return os.WriteFile(path, data, 0644)
|
||||
}
|
||||
|
||||
// DefaultRole is the role used when none is specified
|
||||
const DefaultRole = "default"
|
||||
|
||||
// validateName checks if a role/task name is safe for use in file paths
|
||||
func validateName(name string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("name cannot be empty")
|
||||
}
|
||||
if len(name) > 64 {
|
||||
return fmt.Errorf("name too long (max 64 characters)")
|
||||
}
|
||||
// Only allow alphanumeric, hyphen, and underscore
|
||||
for _, r := range name {
|
||||
if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_') {
|
||||
return fmt.Errorf("name contains invalid character: %c (only alphanumeric, hyphen, underscore allowed)", r)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// expandPath replaces template variables with actual values
|
||||
func expandPath(pattern, cwd, home, tmpdir string) string {
|
||||
func expandPath(pattern, cwd string) string {
|
||||
s := pattern
|
||||
|
||||
// CWD is special - passed as parameter, not from env
|
||||
s = strings.ReplaceAll(s, "{CWD}", cwd)
|
||||
s = strings.ReplaceAll(s, "{HOME}", home)
|
||||
s = strings.ReplaceAll(s, "{TMPDIR}", tmpdir)
|
||||
|
||||
// Generic environment variable expansion: {VAR_NAME}
|
||||
re := regexp.MustCompile(`\{([A-Z_][A-Z0-9_]*)\}`)
|
||||
s = re.ReplaceAllStringFunc(s, func(match string) string {
|
||||
varName := match[1 : len(match)-1] // Strip { and }
|
||||
|
||||
// Special handling with defaults
|
||||
home, _ := os.UserHomeDir()
|
||||
switch varName {
|
||||
case "HOME":
|
||||
if home != "" {
|
||||
return home
|
||||
}
|
||||
case "TMPDIR":
|
||||
if tmpdir := os.Getenv("TMPDIR"); tmpdir != "" {
|
||||
return tmpdir
|
||||
}
|
||||
return "/tmp"
|
||||
case "XDG_CONFIG_HOME":
|
||||
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
|
||||
return xdg
|
||||
}
|
||||
return filepath.Join(home, ".config")
|
||||
case "XDG_DATA_HOME":
|
||||
if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" {
|
||||
return xdg
|
||||
}
|
||||
return filepath.Join(home, ".local/share")
|
||||
case "XDG_CACHE_HOME":
|
||||
if xdg := os.Getenv("XDG_CACHE_HOME"); xdg != "" {
|
||||
return xdg
|
||||
}
|
||||
return filepath.Join(home, ".cache")
|
||||
case "XDG_STATE_HOME":
|
||||
if xdg := os.Getenv("XDG_STATE_HOME"); xdg != "" {
|
||||
return xdg
|
||||
}
|
||||
return filepath.Join(home, ".local/state")
|
||||
case "XDG_RUNTIME_DIR":
|
||||
if xdg := os.Getenv("XDG_RUNTIME_DIR"); xdg != "" {
|
||||
return xdg
|
||||
}
|
||||
return fmt.Sprintf("/run/user/%d", os.Getuid())
|
||||
case "XDG_DOCUMENTS_DIR":
|
||||
if xdg := os.Getenv("XDG_DOCUMENTS_DIR"); xdg != "" {
|
||||
return xdg
|
||||
}
|
||||
return filepath.Join(home, "Documents")
|
||||
case "XDG_DOWNLOAD_DIR":
|
||||
if xdg := os.Getenv("XDG_DOWNLOAD_DIR"); xdg != "" {
|
||||
return xdg
|
||||
}
|
||||
return filepath.Join(home, "Downloads")
|
||||
case "XDG_MUSIC_DIR":
|
||||
if xdg := os.Getenv("XDG_MUSIC_DIR"); xdg != "" {
|
||||
return xdg
|
||||
}
|
||||
return filepath.Join(home, "Music")
|
||||
case "XDG_PICTURES_DIR":
|
||||
if xdg := os.Getenv("XDG_PICTURES_DIR"); xdg != "" {
|
||||
return xdg
|
||||
}
|
||||
return filepath.Join(home, "Pictures")
|
||||
case "XDG_VIDEOS_DIR":
|
||||
if xdg := os.Getenv("XDG_VIDEOS_DIR"); xdg != "" {
|
||||
return xdg
|
||||
}
|
||||
return filepath.Join(home, "Videos")
|
||||
}
|
||||
|
||||
// Generic env var lookup
|
||||
if val := os.Getenv(varName); val != "" {
|
||||
return val
|
||||
}
|
||||
return match // Keep original if env var not set
|
||||
})
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// SystemDefaults returns the system-level defaults (restrictive baseline)
|
||||
func SystemDefaults() LayeredConfig {
|
||||
return LayeredConfig{
|
||||
Filesystem: FilesystemConfig{
|
||||
RO: []string{
|
||||
"/etc/passwd",
|
||||
"/dev/null",
|
||||
"/proc/meminfo",
|
||||
"/proc/self/cgroup",
|
||||
"/proc/self/maps",
|
||||
"/proc/version",
|
||||
},
|
||||
ROX: []string{
|
||||
"/usr",
|
||||
"/lib",
|
||||
"/lib64",
|
||||
"/bin",
|
||||
"/sbin",
|
||||
},
|
||||
RW: []string{
|
||||
"{TMPDIR}",
|
||||
},
|
||||
},
|
||||
Network: NetworkConfig{
|
||||
Enabled: false,
|
||||
Unrestricted: false,
|
||||
},
|
||||
Env: []string{
|
||||
"HOME",
|
||||
"USER",
|
||||
"PATH",
|
||||
"LANG",
|
||||
"TERM",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// LoadBackend loads backend-specific config
|
||||
func LoadBackend(name string) (LayeredConfig, error) {
|
||||
if err := validateName(name); err != nil {
|
||||
return LayeredConfig{}, fmt.Errorf("invalid backend name: %w", err)
|
||||
}
|
||||
|
||||
home, _ := os.UserHomeDir()
|
||||
path := filepath.Join(home, ".config", "anvillm", "backends", name+".yaml")
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return LayeredConfig{}, fmt.Errorf("backend %q not found (looked in %s)", name, path)
|
||||
}
|
||||
if err != nil {
|
||||
return LayeredConfig{}, err
|
||||
}
|
||||
|
||||
var cfg LayeredConfig
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return LayeredConfig{}, fmt.Errorf("parse %s: %w", path, err)
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// LoadRole loads role-specific config
|
||||
func LoadRole(name string) (LayeredConfig, error) {
|
||||
if err := validateName(name); err != nil {
|
||||
return LayeredConfig{}, fmt.Errorf("invalid role name: %w", err)
|
||||
}
|
||||
|
||||
home, _ := os.UserHomeDir()
|
||||
path := filepath.Join(home, ".config", "anvillm", "roles", name+".yaml")
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return LayeredConfig{}, fmt.Errorf("role %q not found (looked in %s)", name, path)
|
||||
}
|
||||
if err != nil {
|
||||
return LayeredConfig{}, err
|
||||
}
|
||||
|
||||
var cfg LayeredConfig
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return LayeredConfig{}, fmt.Errorf("parse %s: %w", path, err)
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// LoadTask loads task-specific config
|
||||
func LoadTask(name string) (LayeredConfig, error) {
|
||||
if err := validateName(name); err != nil {
|
||||
return LayeredConfig{}, fmt.Errorf("invalid task name: %w", err)
|
||||
}
|
||||
|
||||
home, _ := os.UserHomeDir()
|
||||
path := filepath.Join(home, ".config", "anvillm", "tasks", name+".yaml")
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return LayeredConfig{}, fmt.Errorf("task %q not found (looked in %s)", name, path)
|
||||
}
|
||||
if err != nil {
|
||||
return LayeredConfig{}, err
|
||||
}
|
||||
|
||||
var cfg LayeredConfig
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return LayeredConfig{}, fmt.Errorf("parse %s: %w", path, err)
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// Merge combines multiple layers into a final Config (most permissive wins)
|
||||
func Merge(general GeneralConfig, advanced AdvancedConfig, layers ...LayeredConfig) *Config {
|
||||
cfg := &Config{
|
||||
General: general,
|
||||
Advanced: advanced,
|
||||
Filesystem: FilesystemConfig{
|
||||
RO: []string{},
|
||||
ROX: []string{},
|
||||
RW: []string{},
|
||||
RWX: []string{},
|
||||
},
|
||||
Network: NetworkConfig{
|
||||
Enabled: false,
|
||||
Unrestricted: false,
|
||||
},
|
||||
Env: []string{},
|
||||
}
|
||||
|
||||
// Track paths by permission level (higher = more permissive)
|
||||
pathPerms := make(map[string]int) // 0=none, 1=ro, 2=rox, 3=rw, 4=rwx
|
||||
envSet := make(map[string]bool)
|
||||
|
||||
for _, layer := range layers {
|
||||
// Merge filesystem permissions (most permissive wins)
|
||||
for _, p := range layer.Filesystem.RO {
|
||||
if pathPerms[p] < 1 {
|
||||
pathPerms[p] = 1
|
||||
}
|
||||
}
|
||||
for _, p := range layer.Filesystem.ROX {
|
||||
if pathPerms[p] < 2 {
|
||||
pathPerms[p] = 2
|
||||
}
|
||||
}
|
||||
for _, p := range layer.Filesystem.RW {
|
||||
if pathPerms[p] < 3 {
|
||||
pathPerms[p] = 3
|
||||
}
|
||||
}
|
||||
for _, p := range layer.Filesystem.RWX {
|
||||
if pathPerms[p] < 4 {
|
||||
pathPerms[p] = 4
|
||||
}
|
||||
}
|
||||
|
||||
// Merge network (most permissive wins)
|
||||
if layer.Network.Enabled {
|
||||
cfg.Network.Enabled = true
|
||||
}
|
||||
if layer.Network.Unrestricted {
|
||||
cfg.Network.Unrestricted = true
|
||||
}
|
||||
cfg.Network.BindTCP = append(cfg.Network.BindTCP, layer.Network.BindTCP...)
|
||||
cfg.Network.ConnectTCP = append(cfg.Network.ConnectTCP, layer.Network.ConnectTCP...)
|
||||
|
||||
// Merge environment (union)
|
||||
for _, e := range layer.Env {
|
||||
envSet[e] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Convert path permissions back to lists
|
||||
for path, perm := range pathPerms {
|
||||
switch perm {
|
||||
case 1:
|
||||
cfg.Filesystem.RO = append(cfg.Filesystem.RO, path)
|
||||
case 2:
|
||||
cfg.Filesystem.ROX = append(cfg.Filesystem.ROX, path)
|
||||
case 3:
|
||||
cfg.Filesystem.RW = append(cfg.Filesystem.RW, path)
|
||||
case 4:
|
||||
cfg.Filesystem.RWX = append(cfg.Filesystem.RWX, path)
|
||||
}
|
||||
}
|
||||
|
||||
// Convert env set to list
|
||||
for e := range envSet {
|
||||
cfg.Env = append(cfg.Env, e)
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,12 +25,6 @@ func WrapCommand(cfg *Config, originalCmd []string, cwd string) []string {
|
|||
return originalCmd
|
||||
}
|
||||
|
||||
home, _ := os.UserHomeDir()
|
||||
tmpdir := os.Getenv("TMPDIR")
|
||||
if tmpdir == "" {
|
||||
tmpdir = "/tmp"
|
||||
}
|
||||
|
||||
args := []string{"landrun"}
|
||||
|
||||
// Log level
|
||||
|
|
@ -54,25 +48,25 @@ func WrapCommand(cfg *Config, originalCmd []string, cwd string) []string {
|
|||
// Collect all filesystem paths
|
||||
var entries []pathEntry
|
||||
for _, path := range cfg.Filesystem.RO {
|
||||
expanded := expandPath(path, cwd, home, tmpdir)
|
||||
expanded := expandPath(path, cwd)
|
||||
if pathExists(expanded) {
|
||||
entries = append(entries, pathEntry{expanded, "--ro"})
|
||||
}
|
||||
}
|
||||
for _, path := range cfg.Filesystem.ROX {
|
||||
expanded := expandPath(path, cwd, home, tmpdir)
|
||||
expanded := expandPath(path, cwd)
|
||||
if pathExists(expanded) {
|
||||
entries = append(entries, pathEntry{expanded, "--rox"})
|
||||
}
|
||||
}
|
||||
for _, path := range cfg.Filesystem.RW {
|
||||
expanded := expandPath(path, cwd, home, tmpdir)
|
||||
expanded := expandPath(path, cwd)
|
||||
if pathExists(expanded) {
|
||||
entries = append(entries, pathEntry{expanded, "--rw"})
|
||||
}
|
||||
}
|
||||
for _, path := range cfg.Filesystem.RWX {
|
||||
expanded := expandPath(path, cwd, home, tmpdir)
|
||||
expanded := expandPath(path, cwd)
|
||||
if pathExists(expanded) {
|
||||
entries = append(entries, pathEntry{expanded, "--rwx"})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ func NewManager(backends map[string]backend.Backend) *Manager {
|
|||
}
|
||||
|
||||
// New creates a new session in the given working directory using the specified backend
|
||||
func (m *Manager) New(backendName, cwd string) (backend.Session, error) {
|
||||
func (m *Manager) New(opts backend.SessionOptions, backendName string) (backend.Session, error) {
|
||||
m.mu.RLock()
|
||||
b, ok := m.backends[backendName]
|
||||
m.mu.RUnlock()
|
||||
|
|
@ -33,7 +33,7 @@ func (m *Manager) New(backendName, cwd string) (backend.Session, error) {
|
|||
return nil, backend.ErrBackendNotFound
|
||||
}
|
||||
|
||||
sess, err := b.CreateSession(context.Background(), cwd)
|
||||
sess, err := b.CreateSession(context.Background(), opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
8
main.go
8
main.go
|
|
@ -77,7 +77,7 @@ func main() {
|
|||
defer srv.Close()
|
||||
|
||||
// Rename prompt window when alias changes
|
||||
srv.OnAliasChange = func(sess Session) {
|
||||
srv.OnAliasChange = func(sess backend.Session) {
|
||||
meta := sess.Metadata()
|
||||
if meta.WinID > 0 {
|
||||
w, err := acme.Open(meta.WinID, nil)
|
||||
|
|
@ -153,7 +153,8 @@ func main() {
|
|||
fmt.Fprintf(os.Stderr, "Error: Kiro requires a path argument\n")
|
||||
continue
|
||||
}
|
||||
_, err := mgr.New("kiro-cli", arg)
|
||||
opts := backend.SessionOptions{CWD: arg}
|
||||
_, err := mgr.New(opts, "kiro-cli")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
continue
|
||||
|
|
@ -165,7 +166,8 @@ func main() {
|
|||
fmt.Fprintf(os.Stderr, "Error: Claude requires a path argument\n")
|
||||
continue
|
||||
}
|
||||
_, err := mgr.New("claude", arg)
|
||||
opts := backend.SessionOptions{CWD: arg}
|
||||
_, err := mgr.New(opts, "claude")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
continue
|
||||
|
|
|
|||
8
mkfile
8
mkfile
|
|
@ -10,12 +10,14 @@ build:V:
|
|||
cp scripts/Alog $INSTALL_PATH/Alog
|
||||
chmod 0755 $INSTALL_PATH/Alog
|
||||
mkdir -p $INSTALL_PATH/Workflows
|
||||
cp scripts/DevReview $INSTALL_PATH/Workflows/DevReview
|
||||
cp workflows/DevReview $INSTALL_PATH/Workflows/DevReview
|
||||
chmod 0755 $INSTALL_PATH/Workflows/DevReview
|
||||
cp scripts/DevReviewQA $INSTALL_PATH/Workflows/DevReviewQA
|
||||
cp workflows/DevReviewQA $INSTALL_PATH/Workflows/DevReviewQA
|
||||
chmod 0755 $INSTALL_PATH/Workflows/DevReviewQA
|
||||
cp scripts/Planning $INSTALL_PATH/Workflows/Planning
|
||||
cp workflows/Planning $INSTALL_PATH/Workflows/Planning
|
||||
chmod 0755 $INSTALL_PATH/Workflows/Planning
|
||||
mkdir -p $HOME/.config/anvillm
|
||||
cp -rf cfg/* $HOME/.config/anvillm/
|
||||
|
||||
install:V: build
|
||||
|
||||
|
|
|
|||
|
|
@ -31,22 +31,18 @@ fi
|
|||
|
||||
AGENT_MOUNT="agent"
|
||||
|
||||
# Helper to create session and capture ID
|
||||
create_session() {
|
||||
local backend="$1" cwd="$2"
|
||||
local before after
|
||||
before=$(9p read $AGENT_MOUNT/list | awk '{print $1}' | sort)
|
||||
echo "new $backend $cwd" | 9p write $AGENT_MOUNT/ctl
|
||||
after=$(9p read $AGENT_MOUNT/list | awk '{print $1}' | sort)
|
||||
comm -13 <(echo "$before") <(echo "$after") | head -1
|
||||
}
|
||||
|
||||
echo "Creating developer session..."
|
||||
DEV_ID=$(create_session "$BACKEND" "$PROJECT_PATH")
|
||||
before=$(9p read $AGENT_MOUNT/list | awk '{print $1}' | sort)
|
||||
echo "new $BACKEND $PROJECT_PATH role=developer tasks=git,editor,go,rust,python,nodejs,docker,podman,maven,gradle,plan9,gpg,github-cli" | 9p write $AGENT_MOUNT/ctl
|
||||
after=$(9p read $AGENT_MOUNT/list | awk '{print $1}' | sort)
|
||||
DEV_ID=$(comm -13 <(echo "$before") <(echo "$after") | head -1)
|
||||
echo "Developer session ID: $DEV_ID"
|
||||
|
||||
echo "Creating reviewer session..."
|
||||
REVIEWER_ID=$(create_session "$BACKEND" "$PROJECT_PATH")
|
||||
before=$(9p read $AGENT_MOUNT/list | awk '{print $1}' | sort)
|
||||
echo "new $BACKEND $PROJECT_PATH role=reviewer tasks=git,github-cli" | 9p write $AGENT_MOUNT/ctl
|
||||
after=$(9p read $AGENT_MOUNT/list | awk '{print $1}' | sort)
|
||||
REVIEWER_ID=$(comm -13 <(echo "$before") <(echo "$after") | head -1)
|
||||
echo "Reviewer session ID: $REVIEWER_ID"
|
||||
|
||||
# Set aliases
|
||||
|
|
@ -31,26 +31,25 @@ fi
|
|||
|
||||
AGENT_MOUNT="agent"
|
||||
|
||||
# Helper to create session and capture ID
|
||||
create_session() {
|
||||
local backend="$1" cwd="$2"
|
||||
local before after
|
||||
before=$(9p read $AGENT_MOUNT/list | awk '{print $1}' | sort)
|
||||
echo "new $backend $cwd" | 9p write $AGENT_MOUNT/ctl
|
||||
after=$(9p read $AGENT_MOUNT/list | awk '{print $1}' | sort)
|
||||
comm -13 <(echo "$before") <(echo "$after") | head -1
|
||||
}
|
||||
|
||||
echo "Creating developer session..."
|
||||
DEV_ID=$(create_session "$BACKEND" "$PROJECT_PATH")
|
||||
before=$(9p read $AGENT_MOUNT/list | awk '{print $1}' | sort)
|
||||
echo "new $BACKEND $PROJECT_PATH role=developer tasks=git,editor,go,rust,python,nodejs,docker,podman,maven,gradle,plan9,gpg,github-cli" | 9p write $AGENT_MOUNT/ctl
|
||||
after=$(9p read $AGENT_MOUNT/list | awk '{print $1}' | sort)
|
||||
DEV_ID=$(comm -13 <(echo "$before") <(echo "$after") | head -1)
|
||||
echo "Developer session ID: $DEV_ID"
|
||||
|
||||
echo "Creating reviewer session..."
|
||||
REVIEWER_ID=$(create_session "$BACKEND" "$PROJECT_PATH")
|
||||
before=$(9p read $AGENT_MOUNT/list | awk '{print $1}' | sort)
|
||||
echo "new $BACKEND $PROJECT_PATH role=reviewer tasks=git,github-cli" | 9p write $AGENT_MOUNT/ctl
|
||||
after=$(9p read $AGENT_MOUNT/list | awk '{print $1}' | sort)
|
||||
REVIEWER_ID=$(comm -13 <(echo "$before") <(echo "$after") | head -1)
|
||||
echo "Reviewer session ID: $REVIEWER_ID"
|
||||
|
||||
echo "Creating QA session..."
|
||||
QA_ID=$(create_session "$BACKEND" "$PROJECT_PATH")
|
||||
before=$(9p read $AGENT_MOUNT/list | awk '{print $1}' | sort)
|
||||
echo "new $BACKEND $PROJECT_PATH role=tester tasks=git,github-cli" | 9p write $AGENT_MOUNT/ctl
|
||||
after=$(9p read $AGENT_MOUNT/list | awk '{print $1}' | sort)
|
||||
QA_ID=$(comm -13 <(echo "$before") <(echo "$after") | head -1)
|
||||
echo "QA session ID: $QA_ID"
|
||||
|
||||
# Set aliases
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
#!/bin/bash
|
||||
# Planning - Research/Engineering/Tech-Editor workflow
|
||||
# Planning - Research/Architect/Techeditor workflow
|
||||
# Usage: ./scripts/Planning <backend> <workdir>
|
||||
|
||||
set -e
|
||||
|
|
@ -26,31 +26,30 @@ fi
|
|||
|
||||
AGENT="agent"
|
||||
|
||||
# Helper to create session and capture ID
|
||||
create_session() {
|
||||
local backend="$1" cwd="$2"
|
||||
local before after
|
||||
before=$(9p read $AGENT/list | awk '{print $1}' | sort)
|
||||
echo "new $backend $cwd" | 9p write $AGENT/ctl
|
||||
after=$(9p read $AGENT/list | awk '{print $1}' | sort)
|
||||
comm -13 <(echo "$before") <(echo "$after") | head -1
|
||||
}
|
||||
|
||||
echo "Creating research session..."
|
||||
RESEARCH_ID=$(create_session "$BACKEND" "$DOC_PATH")
|
||||
before=$(9p read $AGENT/list | awk '{print $1}' | sort)
|
||||
echo "new $BACKEND $DOC_PATH role=research" | 9p write $AGENT/ctl
|
||||
after=$(9p read $AGENT/list | awk '{print $1}' | sort)
|
||||
RESEARCH_ID=$(comm -13 <(echo "$before") <(echo "$after") | head -1)
|
||||
echo " ID: $RESEARCH_ID"
|
||||
|
||||
echo "Creating engineering session..."
|
||||
ENG_ID=$(create_session "$BACKEND" "$DOC_PATH")
|
||||
echo " ID: $ENG_ID"
|
||||
echo "Creating architect session..."
|
||||
before=$(9p read $AGENT/list | awk '{print $1}' | sort)
|
||||
echo "new $BACKEND $DOC_PATH role=architect tasks=git" | 9p write $AGENT/ctl
|
||||
after=$(9p read $AGENT/list | awk '{print $1}' | sort)
|
||||
ARCH_ID=$(comm -13 <(echo "$before") <(echo "$after") | head -1)
|
||||
echo " ID: $ARCH_ID"
|
||||
|
||||
echo "Creating tech-editor session..."
|
||||
EDITOR_ID=$(create_session "$BACKEND" "$DOC_PATH")
|
||||
echo "Creating techeditor session..."
|
||||
before=$(9p read $AGENT/list | awk '{print $1}' | sort)
|
||||
echo "new $BACKEND $DOC_PATH role=techeditor tasks=git" | 9p write $AGENT/ctl
|
||||
after=$(9p read $AGENT/list | awk '{print $1}' | sort)
|
||||
EDITOR_ID=$(comm -13 <(echo "$before") <(echo "$after") | head -1)
|
||||
echo " ID: $EDITOR_ID"
|
||||
|
||||
# Set aliases
|
||||
echo "${ALIAS_PREFIX}-research" | 9p write $AGENT/$RESEARCH_ID/alias
|
||||
echo "${ALIAS_PREFIX}-engineering" | 9p write $AGENT/$ENG_ID/alias
|
||||
echo "${ALIAS_PREFIX}-architect" | 9p write $AGENT/$ARCH_ID/alias
|
||||
echo "${ALIAS_PREFIX}-techeditor" | 9p write $AGENT/$EDITOR_ID/alias
|
||||
|
||||
# Research context
|
||||
|
|
@ -62,15 +61,15 @@ Your job is to research system architecture and code for requested information.
|
|||
- Prefer updating existing kb documents over creating new ones
|
||||
- Only create new KB documents if the knowledge discovered is significant and reusable
|
||||
|
||||
When you receive a query from engineering, research and respond:
|
||||
- Use the peer-bot skill to find and communicate with the engineering agent (alias contains "engineering")
|
||||
- Send your answer back to engineering
|
||||
When you receive a query from the architect, research and respond:
|
||||
- Use the peer-bot skill to find and communicate with the architect agent (alias contains "architect")
|
||||
- Send your answer back to the architect
|
||||
|
||||
Do not actively await responses, but stop working until you receive an explicit request to conduct research.
|
||||
EOF
|
||||
|
||||
# Engineering context
|
||||
cat <<EOF | 9p write $AGENT/$ENG_ID/context
|
||||
# Architect context
|
||||
cat <<EOF | 9p write $AGENT/$ARCH_ID/context
|
||||
Your job is to update design documentation in $DOC_PATH. Follow agent-kb conventions. Search the knowledge base for relevant documents.
|
||||
|
||||
If documentation is insufficient, query the research bot:
|
||||
|
|
@ -78,23 +77,23 @@ If documentation is insufficient, query the research bot:
|
|||
- Include in your query: the question, context, and request for full path to new/updated docs or a direct answer
|
||||
- Wait for the research agent to send the answer back
|
||||
|
||||
You will receive edit requests from the tech-editor:
|
||||
- Use the peer-bot skill to find and communicate with the tech-editor agent (alias contains "techeditor")
|
||||
You will receive edit requests from the techeditor:
|
||||
- Use the peer-bot skill to find and communicate with the techeditor agent (alias contains "techeditor")
|
||||
- Apply the requested changes and send acknowledgment back
|
||||
|
||||
Do not actively await responses, but stop working until you receive an explicit request to make a change from the tech-editor.
|
||||
Do not actively await responses, but stop working until you receive an explicit request to make a change from the techeditor.
|
||||
EOF
|
||||
|
||||
# Tech-editor context
|
||||
cat <<EOF | 9p write $AGENT/$EDITOR_ID/context
|
||||
You are a professional technical writer. Ensure documentation in $DOC_PATH is high quality, readable, and at the right detail level for software engineers.
|
||||
|
||||
Do not edit documents directly. Send recommended changes to engineering:
|
||||
- Use the peer-bot skill to find and communicate with the engineering agent (alias contains "engineering")
|
||||
Do not edit documents directly. Send recommended changes to the architect:
|
||||
- Use the peer-bot skill to find and communicate with the architect agent (alias contains "architect")
|
||||
- Include your recommended changes
|
||||
- Request acknowledgment from engineering
|
||||
- Request acknowledgment from the architect
|
||||
|
||||
When engineering acknowledges, proofread again. Iterate until satisfied.
|
||||
When the architect acknowledges, proofread again. Iterate until satisfied.
|
||||
|
||||
Do not actively await responses, but stop working until you receive an explicit request to perform work.
|
||||
EOF
|
||||
|
|
@ -103,8 +102,8 @@ echo ""
|
|||
echo "✓ Planning workflow ready ($ALIAS_PREFIX)"
|
||||
echo ""
|
||||
echo "Sessions:"
|
||||
echo " ${ALIAS_PREFIX}-research: $RESEARCH_ID"
|
||||
echo " ${ALIAS_PREFIX}-engineering: $ENG_ID"
|
||||
echo " ${ALIAS_PREFIX}-techeditor: $EDITOR_ID"
|
||||
echo " ${ALIAS_PREFIX}-research: $RESEARCH_ID"
|
||||
echo " ${ALIAS_PREFIX}-architect: $ARCH_ID"
|
||||
echo " ${ALIAS_PREFIX}-techeditor: $EDITOR_ID"
|
||||
echo ""
|
||||
echo "Start by giving engineering a task, or tech-editor a document to review."
|
||||
echo "Start by giving the architect a task, or techeditor a document to review."
|
||||
Reference in New Issue