Skip to content
Open
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
35 changes: 34 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,39 @@
# Changelog

## Unreleased
## 0.3.1

### Added

- Releases have a `.deb` and an `.rpm` for Linux (amd64, arm64): the binary in
`/usr/bin` and the docs, with QEMU and ssh recommended. Not in a package
repository yet: install the file with `apt install ./agent-vm_X.Y.Z_arm64.deb`
or `dnf install ./agent-vm-X.Y.Z-1.aarch64.rpm`.
- A Nix flake (Linux): `nix run github:sylvinus/agent-vm`. QEMU comes with it,
ssh is the system's.

### Fixed

- Linux: VMs turned Broken seconds to minutes after a start. The end of the
agent-vm command that started a VM (closing its terminal, a kill, and at
random a plain exit) killed Lima's host agent, which left QEMU running
without it, cut off, and failing every later start. The host agent now
outlives that command. On macOS, the same killed the VM.
- A VM left in that state is stopped by force, then started again, on the
next run; `agent-vm stop` stops it too. `stop` no longer says "VM stopped."
for a Broken VM it did not stop.
- A start that fails (the VM never came up) now stops the VM, which Lima's
host agent would otherwise keep running. The force-stop of a VM left without
its host agent checks that the process is QEMU first: the PID file of a QEMU
that aborted may name another program's process by now.
- The missing-QEMU hint no longer says `apt-get` where there is neither apt
nor dnf (NixOS): it points to the distribution's package, or the Nix flake.
- Linux: a VM could lose its network and stop in the middle of `agent-vm
setup` or any download (`exit status 255`, the VM Stopped), with QEMU
10.2 and a KVM host. QEMU aborted on `net_fill_rstate: Assertion 'size ==
0' failed`, a bug of its `socket` network backend, which handled a failed
read of the stream badly. The VMs' network now goes through QEMU's `dgram`
backend (QEMU 7.2 or later): no stream, nothing to lose. An older QEMU keeps
the former backend, and so does Windows (it has no unix datagram sockets).

## 0.3.0

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ One binary with [Lima](https://lima-vm.io/) built in. Ships dev tools, Docker, h
curl -fsSL https://www.agent-vm.org/install.sh | sh
```

Or `brew install sylvinus/tap/agent-vm`. From a clone, with Go (and on macOS the Xcode command line tools): `./agent-vm.sh install` builds it and links it onto your `PATH`; after a `git pull`, run it again. See [Install](https://www.agent-vm.org/#install) for prerequisites.
Or `brew install sylvinus/tap/agent-vm`; on Linux, the `.deb` or `.rpm` of the [latest release](https://github.com/sylvinus/agent-vm/releases/latest), or `nix run github:sylvinus/agent-vm` (QEMU included). From a clone, with Go (and on macOS the Xcode command line tools): `./agent-vm.sh install` builds it and links it onto your `PATH`; after a `git pull`, run it again. See [Install](https://www.agent-vm.org/#install) for prerequisites.

## Use

Expand Down
9 changes: 9 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ binaries `osslsigncode` (`brew install osslsigncode`).
`AGENT_VM_WINDOWS_PFX_PASS=<its password>`. Without, they go out unsigned, with a warning.
- Homebrew: `AGENT_VM_TAP=<clone of the tap>` updates `Formula/agent-vm.rb` there (each
platform's url and sha256) and prints the commands to commit it.
- The Linux `.deb` and `.rpm` (`packaging/nfpm.yaml`) are made with nfpm, built with Go at the
version pinned in `release.sh` (`NFPM_VERSION`; `AGENT_VM_NFPM=<binary>` uses another). They
are release assets and in `SHA256SUMS`, after the tarballs.
- A run that tagged and then failed is resumed by running it again.
3. In a commit after it: `NEXT_VERSION` to the next version, and a new `## Unreleased` section.

Expand All @@ -34,5 +37,11 @@ binaries `osslsigncode` (`brew install osslsigncode`).
`on_arm`/`on_intel`), installing the tarball's `agent-vm`: that is the shape `AGENT_VM_TAP` updates.
Then, in the tap: `brew audit --strict sylvinus/tap/agent-vm`, `brew reinstall` and `brew test`
before pushing.
- `flake.nix` builds from source, with its `vendorHash` for the Go dependencies. When go.mod or
go.sum change, `release.sh` stops until it is updated: on a machine with Nix, set
`pkgs.lib.fakeHash`, run `nix build .#agent-vm` (Linux), put the hash it prints back, and update
the `go.mod+go.sum:` checksum line next to it (the error gives the value). The hash is of the
downloaded modules only (`proxyVendor`), so patches to `third_party/` leave it as it is. Do the
same after `nix flake update`: `flake.lock` pins nixpkgs, and with it the Go that downloads them.
- `--not-latest` publishes a release without making it GitHub's latest: the installer's default
stays the previous release, and the Homebrew formula is left alone, `AGENT_VM_TAP` or not.
9 changes: 9 additions & 0 deletions cmd/agent-vm/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,21 @@ package main

