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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -213,4 +213,4 @@ jobs:
magent tools doctor
magent system security-report
- name: Cross-platform contract tests
run: python -m pytest -q tests/unit/test_permissions_bypass.py tests/unit/test_safe_names.py tests/unit/test_durability_and_state.py tests/unit/test_auth_store.py tests/unit/test_tools_safety.py tests/unit/test_provider_support.py tests/unit/test_security_assurance.py -k "not document_tools_create_docx_and_pptx and not visual_artifact_tools_create_svg_diagram_and_image"
run: python -m pytest -q tests/unit/test_process_liveness.py tests/unit/test_permissions_bypass.py tests/unit/test_safe_names.py tests/unit/test_durability_and_state.py tests/unit/test_auth_store.py tests/unit/test_tools_safety.py tests/unit/test_provider_support.py tests/unit/test_security_assurance.py -k "not document_tools_create_docx_and_pptx and not visual_artifact_tools_create_svg_diagram_and_image"
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## 1.3.0 — Unreleased

See [release notes](docs/RELEASE_NOTES_1.3.0.md).

## Unreleased

## 1.2.0 (2026-09-06)
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<div align="center">

Next release: [1.3.0 preparation notes](docs/RELEASE_NOTES_1.3.0.md).

<img src="docs/assets/brand/magagent-logo.png" alt="MagAgent logo" width="220">

# 🐦‍⬛ MagAgent
Expand Down
15 changes: 15 additions & 0 deletions docs/RELEASE_NOTES_1.3.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# MagAgent 1.3.0

Release notes — September 12, 2026.

- External approval decisions now wake the issuing broker; request creation and decisions share full storage transactions. Corruption and lock timeout fail closed. Windows locking uses an actual byte lock.
- Stdio decisions validate their AAIS envelope and reviewed digest. The Web UI reports the authority outcome, retains dialog focus, and shows origin and expiry.
- `magent capabilities --json` reports installed WebMCP prerequisites without a model call; the existing `magent tools doctor` remains available.
- Run center exposes recorded file changes, checkpoints and final audit evidence before recovery, and recognizes the runtime's waiting state. Task status changes are explicitly distinguished from restarting a stopped harness.
- Config safety and workbench maintenance now pass the type checker and were removed from its exclusion list.

Approval and task records remain local to their existing authority. Inspect uncertain external effects before retrying; changing a task status is not a guarantee of execution resumption.

AGS, OAP and AAIS document/wire formats remain unchanged. Local validation evidence and remaining platform gates are recorded in the ecosystem release report.

