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
7 changes: 7 additions & 0 deletions .github/workflows/image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ jobs:
repository: PointBlueTechnology/DirXMLDev
ref: v${{ steps.v.outputs.core }}
path: DirXMLDev
- name: DirXMLSimulator (its policy-testing skill, for the agent kit)
uses: actions/checkout@v4
with:
repository: PointBlueTechnology/DirXMLSimulator
ref: v${{ steps.v.outputs.sim }}
path: DirXMLSimulator
- name: DirXMLDev and simulator jars
env:
GH_TOKEN: ${{ github.token }}
Expand Down Expand Up @@ -70,6 +76,7 @@ jobs:
- name: Build and push
env:
IDM_HOME: ${{ github.workspace }}/DirXMLDev
SIM_HOME: ${{ github.workspace }}/DirXMLSimulator
IDM_SIM_VERSION: ${{ steps.v.outputs.sim }}
PUSH: "1"
ALSO_TAG: latest
Expand Down
25 changes: 24 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,30 @@ All notable changes to DirXMLDevWeb are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
## [0.72.0] - 2026-10-22

Needs DirXMLDev 0.50.0.

### Added

- **Agents get everything a DirXMLDev install gives them** (docs/agents.md).
- **MCP:** the server is an MCP server at `/mcp`, with 22 tools: orientation, edit operations
(preview, then apply), commit, compare, compare environments, plan, deploy, rollback, driver
control, simulate, trace. Each tool is a call to the server's own API with the caller's token:
the same sign-in, roles, audit, write lock and production gate as the page.
- **Personal agent tokens:** your name at the top right → *agent tokens…*. An agent with your
token acts as you (your roles, connections, workspace), marked `via` the token in the audit log.
Only a hash is kept; roles follow you; an agent cannot make tokens. The dialog shows the command
that connects Claude Code. `agent.tokens` / `agent.tokens.maxDays` in `hosted.properties`.
- **Discoverable over MCP:** the agent kit is served as MCP resources (`dirxmldev://kit/…`), and
two prompts: `setup` (read the rules, install the skills, orient; `/mcp__dirxmldev__setup` in
Claude Code) and `orient`. The token dialog's *Set up your agent* gives the same text to paste
into agents without MCP prompts; it holds no token.
- **The agent kit:** this version's `AGENTS.md`, `CLAUDE.md`, the `dirxml-dev` and
`dirxml-policy-testing` skills and the agent docs. In the image; the local install's `setup`
writes it to `agent/`; *Download the agent kit* gives it with the server's MCP address.
- **`sim` and `apps` in the image**, beside `idm`: `docker exec idmweb sim …` (the policy
simulator's CLI), `docker exec idmweb apps …` (Identity Applications), in your workspace.

## [0.71.0] - 2026-10-20

Expand Down
19 changes: 19 additions & 0 deletions bin/smoke-container.sh
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,25 @@ s, c = call("POST", "/api/git/commit", {"message": "From the container", "push":
check("commit and push reach the host's repository", s == 200 and c["pushed"] and "From the container" in subprocess.run(["git", "--git-dir", os.path.join(work, "repo.git"), "log", "feature/container", "-1", "--format=%s"], capture_output=True, text=True).stdout)
cli = subprocess.run(["docker", "exec", name, "idm", "validate", "/var/lib/idmweb/workspaces/repo/agent_ci/feature_container/tree", "--json"], capture_output=True, text=True)
check("the idm CLI inside the container validates the same workspace", cli.returncode == 0 and '"ok":true' in cli.stdout.replace(" ", ""))
# agents (docs/agents.md): the simulator's and the Identity Applications' CLIs, the MCP endpoint, the agent kit
sim = subprocess.run(["docker", "exec", name, "sim"], capture_output=True, text=True)
check("the simulator's CLI is in the image", "run" in sim.stdout + sim.stderr and "usage" in (sim.stdout + sim.stderr).lower())
apps = subprocess.run(["docker", "exec", name, "apps", "--help"], capture_output=True, text=True)
check("the Identity Applications helper is in the image", apps.returncode == 0 and "--env" in apps.stdout)
s, init = call("POST", "/mcp", {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "smoke", "version": "1"}}})
check("the container answers MCP at /mcp", s == 200 and init["result"]["serverInfo"]["name"] == "dirxmldevweb")
s, rl = call("POST", "/mcp", {"jsonrpc": "2.0", "id": 2, "method": "resources/list"})
uris = [x["uri"] for x in rl["result"]["resources"]]
check("its MCP resources are the kit: the rules and both skills", "dirxmldev://kit/AGENTS.md" in uris and "dirxmldev://kit/.claude/skills/dirxml-dev/SKILL.md" in uris
and "dirxmldev://kit/.claude/skills/dirxml-policy-testing/SKILL.md" in uris)
req = urllib.request.Request(base + "/api/agent-kit", headers={"Authorization": "Bearer " + TOKEN})
with urllib.request.urlopen(req) as r:
import io, zipfile
z = zipfile.ZipFile(io.BytesIO(r.read()))
names = z.namelist()
check("the agent kit carries the rules, both skills and the docs, with this server's MCP address",
"agent-kit/AGENTS.md" in names and "agent-kit/.claude/skills/dirxml-dev/SKILL.md" in names and "agent-kit/.claude/skills/dirxml-policy-testing/SKILL.md" in names
and "agent-kit/docs/agent-guide.md" in names and (base + "/mcp") in z.read("agent-kit/AGENTS.md").decode())
with open(os.path.join(work, "data", "audit.log")) as f:
lines = [json.loads(l) for l in f if l.strip()]
check("the audit log names the agent", any(l["user"] == "agent:ci" and l["event"] == "commit+push" for l in lines))
Expand Down
53 changes: 52 additions & 1 deletion bin/smoke-hosted.sh
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ agents=ci:idm-deployers;reader:
agent.ci.token=ci-agent-token-0123456789
agent.reader.token=reader-agent-token-0123456789
ENV
IDM_ENVIRONMENTS="$WORK/data/environments.properties" "$HERE/bin/idmweb" serve --hosted "$WORK/hosted.properties" > "$WORK/log" 2>&1 &
IDMWEB_AGENT_KIT="$HERE/hosted/agent-kit" IDM_ENVIRONMENTS="$WORK/data/environments.properties" "$HERE/bin/idmweb" serve --hosted "$WORK/hosted.properties" > "$WORK/log" 2>&1 &
PID=$!
i=0
while ! grep -q 'hosted:' "$WORK/log" 2>/dev/null; do
Expand Down Expand Up @@ -256,6 +256,55 @@ check("a wrong token is refused", ci.call("GET", "/api/session", headers={"Autho
s, _, _ = ci.call("POST", "/api/workspaces/open", {"branch": "feature/x"}, headers={"Authorization": "Bearer reader-agent-token-0123456789"}, csrf=False)
check("an agent without a role reads only", s == 403)

# personal agent tokens and the MCP endpoint (docs/agents.md): alice's agent acts as alice
s, made, _ = alice.call("POST", "/api/agent-tokens", {"label": "claude", "days": 30})
check("a person makes a token for her agent; it is shown once", s == 200 and str(made.get("token", "")).startswith("idmw_") and made["label"] == "claude")
mine = {"Authorization": "Bearer " + made["token"]}
s, sess, _ = ci.call("GET", "/api/session", headers=mine)
check("her agent signs in as her, with her roles", s == 200 and sess["user"]["subject"] == "alice" and sess["user"]["editor"] and not sess["user"]["deployer"])
check("an agent cannot make tokens", ci.call("POST", "/api/agent-tokens", {"label": "x"}, headers=mine, csrf=False)[0] == 403)
s, lst, _ = alice.call("GET", "/api/agent-tokens")
check("her token list shows the label, never the token", s == 200 and [t["label"] for t in lst["tokens"]] == ["claude"] and made["token"] not in json.dumps(lst))
def mcp(body, headers=None):
h = dict(mine)
h.update(headers or {})
return ci.call("POST", "/mcp", body, headers=h, csrf=False)
s, init, _ = mcp({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "smoke", "version": "1"}}})
check("MCP: initialize answers with the protocol and the rules", s == 200 and init["result"]["protocolVersion"] == "2025-06-18" and "plan_deploy" in init["result"]["instructions"])
s, tl, _ = mcp({"jsonrpc": "2.0", "id": 2, "method": "tools/list"})
names = [t["name"] for t in tl["result"]["tools"]]
check("MCP: the tools are listed, the vault writes marked destructive", s == 200 and "run_operations" in names and "deploy" in names
and [t for t in tl["result"]["tools"] if t["name"] == "deploy"][0]["annotations"]["destructiveHint"])
s, r, _ = mcp({"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "session", "arguments": {}}})
text = r["result"]["content"][0]["text"]
check("MCP: a tool call acts as her, and the page's CSRF token stays out", s == 200 and not r["result"]["isError"] and '"subject":"alice"' in text and "csrf" not in text)
s, r, _ = mcp({"jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": {"name": "outline", "arguments": {}}})
check("MCP: her branch's tree is read through the API", s == 200 and not r["result"]["isError"] and "Loop" in r["result"]["content"][0]["text"])
s, r, _ = mcp({"jsonrpc": "2.0", "id": 5, "method": "tools/call", "params": {"name": "deploy", "arguments": {"env": "lab", "confirm": "lab"}}})
check("MCP: a vault write is the API's to refuse (an editor, not a deployer)", s == 200 and r["result"]["isError"] and "deployer" in r["result"]["content"][0]["text"])
s, _, _ = mcp({"jsonrpc": "2.0", "id": 6, "method": "tools/list"}, headers={"Host": "evil.example"})
check("MCP: another host is refused", s == 403)
s, _, _ = anon.call("POST", "/mcp", {"jsonrpc": "2.0", "id": 7, "method": "tools/list"}, csrf=False)
check("MCP: without a token it says to sign in", s == 401)
check("MCP: GET has no stream (405)", ci.call("GET", "/mcp", headers=mine)[0] == 405)
s, rl, _ = mcp({"jsonrpc": "2.0", "id": 8, "method": "resources/list"})
uris = [x["uri"] for x in rl["result"]["resources"]]
check("MCP: the agent kit is listed as resources", s == 200 and "dirxmldev://kit/AGENTS.md" in uris)
s, rr, _ = mcp({"jsonrpc": "2.0", "id": 9, "method": "resources/read", "params": {"uri": "dirxmldev://kit/AGENTS.md"}})
check("MCP: a resource reads with this server's MCP address filled in", s == 200 and (base + "/mcp") in rr["result"]["contents"][0]["text"] and "{{" not in rr["result"]["contents"][0]["text"])
s, bad, _ = mcp({"jsonrpc": "2.0", "id": 10, "method": "resources/read", "params": {"uri": "dirxmldev://kit/../../hosted.properties"}})
check("MCP: a resource outside the kit is refused", s == 200 and "error" in bad)
s, pl, _ = mcp({"jsonrpc": "2.0", "id": 11, "method": "prompts/list"})
check("MCP: the setup and orient prompts are listed", s == 200 and [p["name"] for p in pl["result"]["prompts"]] == ["setup", "orient"])
s, pg, _ = mcp({"jsonrpc": "2.0", "id": 12, "method": "prompts/get", "params": {"name": "setup", "arguments": {"skills_dir": ".claude/skills"}}})
st = pg["result"]["messages"][0]["content"]["text"]
check("MCP: the setup prompt reads the rules, installs the skills where asked, then orients", s == 200 and "dirxmldev://kit/AGENTS.md" in st and ".claude/skills/<the path" in st and "validate" in st)
s, lst, _ = alice.call("GET", "/api/agent-tokens")
check("the page's setup prompt is the MCP prompt's, without a secret", s == 200 and lst["setupPrompt"].startswith("Set yourself up") and "idmw_" not in lst["setupPrompt"])
s, _, _ = alice.call("POST", "/api/agent-tokens/revoke", {"id": made["id"]})
check("she revokes the token", s == 200)
check("a revoked token no longer signs in", ci.call("GET", "/api/session", headers=mine)[0] == 401)

s, _, h = alice.call("GET", "/auth/logout")
check("sign-out clears the cookie", s == 303 and "Max-Age=0" in h.get("set-cookie", ""))
check("and the session is gone", alice.call("GET", "/api/session")[0] == 401)
Expand Down Expand Up @@ -365,6 +414,8 @@ events = [(l["user"], l["event"], l["outcome"]) for l in lines]
check("the audit log has sign-ins, the workspace, the edit, the commit and the sign-out, per user",
("alice", "sign-in", "ok") in events and ("alice", "workspace.open", "ok") in events and ("alice", "op/gcv.set", "ok") in events and ("alice", "commit+push", "ok") in events and ("bob", "workspace.open", "ok") in events and ("alice", "sign-out", "ok") in events and ("agent:ci", "workspace.open", "ok") in events)
check("and no line carries a file's contents", all("Hosted Corp" not in json.dumps(l["details"]) for l in lines))
check("her agent's calls are hers, marked via its token", any(l["user"] == "alice" and l.get("via") == "claude" for l in lines)
and any(l["user"] == "alice" and l["event"] == "agent-token-create" for l in lines) and all(made["token"] not in json.dumps(l) for l in lines))
print("smoke-hosted: all passed")
PY
# the local install's `setup` (docs/simple-install.md), written into a temporary folder
Expand Down
124 changes: 124 additions & 0 deletions docs/agents.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# Agents: Claude Code, Cursor, scripts

An agent works with DirXMLDevWeb the way a person does: through the same server, as a person, under
the same rules. Installing DirXMLDevWeb gives an agent everything installing DirXMLDev would: the CLI,
the simulator, the Identity Applications helper, the skills and docs of this version, and an MCP
server.

## 1. A token

On the page, your name at the top right → **agent tokens…** → *Make the token*. Give it a label
(`claude-laptop`) and a lifetime (90 days by default). The token is shown once, with the command
that connects Claude Code.

An agent with your token **acts as you**: your roles (a reader's agent reads, an editor's edits, a
deployer's may deploy), your connections and vault credentials, your workspace of the branch you have
open. Every call it makes is in the audit log as yours, marked `via` the token's label; its commits
are yours. Revoke a token from the same dialog; it stops working at once. An agent cannot make or
revoke tokens.

Only a SHA-256 of each token is kept (`users/<you>/agent-tokens.json` in the server's data folder,
owner-only), with who you were when you last used the page, so a change of your roles reaches your
tokens. On a team server, an administrator can turn personal tokens off (`agent.tokens=false`) or
shorten their longest life (`agent.tokens.maxDays`, default 365) in `hosted.properties`.

Agents that act for no one (CI) keep their own `agents=` entries in `hosted.properties`
([hosted-mode.md](hosted-mode.md)).

## 2. MCP

The server is an MCP server at `/mcp` (Streamable HTTP, stateless). With Claude Code:

```bash
claude mcp add --transport http dirxmldev http://localhost:8080/mcp --header "Authorization: Bearer <your token>"
```

Use your server's address for a team server (`https://idm.example.com/mcp`). Keep the token in an
environment variable rather than in a file you commit.

Every tool is a call to the server's own API, made with your token: the same sign-in, origin check,
roles, audit, per-environment write lock and production gate as the page.

| Tool | What it does |
|---|---|
| `session` | who you act as, your roles, the project and branch open, the versions |
| `outline` | the tree: drivers, policies, resources, objects, and its revision |
| `read_artifact`, `read_file` | an artifact's content by tree path; a file of the tree |
| `validate` | the engine's own checks over the tree |
| `fishbone` | a driver's policy flow |
| `git_status`, `git_diff` | the workspace against the last commit or the base branch |
| `list_operations` | every edit operation and its arguments |
| `run_operations` | edit the tree: preview (the default), then apply |
| `commit` | commit, and push |
| `environments` | the environments, their tier, whether you are connected |
| `compare` | the tree against an environment's vault |
| `compare_environments` | two environments' vaults side by side |
| `plan_deploy` | a deploy step by step, and what stands in its way |
| `driver_status`, `trace`, `snapshots` | drivers' state, a driver's trace, the snapshots |
| `simulate` | run the project's simulator cases |
| `deploy`, `rollback`, `driver_lifecycle` | vault writes: need the deployer role, and the environment's name typed by the person as `confirm` |

The server tells the agent the rules when it connects: preview before applying, the plan before any
vault write, the person types the environment's name, production only from `main`, never a secret.

It also serves this version's [agent kit](#4-the-agent-kit) as MCP **resources**
(`dirxmldev://kit/AGENTS.md`, `dirxmldev://kit/.claude/skills/…`, `dirxmldev://kit/docs/…`), and two
**prompts**:

- `setup`: read the rules, install the skills (into `~/.claude/skills` unless you give `skills_dir`;
it asks before overwriting), read the agent docs, then orient. Nothing is changed in the tree or a
vault.
- `orient`: who you act as, the project and branch, what `validate` says, what is uncommitted.

### Set up your agent

After `claude mcp add`, in Claude Code:

```
/mcp__dirxmldev__setup
```

Agents that don't offer MCP prompts can be given the same text: *agent tokens…* → *Set up your
agent* → *Copy*, and paste it into the chat. It holds no token.

## 3. The CLIs

On the machine that runs the container (the local install), the CLIs run in your workspace of the
first project's branch `main`:

```bash
docker exec idmweb idm validate tree
```

```bash
docker exec idmweb sim
```

```bash
docker exec idmweb apps --env lab tasks
```

`-e IDMWEB_BRANCH=feature/x` picks another branch, `-e IDMWEB_PROJECT=<name>` another project.
Paths are the container's: use paths relative to the workspace (`tree`, `cases`). On Windows, Git
Bash rewrites arguments that look like Unix paths; prefix `MSYS_NO_PATHCONV=1` for an absolute one.

## 4. The agent kit

The rules, skills and docs of this version, for any agent that reads `AGENTS.md` or skills:

- `AGENTS.md` (and `CLAUDE.md`, which imports it): how to connect, and the rules;
- `.claude/skills/dirxml-dev/`: editing, validating, simulating and deploying a driver set;
- `.claude/skills/dirxml-policy-testing/`: testing and debugging policies with the simulator;
- `docs/`: the agent guide, this page, and how work moves between branches and environments.

The local install's `setup` writes it to `agent/` in your `idmweb` folder, and refreshes it on every
upgrade; start Claude Code there, or copy `agent/.claude/skills` to `~/.claude/skills` to have the
skills everywhere. On any server, *agent tokens…* → *Download the agent kit* gives it as a zip with
that server's MCP address.

## What stays on a workstation

A container can't hold what lives on the desktop: Designer, the vendor form builder, the VS Code
extension's views of local files, and macOS Keychain references for secrets (the container keeps
secrets in its own files, environment variables or command references). Install DirXMLDev on the
workstation for those; it works on the same repository.
4 changes: 4 additions & 0 deletions docs/hosted-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ are mounted, never shipped. `hosted/docker/compose.yml` runs it behind Caddy (TL
**Agents** (CI, an MCP server) use the same server with `Authorization: Bearer <token>` from
`agents=`, or the CLI with `docker exec idmweb idm …`; `bin/smoke-container.sh` proves the image.

**People's agents** (Claude Code, Cursor) use a token the person makes on the page and act as that
person ([agents.md](agents.md)): the server's MCP endpoint is `/mcp`. `agent.tokens=false` turns
personal tokens off; `agent.tokens.maxDays` (default 365) caps how long one may last.

## A project's repository: moving it to GitHub or GitLab

A project made on the page pushes to a repository this server created, `data/projects/<name>.git`.
Expand Down
Loading
Loading