import (
"os"
"strings"

"github.com/sylvinus/agent-vm/internal/cli"
"github.com/sylvinus/agent-vm/internal/limaembed"
)

// extraPath goes first on PATH, for this process and what it starts (the
// hostagent, QEMU): set at build time (-ldflags -X) by a build that ships
// QEMU with agent-vm (flake.nix). Empty otherwise.
var extraPath string

func main() {
if p := os.Getenv("PATH"); extraPath != "" && !strings.HasPrefix(p, extraPath+string(os.PathListSeparator)) {
os.Setenv("PATH", extraPath+string(os.PathListSeparator)+p)
}
// Lima's libraries start this executable again for its hostagent.
if limaembed.Run(os.Args[1:]) {
return
Expand Down
27 changes: 27 additions & 0 deletions flake.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

122 changes: 122 additions & 0 deletions flake.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
{
description = "agent-vm: AI coding agents in a disposable Linux VM per project";

inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";

outputs =
{ self, nixpkgs }:
let
# Linux only for now: on macOS the binary needs cgo, the SDK and the
# virtualization entitlement (nixpkgs' lima signs it with
# darwin.sigtool), none of which this build sets up.
systems = [
"x86_64-linux"
"aarch64-linux"
];
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});

# The version, the build tags and Lima's version are the Makefile's and
# third_party/SOURCES's: there is one place to change each.
linesOf = file: builtins.filter builtins.isString (builtins.split "\n" (builtins.readFile file));
lineWith =
file: prefix:
builtins.head (
builtins.filter (l: builtins.substring 0 (builtins.stringLength prefix) l == prefix) (linesOf file)
);
makeVar =
name:
let
prefix = "${name} := ";
in
builtins.substring (builtins.stringLength prefix) (-1) (lineWith ./Makefile prefix);
# lima <upstream> <commit> <version> <left out>
limaVersion = builtins.elemAt (builtins.filter (s: builtins.isString s && s != "") (
builtins.split " +" (lineWith ./third_party/SOURCES "lima ")
)) 3;
in
{
packages = forAllSystems (pkgs: rec {
agent-vm = pkgs.buildGoModule {
pname = "agent-vm";
version = makeVar "NEXT_VERSION";
src = self;

# The downloaded modules only: a vendor folder would hold
# third_party/ too (go.mod's replaces), and change with each patch.
proxyVendor = true;
# Changes with go.mod and go.sum: set pkgs.lib.fakeHash here, build,
# and nix prints the new one. release.sh refuses a release when
# this checksum of `cat go.mod go.sum` is not the current one:
# update it with the hash.
# go.mod+go.sum: 0d831936e2c268c65229802f0d5026b4aba48507157f9b6a034225b64408a24e
vendorHash = "sha256-zfAasYQEKqcrGi4OI3UKJyq3VoUCmNE5lU4JKPtJDmc=";

subPackages = [ "cmd/agent-vm" ];
tags = pkgs.lib.splitString "," (makeVar "TAGS");
env.CGO_ENABLED = "0";
ldflags = [
"-s"
"-w"
"-X github.com/sylvinus/agent-vm/internal/version.Version=${makeVar "NEXT_VERSION"}"
"-X github.com/lima-vm/lima/v2/pkg/version.Version=${limaVersion}"
# QEMU comes with it, first on its PATH (cmd/agent-vm/main.go), as
# nixpkgs' lima does with a wrapper: a wrapped binary would make
# `agent-vm install` link past the wrapper. qemu_kvm, this host's
# architecture only, is a third smaller than qemu. ssh is the
# system's.
"-X main.extraPath=${pkgs.lib.makeBinPath [ pkgs.qemu_kvm ]}"
];

nativeBuildInputs = [ pkgs.gzip ];

# go.mod names the Go the project is developed with, and nixpkgs
# has it a few days after a release: the build uses nixpkgs' (the
# sandbox cannot download a toolchain).
postPatch = ''
sed -i 's/^go [0-9.]*$/go ${pkgs.go.version}/' go.mod
'';

# What the Makefile's `assets` target does: the guest agent runs in
# the Linux guest, of this machine's architecture, and is embedded
# in the binary with Lima's templates.
preBuild =
let
guestArch = if pkgs.stdenv.hostPlatform.isAarch64 then "aarch64" else "x86_64";
assets = "internal/limaembed/assets";
in
''
go build -trimpath -ldflags "-s -w -X github.com/lima-vm/lima/v2/pkg/version.Version=${limaVersion}" \
-o ${assets}/lima-guestagent.Linux-${guestArch} github.com/lima-vm/lima/v2/cmd/lima-guestagent
gzip -9 -n -f ${assets}/lima-guestagent.Linux-${guestArch}
rm -rf ${assets}/templates
cp -R third_party/lima/templates ${assets}/templates
'';

# The module download shares preBuild with the build, and has no
# modules to build the guest agent from yet.
overrideModAttrs = _: {
preBuild = "";
};

# The tests run in CI, on the three platforms.
doCheck = false;

meta = {
description = "Run AI coding agents in a disposable Linux VM per project";
homepage = "https://www.agent-vm.org/";
license = pkgs.lib.licenses.mit;
mainProgram = "agent-vm";
platforms = systems;
};
};
default = agent-vm;
});

apps = forAllSystems (pkgs: {
default = {
type = "app";
program = "${self.packages.${pkgs.stdenv.hostPlatform.system}.agent-vm}/bin/agent-vm";
};
});
};
}
20 changes: 19 additions & 1 deletion internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,8 +171,26 @@ func versionCmd(args []string, io IO) int {
return 1
}

