Metadata-Version: 2.4
Name: agmx
Version: 0.2.2
Summary: Run AgentMatrix work on your own machine, under your own coding-agent subscription.
Author: lupca
License-Expression: MIT
Project-URL: Homepage, https://github.com/nothan-agentic/AgentMatrix
Project-URL: Source, https://github.com/nothan-agentic/AgentMatrix/tree/main/runner
Keywords: agmx,agentmatrix,coding-agent,runner
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Software Development :: Build Tools
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: httpx>=0.24
Dynamic: license-file

# agmx

[![PyPI](https://img.shields.io/pypi/v/agmx.svg)](https://pypi.org/project/agmx/)
[![Python](https://img.shields.io/pypi/pyversions/agmx.svg)](https://pypi.org/project/agmx/)
[![License](https://img.shields.io/pypi/l/agmx.svg)](https://github.com/nothan-agentic/AgentMatrix/blob/main/runner/LICENSE)

Run AgentMatrix work on **your own machine**, under **your own** coding-agent
subscription.

AgentMatrix is a coordination service for coding agents: it plans work, holds
the review gates, and decides what lands. `agmx` is the other half — a small
client that picks up the work the server hands out and executes it locally,
inside a throwaway git worktree of a repository you already have checked out.

The server dispatches a `project_id`; **your** machine decides which directory
that is. When the runner enrolls it reports, per project, the checkout's path and
its remote URL — with any username, password or token stripped — so the server
can confirm the checkout belongs to that project. A project the server rejects
is printed and dropped for the session; if every project is rejected,
`agmx runner start` exits instead of polling for work it can never take.

## Why it is separate

The control plane needs Postgres, Redis and a pile of orchestration code. A
runner does not. `agmx` depends on `httpx` and nothing else, so installing it
never asks you for a database URL or a shared secret.

```console
$ pip list
agmx  anyio  certifi  h11  httpcore  httpx  idna  typing_extensions
```

## Install

```bash
pipx install agmx
```

Requires Python 3.10+ and `git` on `PATH`.

## Quickstart

```bash
# 1. Authenticate.  Opens your browser at your team's sign-in page.
agmx login

# 2. Tell the runner where your checkouts live, then let it work.
agmx runner start --project agenticmatix=~/code/AgentMatrix
```

`agmx login` asks the server where your team signs in (its OAuth metadata) and
logs you in there with your browser: a loopback redirect to `127.0.0.1`, PKCE
and a `state` check. On a machine you reach over SSH, `agmx login --device`
shows a code to enter on any device with a browser. `agmx login --token` takes a
personal access token instead (create one with `manage_user create_token` from a
logged-in coordinator CLI); it is read from the terminal, never from the command
line. The session refreshes itself; refresh tokens rotate, so each refresh is
saved at once and taken under a file lock, which lets two `agmx` processes share
one login. A server without single sign-on still gets `--email` and a password.

If the server refuses to enroll you (`not_a_member`, `email_not_verified`,
`user_disabled`, `subject_mismatch`, `invite_expired`), the runner prints the
server's answer and exits: those are for your team's admin, and retrying does
not change them.

`--project` may be repeated. A `project_id` you have not registered is reported
back as failed rather than guessed at, so a run can never be executed against
the wrong tree.

Point at a different control plane with `--server`, or `AGMX_SERVER`.

The first `runner start` enrolls the machine with your login and receives the
machine's own **runner token**, stored next to your login token. From then on
every call the runner makes — including enrolling again when it restarts — uses
that token, not your login session. If the server stops accepting it (the
runner was revoked, or you logged in elsewhere and re-enrolled), the runner
exits with one line telling you to `agmx login` and start again.

By default the server gives this machine only your own runs. `--serve-team`
(remembered; `--no-serve-team` to stop) lets it take your teammates' runs too —
they then run on the CLI subscriptions logged in on this machine.

On start, the runner removes worktrees that a runner which died mid-run left
behind in your registered checkouts — only its own (`agmx-*/worktree` in its
data directory, or in the temp directory where older versions put them, whose
owning process is gone), never yours or those of a runner that is still running.

## Sandbox by default

The server builds every agent command to run inside that CLI's own sandbox.
Full access — the agent can do anything your OS user can — is **your** choice,
made on this machine:

```bash
agmx runner start --full-access ...     # remembered in ~/.agmx/settings.json
agmx runner start --no-full-access ...  # back to the default
```

With it off, the runner does not take the server's word for it: a command
carrying a flag that switches the sandbox or permission checks off
(`--dangerously-skip-permissions`, `--allow-dangerously-skip-permissions`,
`--permission-mode bypassPermissions`, `--dangerously-bypass-approvals-and-sandbox`,
codex `--sandbox danger-full-access`, qwen `--yolo` without `--sandbox`) is not
run; the run is reported failed before the agent starts.

At start it also checks, for each agent CLI on your `PATH`, that its sandbox
really works here, with a real probe rather than a guess, and prints exactly
what to install or configure when it does not: `bwrap` and `socat` for claude on
Linux, plus the kernel setting that Ubuntu 24.04+ uses to block the nested user
namespace claude's shell sandbox needs; a running docker or podman for qwen.
**agy is not treated as sandboxed**: its sandbox has not been measured anywhere
it starts and does not keep it from reading `~/.agmx`, where the other runs'
tokens are, so agy runs are refused unless you choose `--full-access`.
Runs for a CLI whose sandbox cannot work are refused with that fix, instead of
quietly running unsandboxed; the other CLIs keep working. The runner never
edits your CLI settings files.

## How a run executes

1. The runner leases one run: `{run_id, task_id, project_id, command,
   timeout_seconds, kind, base_ref, needs}`. No path, no repository URL.
2. It resolves `project_id` against the checkouts you registered.
3. **The commits the run needs must be here.** Every SHA in `needs` (and
   `base_ref`) is checked; any that is missing is fetched from the
   `agmx-run/*` branches on your remote — that is where another machine's run
   left it. Still missing means the run is reported as failed, naming the SHA.
   It is never run against some other commit instead.
4. It creates a git worktree at `base_ref` when the server gave one (a fix
   round starts from the previous attempt; a review sits at the head it
   reviews), otherwise at the **freshly fetched tip of the shared branch on the
   remote** — never your local checkout, even when it is on a detached HEAD, so
   every machine starts from the same base. The shared branch is your current
   branch's upstream, or the remote's default branch when your checkout is
   detached — or the branch the server names for the project, when it names
   one. A repository with no remote uses local HEAD. `.agmx/` is created
   inside it for the files an agent hands back to the server.
5. The command runs there through `sh -c`, with `AGMX_WORKTREE` set to the
   worktree's absolute path — some agent CLIs start their shell tool somewhere
   else and would otherwise go looking for the repository across your disk.
   The run's MCP server goes into `~/.agmx/run-secrets/<run_id>/` (`0700`,
   files `0600`) — inside `~/.agmx`, which the sandboxes the server builds for
   claude and codex refuse to read, so a sandboxed agent cannot read the token of
   another run on the same machine — in the form each CLI reads:
   `AGMX_MCP_CONFIG` (the lease's config verbatim, for `--mcp-config` of claude
   and qwen) and `AGMX_AGY_DIR` (agy, `--add-dir`: it holds an `agmx` plugin).
   qwen's sandbox is a container that mounts only the worktree, `~/.qwen` and
   `TMPDIR`, so the runner mounts that one run's directory into it, read-only,
   through `SANDBOX_MOUNTS`. The run's token
   is exported as `AGMX_MCP_TOKEN`, and the command gets its own private
   `TMPDIR`. All of it is deleted when the run ends — on timeout, cancellation
   or interruption too — and the token is masked in any output streamed back.
   An older server that sends no MCP still gets these, with no servers in them.

   The command does **not** inherit the runner's environment. It gets an
   allowlist: `PATH`, `HOME`, `USER`, `LOGNAME`, `SHELL`, `LANG`, `LC_*`, `TERM`,
   `TMPDIR`, `TZ`, `XDG_*`, the proxy variables (`http(s)_proxy`, `no_proxy`, in
   both cases), CA bundles (`NODE_EXTRA_CA_CERTS`, `SSL_CERT_FILE`,
   `SSL_CERT_DIR`), and the agent CLIs' own configuration: `ANTHROPIC_*`,
   `CLAUDE_*` and `CLOUD_ML_REGION` (claude); `CODEX_*` and `OPENAI_API_KEY`
   (codex); `AGY_*`, `ANTIGRAVITY_*`, `GEMINI_API_KEY`, `GOOGLE_API_KEY`,
   `GOOGLE_GEMINI_BASE_URL`, `GOOGLE_GENAI_USE_ENTERPRISE` (agy); `QWEN_*`,
   `OPENAI_BASE_URL`, `GOOGLE_CLOUD_PROJECT`, `GOOGLE_GENAI_USE_VERTEXAI` (qwen).
   Anything else — cloud credentials for Bedrock or Vertex, say — is passed only
   when you ask: `--pass-env NAME` (repeatable), or `"pass_env": [...]` in
   `~/.agmx/settings.json`. The runner's own git commands keep the full
   environment, since pushing needs your SSH agent. If the repository has no git
   identity configured, the command also gets `GIT_AUTHOR_*`/`GIT_COMMITTER_*`
   as `agmx runner <agmx@localhost>` in its environment, so an agent never
   writes `git config` into your clone. Output is streamed back in batches while
   it runs, and every batch is delivered before the result is reported. The
   runner keeps sending heartbeats throughout, so a long run keeps its lease.
6. What happens on exit depends on `kind`:
   - **`execute`** is the only kind that commits. The agent may commit by
     itself; whatever it leaves uncommitted is committed after it on
     `agmx-run/<run_id>` — never anything under `.agmx/`, which your repository
     does not ignore. The run's head is the worktree's `HEAD`; one that does
     not descend from the base (the agent checked out something unrelated)
     fails the run instead of being reported as the work. The branch is
     **pushed** to your remote, because the
     reviewer is usually a different machine. A push that fails fails the run,
     saying the commit exists only on this machine; the local branch is kept.
     A pre-commit hook that rejects the change fails the run too, but the work
     is still committed to the branch so someone can see what was rejected.
     If the commit itself fails (a signing key that cannot be used, say), the
     worktree is **kept on disk** and its path is in the error, so the agent's
     uncommitted work can be recovered.
   - **Every other kind** (`review`, and any kind added later) is read-only: no
     branch, no commit, no push. A review run's
     `.agmx/review-<task_id>.json` is read before the worktree goes and sent with
     the result — as an object when it is a JSON object, verbatim otherwise.
7. The worktree is removed and the runner reports facts: exit code, base and
   head SHA, whether it timed out. **It does not decide** whether the run
   succeeded; that judgement stays on the server, with the review history.
   The exit code is the agent's own: `0` when the agent finished cleanly and a
   step of the runner's failed afterwards (commit, push), and absent when the
   agent never started (a missing commit, a worktree that could not be
   created) — so the server does not blame the agent for this machine's git.
   A report the server did not record (a 5xx, a dropped connection) is resent a
   bounded number of times; a refusal (4xx) is final.

A run that exceeds `timeout_seconds` has its whole process group terminated,
not just the process the runner spawned — coding agents spawn children. The same
happens at once when the server cancels a run (the heartbeat lists it, or the
server answers `run_cancelled`/`not_your_run`): nothing is committed or pushed,
the worktree is cleaned up, and the runner reports `failed` once.

## How a result lands

The control plane keeps no checkout of your code, so it cannot merge and it
does not pretend to. Once a result passes review, the server offers the merge
to a runner that has registered that project, and the merge happens here:

1. Fetch the shared branch (the one the server names for the project, else your
   checkout's upstream), and check whether the commit is already on it — if
   it is, the work is done and the runner says so. A commit this machine never
   had is fetched from the `agmx-run/*` branch the executing machine pushed.
2. Create a **temporary worktree at the fetched remote tip** and merge there
   with `--no-ff`. Your own checkout is never checked out, merged into, or
   required to be clean: a local branch that is behind, a detached HEAD, or
   uncommitted edits make no difference.
3. **Push** `HEAD` to the shared branch, then remove the temporary worktree. A
   merge that is not pushed leaves the work on exactly one laptop, and the merge
   commit reported back to the server would name something nobody else can see.
4. Delete the `agmx-run/*` branches, locally and on the remote, that the landed
   commit now contains — only those, and a failure to delete never fails the
   landing.
5. Report the merge commit. Only then does the task become `done`.

A repository with no remote is a single-machine project: the merge goes into
the checked-out branch, which then has to be on a branch and clean.

Landing refuses rather than improvising, and says which kind of failure it
was, because that decides who waits: **`local`** — this machine's own state (not
a repository, the commit not found even after fetching, and without a remote a
detached HEAD or uncommitted tracked changes); another machine of the team may
still land it.
**`transient`** — the push timed out, or the remote moved ahead
(`non-fast-forward`, `fetch first`); retried shortly. **`conflict`** — a merge
conflict, or a push refused for any other reason (a protected branch, no
permission); only a person can fix those. Each failure is reported with the
reason, and your repository is left as it was — the temporary worktree goes
with the failed merge or the unpushed one, and the next attempt starts again
from the remote's new tip. Your uncommitted work is never touched.

The practical consequence: **if nobody is running `agmx runner start`, no task
reaches `done`.** That is the design, not an outage.

## Configuration

| Path | Mode | Contents |
| --- | --- | --- |
| `~/.agmx/.credentials.json` | `0600` | login session (access and refresh token) or personal access token, runner token |
| `~/.agmx/settings.json` | `0644` | `server`, `email`, `issuer`, `runner_id`, `pass_env`, `full_access`, `serve_team` |
| `~/.agmx/run-secrets/` | `0700` | one directory per running run: its MCP config and agy plugin, removed when it ends |
| `~/.local/share/agmx/runs/` | `0700` | one directory per run: its worktree, secrets, `TMPDIR` |

Secrets are kept in a separate file from settings, and the credentials file is
created with its mode already set rather than being widened and then narrowed.
Set `AGMX_CONFIG_DIR` to move the directory. Run directories live apart from
it (`$XDG_DATA_HOME/agmx`, or `AGMX_DATA_DIR`): the sandboxes the server builds
deny reads of `~/.agmx`, which holds your tokens, and a worktree in `/tmp` would
be writable by every sandboxed run.

## Security notes

- The password is read from the terminal only. There is deliberately no
  `--password` flag: it would survive in shell history and in `ps` output.
- `GIT_DIR` and `GIT_WORK_TREE` are stripped from the environment handed to the
  command, and from the runner's own git calls, so neither an agent nor a merge
  can reach outside the repository it was pointed at. Those variables beat
  `git -C`, always.
- The default server is HTTPS. `agmx login` sends a password in the request
  body; plain HTTP would hand it to anyone on the path.
- The runner token can drive only the runner routes: it cannot call a single
  coordinator tool, and revoking one machine does not touch your login.
- An agent never sees the runner's full environment (see *How a run executes*),
  and never writes into your checkout's git config: missing git identity is
  given to it through its environment.

## Development

```bash
git clone https://github.com/nothan-agentic/AgentMatrix
cd AgentMatrix/runner
pip install -e .
python -m pytest tests -q
```

## License

MIT — see [LICENSE](LICENSE).