Recovery ownership checks use a non-destructive Windows process query, with a regression test that verifies live child processes survive inspection.
4 changes: 1 addition & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "mag-agent"
version = "1.2.0"
version = "1.3.0"
description = "MagAgent — CLI AI coding agent with persistent MagGraph memory. Like the Magpie: intelligent, tool-using, and never forgets."
readme = "README.md"
license = { text = "Apache-2.0" }
Expand Down Expand Up @@ -181,7 +181,6 @@ module = [
"magent.cli.commands.plugins",
"magent.cli.main",
"magent.config",
"magent.config_safety",
"magent.gateway.adapters.slack",
"magent.goal_orchestrator",
"magent.model_capabilities",
Expand All @@ -190,7 +189,6 @@ module = [
"magent.tools.web",
"magent.utils",
"magent.workbench",
"magent.workbench_maintenance",
]
ignore_errors = true

Expand Down
2 changes: 1 addition & 1 deletion src/magent/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""MagAgent — CLI AI coding agent powered by MagGraph persistent memory."""

__version__ = "1.2.0"
__version__ = "1.3.0"
__author__ = "Alex Merced"
99 changes: 80 additions & 19 deletions src/magent/approval_broker.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@

import copy
import json
import os
import signal
import sys
import threading
import time
from collections.abc import Callable, Mapping
from contextlib import suppress
from dataclasses import dataclass
Expand All @@ -29,7 +31,7 @@
validate,
)

from magent.workbench_store import WorkbenchStore
from magent.workbench_store import WorkbenchStore, WorkbenchStoreError

Envelope = dict[str, Any]
Publisher = Callable[[Envelope], None]
Expand Down Expand Up @@ -109,8 +111,17 @@ def _empty() -> Envelope:
}

def _state(self) -> Envelope:
state = self.store.read(self.STORE_NAME, self._empty())
return state if isinstance(state, dict) else self._empty()
state = self.store.read(self.STORE_NAME, self._empty(), strict=True)
if (
not isinstance(state, dict)
or state.get("schema") != self._empty()["schema"]
or any(
key not in state or not isinstance(state[key], type(default))
for key, default in self._empty().items()
)
):
raise WorkbenchStoreError("Invalid approval state; explicit recovery is required")
return state

def _next_sequence(self, state: Envelope, key: str = "sequence") -> int:
value = int(state.get(key, 0)) + 1
Expand Down Expand Up @@ -173,7 +184,7 @@ def request(
) -> str:
exact_action = copy.deepcopy(dict(action))
digest = action_digest(exact_action)
with self._lock:
with self._lock, self.store.lock(self.STORE_NAME):
state = self._state()
remembered = next(
(
Expand Down Expand Up @@ -229,6 +240,7 @@ def request(
)
request_id = str(envelope["request"]["id"])
state.setdefault("pending", {})[request_id] = envelope
state.setdefault("owners", {})[request_id] = os.getpid()
state.setdefault("events", []).append(envelope)
state["events"] = state["events"][-1000:]
self.store.write(self.STORE_NAME, state)
Expand All @@ -240,25 +252,38 @@ def request(
self._waiters[request_id] = waiter
self._publishers[request_id] = publish
publish(copy.deepcopy(envelope))
if not waiter.resolved.wait(timeout):
with suppress(ConflictError, ValueError):
self.decide(
request_id,
decision="deny",
scope="once",
actor={
"id": "magent.timeout",
"type": "policy",
"authenticated_by": "authority",
},
)
deadline = time.monotonic() + timeout
while not waiter.resolved.wait(min(0.1, max(0, deadline - time.monotonic()))):
with self._lock, self.store.lock(self.STORE_NAME):
durable = self._state().get("resolutions", {}).get(request_id)
if durable:
waiter.resolution = durable
publish(copy.deepcopy(durable))
break
if time.monotonic() >= deadline:
with suppress(ConflictError, ValueError):
self.decide(
request_id,
decision="deny",
scope="once",
actor={
"id": "magent.timeout",
"type": "policy",
"authenticated_by": "authority",
},
)
with self._lock, self.store.lock(self.STORE_NAME):
waiter.resolution = self._state().get("resolutions", {}).get(request_id)
break
with self._lock:
resolution = waiter.resolution
self._waiters.pop(request_id, None)
self._publishers.pop(request_id, None)
if not resolution:
return "deny"
body = resolution["resolution"]
if body["outcome"] == "approved" and action_digest(waiter.current_action()) != digest:
return "deny"
return str(body.get("effective_scope", "deny")) if body["outcome"] == "approved" else "deny"

def decide(
Expand All @@ -269,6 +294,7 @@ def decide(
scope: str,
actor: Mapping[str, Any],
decision_id: str | None = None,
reviewed_digest: str | None = None,
) -> Envelope:
publisher: Publisher | None = None
waiter: _Waiter | None = None
Expand All @@ -286,6 +312,16 @@ def decide(
raise ConflictError(f"request {request_id} was already resolved")
if pending is None:
raise ValueError(f"unknown pending approval: {request_id}")
if (
reviewed_digest is not None
and reviewed_digest != pending["request"]["action_digest"]
):
raise ConflictError("Decision digest does not match the reviewed action")
owner = state.get("owners", {}).get(request_id)
if decision == "approve" and owner is not None and not self._owner_alive(owner):
raise ConflictError(
"The issuing process stopped; inspect recovery before starting new work"
)
decided = create_decision(
pending,
decision=decision,
Expand All @@ -308,6 +344,7 @@ def decide(
state.setdefault("decisions", {})[request_id] = decided
state.setdefault("resolutions", {})[request_id] = resolution
state.setdefault("events", []).append(resolution)
state["events"] = state["events"][-1000:]
if resolution["resolution"]["outcome"] == "approved" and scope in {
"session",
"persistent",
Expand Down Expand Up @@ -375,12 +412,35 @@ def cancel_active(self) -> int:
)
return len(request_ids)

@staticmethod
def _owner_alive(pid: int | None) -> bool:
from magent.process_liveness import process_alive

return process_alive(pid)

def recovery(self) -> Envelope:
with self._lock, self.store.lock(self.STORE_NAME):
state = self._state()
owners = state.get("owners", {})
return {
"orphaned": [
key
for key in state["pending"]
if key in owners and not self._owner_alive(owners[key])
],
"unknown_owner": [key for key in state["pending"] if key not in owners],
"receipts": list(state["resolutions"].values())[-100:],
"guidance": "Stopped owners are not restarted. Inspect completed effects before creating a new run.",
}

def snapshot(self) -> Envelope:
with self._lock:
state = self._state()
machine = ApprovalStore(last_sequence=int(state.get("sequence", 0)))
for envelope in state.get("pending", {}).values():
machine.add(validate(envelope))
for key, envelope in state.get("pending", {}).items():
owner = state.get("owners", {}).get(key)
if owner is None or self._owner_alive(owner):
machine.add(validate(envelope))
return machine.snapshot(stream=self.stream)

def events_after(self, sequence: int) -> list[Envelope]:
Expand Down Expand Up @@ -409,7 +469,7 @@ def read_decisions() -> None:
try:
for line in sys.stdin:
try:
envelope = json.loads(line)
envelope = validate(json.loads(line))
if envelope.get("type") != "approval.decided":
continue
decision = envelope["decision"]
Expand All @@ -423,6 +483,7 @@ def read_decisions() -> None:
"authenticated_by": "aais-ndjson-stdio",
},
decision_id=str(decision.get("id") or "") or None,
reviewed_digest=str(decision["action_digest"]),
)
except Exception as error: # malformed input never grants authority
print(
Expand Down
57 changes: 57 additions & 0 deletions src/magent/capability_readiness.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@
from __future__ import annotations

import importlib.util
import json
import subprocess
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any

from magent import __version__


@dataclass(frozen=True)
Expand Down Expand Up @@ -50,3 +56,54 @@ def readiness_report() -> dict[str, object]:
"capabilities": capabilities,
"full_install": 'python -m pip install "mag-agent[full]"',
}


def capability_report() -> dict[str, Any]:
reasons: list[str] = []
package = importlib.util.find_spec("playwright") is not None
browser = False
if package:
try:
from playwright._impl._driver import compute_driver_executable

node, cli = compute_driver_executable()
script = (
"process.stdout.write(require("
+ json.dumps(str(Path(cli).parent))
+ ").chromium.executablePath())"
)
result = subprocess.run(
[node, "-e", script], capture_output=True, text=True, timeout=3, check=True
)
browser = Path(result.stdout).is_file()
except Exception:
reasons.append("Browser driver could not be inspected.")
if not package:
reasons.append("Install the webmcp optional dependency pack.")
if not browser:
reasons.append("Install the Playwright Chromium browser.")
try:
from magent.browser import normalize_webmcp_origins
from magent.config import load_config

origins = normalize_webmcp_origins(load_config().get("webmcp", "origins", default=None))
except (ValueError, RuntimeError):
origins = ()
reasons.append("Configure at least one valid exact HTTPS origin.")
return {
"schema": "agent-runtime.capabilities.v1",
"harness": "magagent",
"version": __version__,
"supported": {"aais": True, "webmcp": True, "oap": "1.0", "ags": "1.0"},
"ready": {"webmcp": bool(package and browser and origins)},
"webmcp": {
"package_installed": package,
"browser_installed": browser,
"origins": list(origins),
"registry_status": "not_checked",
"reasons": reasons,
},
"limits": [
"Live registry, credentials and profile policy are checked by the harness at execution."
],
}
12 changes: 12 additions & 0 deletions src/magent/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,18 @@ def main(
_run_repl(username, config, main_provider, extract_provider, cwd, profile=effective_profile)


@app.command("capabilities")
def capabilities_cmd(json_output: bool = typer.Option(True, "--json/--no-json")) -> None:
"""Inspect installed runtime capabilities without running a model or opening a site."""
from magent.capability_readiness import capability_report

report = capability_report()
if json_output:
console.print_json(data=report)
else:
console.print(report)


@app.command("ask", rich_help_panel="Everyday Agent Work")
def ask_cmd(
task: str = typer.Argument(..., help="One-shot task to run non-interactively"),
Expand Down
10 changes: 7 additions & 3 deletions src/magent/config_safety.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ def config_paths(username: str | None = None) -> dict[str, str]:

def show_config(username: str | None = None) -> dict[str, Any]:
"""Return config file paths and text for global/current-user config."""
result = {"ok": True, "files": {}}
result: dict[str, Any] = {"ok": True, "files": {}}
for key, raw_path in config_paths(username).items():
path = Path(raw_path)
text = path.read_text(encoding="utf-8") if path.exists() else ""
Expand All @@ -50,7 +50,7 @@ def redact_config_text(text: str) -> str:
key = stripped.split("=", 1)[0].strip().strip('"').strip("'").lower()
if any(secret in key for secret in SECRET_KEYS):
prefix = line.split("=", 1)[0].rstrip()
lines.append(f"{prefix} = \"***\"")
lines.append(f'{prefix} = "***"')
else:
lines.append(line)
return "\n".join(lines) + ("\n" if text.endswith("\n") else "")
Expand Down Expand Up @@ -94,7 +94,11 @@ def diff_config(backup_id: str | None = None, username: str | None = None) -> di
current = Path(raw_path)
old = backup / f"{key}.toml"
before = old.read_text(encoding="utf-8").splitlines(keepends=True) if old.exists() else []
after = current.read_text(encoding="utf-8").splitlines(keepends=True) if current.exists() else []
after = (
current.read_text(encoding="utf-8").splitlines(keepends=True)
if current.exists()
else []
)
diffs[key] = "".join(
difflib.unified_diff(
before,
Expand Down
1 change: 1 addition & 0 deletions src/magent/docs/command-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ Generated from the active Typer command tree.
- `magent auth`
- `magent browser`
- `magent cache`
- `magent capabilities`
- `magent checkpoint`
- `magent ci`
- `magent code`
Expand Down
Loading
Loading