// updateCommand is how this copy of agent-vm is updated.
// updateCommand is how this copy of agent-vm is updated: by what installed
// it. The installer's copy would otherwise land in ~/.local/bin, ahead of a
// package's on the PATH, and hide its updates.
func updateCommand() string {
exe, err := self()
if err != nil {
exe = ""
}
return updateCommandFor(exe)
}

func updateCommandFor(exe string) string {
switch {
case strings.HasPrefix(exe, "/nix/store/"):
return "with Nix, as installed (nix profile upgrade agent-vm, or the flake input)"
case strings.Contains(exe, "/Cellar/agent-vm/"):
return "brew upgrade agent-vm"
case exe == "/usr/bin/agent-vm":
return "install the .deb or .rpm of https://github.com/sylvinus/agent-vm/releases/latest"
}
return "curl -fsSL https://www.agent-vm.org/install.sh | sh"
}

Expand Down
17 changes: 17 additions & 0 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,23 @@ func TestHelp(t *testing.T) {
}
}

// A copy a package manager installed is updated by it: the installer's
// would land ahead of it on the PATH.
func TestUpdateCommand(t *testing.T) {
for exe, want := range map[string]string{
"/nix/store/abc-agent-vm-0.3.1/bin/agent-vm": "nix profile upgrade",
"/opt/homebrew/Cellar/agent-vm/0.3.1/bin/agent-vm": "brew upgrade agent-vm",
"/usr/bin/agent-vm": ".deb or .rpm",
"/home/u/.local/share/agent-vm/0.3.1/agent-vm": "install.sh | sh",
"/usr/local/bin/agent-vm": "install.sh | sh",
"": "install.sh | sh",
} {
if got := updateCommandFor(exe); !strings.Contains(got, want) {
t.Errorf("%q: %q", exe, got)
}
}
}

func TestSplit(t *testing.T) {
for _, c := range []struct {
in []string
Expand Down
4 changes: 4 additions & 0 deletions internal/cli/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,10 @@ func (d *doctor) directory(ctx context.Context, dir string, protects bool, getVM
case inst.Status == vm.Running:
d.line("warn", "running with a share the host does not confine: "+confined.Error(),
"The next start (after 'agent-vm stop') sets its shares back.")
case inst.Orphaned:
d.line("warn", "running without Lima's host agent, cut off", "'agent-vm stop' stops it, and so does the next agent-vm command here that starts a VM.")
case inst.Status == vm.Broken:
d.line("warn", "Broken: Lima cannot say whether it runs")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
default:
d.line("info", "stopped")
}
Expand Down
11 changes: 11 additions & 0 deletions internal/cli/doctor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (

"github.com/sylvinus/agent-vm/internal/paths"
"github.com/sylvinus/agent-vm/internal/state"
"github.com/sylvinus/agent-vm/internal/vm"
"github.com/sylvinus/agent-vm/internal/vmname"
)

Expand Down Expand Up @@ -83,6 +84,16 @@ func TestDoctor(t *testing.T) {
if o = out(); !strings.Contains(o, " warn running with a share the host does not confine: ") || !strings.Contains(o, `the mount type is "9p"`) {
t.Errorf("9p:\n%s", o)
}
// Broken, orphaned or not: never "stopped".
v.Status = vm.Broken
if o = out(); !strings.Contains(o, " warn Broken: Lima cannot say whether it runs") {
t.Errorf("Broken:\n%s", o)
}
v.Orphaned = true
if o = out(); !strings.Contains(o, " warn running without Lima's host agent, cut off") {
t.Errorf("orphaned:\n%s", o)
}
v.Status, v.Orphaned = vm.Running, false
if strings.Contains(se.fake.CallLog(), "edit") || strings.Contains(se.fake.CallLog(), "start") || strings.Contains(se.fake.CallLog(), "stop") {
t.Errorf("doctor changed the VM:\n%s", se.fake.CallLog())
}
Expand Down
Loading
Loading