v0.2: multi-turn /sessions endpoints backed by ACPX

- Dockerfile: install acpx@latest alongside @anthropic-ai/claude-code
- compose.yml: bind /mnt/user/appdata/clawdforge/acpx-sessions:/root/.acpx/sessions
- DB: additive sessions + session_events tables in store.py SCHEMA
- clawdforge/acpx_runner.py: AcpxManager + AcpxSession, bounded async pool,
  per-invocation subprocess model (acpx CLI itself owns the queue-owner
  lifecycle, so each turn = one fresh `acpx prompt -s <uuid>` call)
- server.py: POST/GET/DELETE /sessions + POST /sessions/{id}/turn + GET /sessions
- Per-app isolation: 404 (not 403) on cross-token session access — no
  existence leak across tokens
- Lifespan-managed TTL sweeper: every 60s soft-closes idle sessions past
  CLAWDFORGE_SESSION_TTL_SECS (1h default), hard-deletes ledger rows past
  CLAWDFORGE_SESSION_HARD_TTL_SECS (24h default)
- session_events audit table parallel to existing runs table
  (events: create, turn, close, sweep_close, hard_delete)
- /healthz now reports acpx_present + acpx_version + open_sessions count
- tests/test_sessions.py: 16 tests covering create/turn/close/list/isolation/
  sweep/pool-full/regression. /run regression test asserts byte-identical
  v0.1 response shape.

ACPX research notes (v0.6.1, openclaw/acpx):
- npm package is `acpx`, not `@openclaw/acpx`
- Sessions are scoped by (agentCommand, cwd, name?). We mint our own UUID
  as `--name` and give every session a unique cwd subdir, so the scope key
  is collision-free across apps.
- session_id source: ours. We pass --name <uuid>, ACPX records it under
  ~/.acpx/sessions/<encoded-id>.json. We never need to parse ACPX's
  acpxRecordId — our UUID is canonical.
- Subprocess lifetime: per-invocation, NOT per-session. The acpx CLI itself
  spawns/maintains a per-session "queue owner" process via local IPC; each
  `acpx prompt` call we make either elects itself owner or enqueues. The
  AcpxSession class is therefore a thin (uuid, cwd, asyncio.Lock) handle,
  not a long-lived stdio pipe. The spec's "owns one stdio pipe pair" model
  was rewritten to match reality — flagged here.
- Close semantics: soft-close via `acpx sessions close <name>`. The
  on-disk record stays (ACPX's `sessions prune` is the hard-delete path,
  not invoked from clawdforge). DELETE /sessions/<id> is documented as
  idempotent (200 with already_closed=true on second call) so SDKs can
  call close() in finally/Drop blocks safely.
- File uploads: ACPX has no file-attach ACP method exposed via the CLI.
  We prepend a [Attached files] header listing absolute paths; the agent
  uses its Read tool to open them. Same behavior as /run --files in v0.1.
- Permissions: --approve-all on the turn invocation since the container is
  unattended and callers are bearer-token-trusted. Future v0.3 may expose
  a per-session permission policy.

/run endpoint unchanged — backwards compat verified by
test_run_endpoint_unchanged + test_run_endpoint_unchanged_error_shape.

Spec: memory/spec-clawdforge-v0.2.md
ACPX CLI ref: https://github.com/openclaw/acpx/blob/main/docs/CLI.md
This commit is contained in:
Kayos 2026-04-29 06:22:55 -07:00
parent 19fe299b3d
commit 940861f70a
11 changed files with 1829 additions and 20 deletions

View file

@ -22,6 +22,20 @@ CLAUDE_BIN=claude
DEFAULT_MODEL=sonnet
DEFAULT_TIMEOUT_SECS=120
# ACPX (multi-turn /sessions endpoints). Reuses Claude Code auth at /root/.claude.
ACPX_BIN=acpx
# Working directory for each session's CWD (acpx scopes by cwd; we give each session its own subdir).
ACPX_SESSIONS_CWD=/data/acpx-cwds
# Max simultaneously-open (non-closed) sessions across all apps. New /sessions returns 503 if at cap.
CLAWDFORGE_MAX_LIVE_SESSIONS=32
# How long an idle session lives before the sweeper soft-closes it. Counted from last_turn_at (or
# created_at if no turn ever ran).
CLAWDFORGE_SESSION_TTL_SECS=3600
# How long a closed session record stays before hard-delete (ledger row + acpx on-disk metadata).
CLAWDFORGE_SESSION_HARD_TTL_SECS=86400
# Sweep cadence in seconds.
CLAWDFORGE_SWEEP_INTERVAL_SECS=60
# Run-staging area inside the container (don't change unless you also change compose mount)
RUNS_DIR=/data/runs

View file

@ -9,6 +9,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
# Claude Code CLI
RUN npm install -g @anthropic-ai/claude-code
# ACPX — headless Agent Client Protocol CLI (https://github.com/openclaw/acpx).
# Drives multi-turn /sessions endpoints. Shares Claude Code auth from /root/.claude.
RUN npm install -g acpx@latest
# Python deps in a venv
ENV VIRTUAL_ENV=/opt/venv
RUN python3 -m venv $VIRTUAL_ENV
@ -24,7 +28,9 @@ COPY clawdforge /app/clawdforge
# /data -> sqlite + runs staging
# /root/.claude -> claude code auth (cobb runs `claude /login` once per container)
# /root/.config/claude -> alt config path some claude versions use
RUN mkdir -p /data /root/.claude /root/.config/claude
# /root/.acpx/sessions -> acpx session metadata (per-session JSON files)
RUN mkdir -p /data /root/.claude /root/.config/claude /root/.acpx/sessions \
&& chmod 700 /root/.acpx
EXPOSE 8800

111
README.md
View file

@ -17,6 +17,13 @@ tokens + IP allowlist.
GET /healthz liveness + claude --version smoke
POST /run run a prompt, return parsed result
POST /files upload a file, get a file_token to pass to /run
POST /sessions create a multi-turn session (v0.2)
POST /sessions/<id>/turn send a turn to a session (v0.2)
GET /sessions/<id> read session state (v0.2)
DELETE /sessions/<id> soft-close a session (v0.2)
GET /sessions list this token's sessions (v0.2)
POST /admin/tokens mint a per-app token (admin)
GET /admin/tokens list app tokens (admin)
DELETE /admin/tokens/<name> revoke a token (admin)
@ -115,10 +122,112 @@ r.raise_for_status()
print(r.json()["result"]) # {'hello': 'world'}
```
## Multi-turn / Sessions (v0.2)
`/run` is one-shot: stateless, fast, returns a single result. When you need
multi-turn context (build something step-by-step, debug across iterations,
long-running structured tool-call work), use `/sessions/*`.
The session surface is backed by [ACPX](https://github.com/openclaw/acpx) —
the OpenClaw team's headless Agent Client Protocol CLI. Clawdforge wraps it
so apps don't need to manage ACPX subprocesses or session metadata directly.
Sessions persist on disk under `/root/.acpx/sessions/` (mounted from the
host) so they survive container rebuilds.
### Quickstart — three turns in one session
```bash
TOKEN=$CLAWDFORGE_TOKEN
CF=http://192.168.0.5:8800
# 1. Create a session
SID=$(curl -sS -X POST $CF/sessions \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"agent":"claude"}' | jq -r .session_id)
# 2. Send a turn
curl -sS -X POST $CF/sessions/$SID/turn \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"prompt":"Help me draft a SQL migration. First describe what you need to know."}'
# 3. Send a follow-up — the session keeps context
curl -sS -X POST $CF/sessions/$SID/turn \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"prompt":"Postgres 16, table users, add column tier text default \"free\""}'
# 4. Close
curl -sS -X DELETE $CF/sessions/$SID -H "Authorization: Bearer $TOKEN"
```
### Turn response shape
```json
{
"ok": true,
"session_id": "abc123...",
"turn_index": 2,
"events": [
{"jsonrpc":"2.0","method":"session/update","params":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"..."}}},
{"jsonrpc":"2.0","id":"req-1","result":{"stopReason":"end_turn"}}
],
"stop_reason": "end_turn",
"duration_ms": 12345
}
```
`events` is the raw ACP NDJSON stream from acpx, parsed into objects. Each
entry is a JSON-RPC message — `session/update` for streamed agent output,
tool calls, plan updates, etc., and a final `result` envelope with the
`stopReason`. No streaming/SSE in v0.2; the full event list is returned
when the turn ends.
### Per-app isolation
Every session is owned by the token that created it. Cross-token access
returns `404` (not `403`) so token A can't even probe whether token B's
session exists.
### TTL + cleanup
A background sweeper runs every `CLAWDFORGE_SWEEP_INTERVAL_SECS` (default 60s):
- Sessions idle longer than `CLAWDFORGE_SESSION_TTL_SECS` (default 1h) are
soft-closed via `acpx sessions close`.
- Sessions whose `closed_at` is older than `CLAWDFORGE_SESSION_HARD_TTL_SECS`
(default 24h) are hard-deleted from clawdforge's ledger.
- Closed sessions stay queryable via `GET /sessions/<id>` until the hard-TTL
fires.
### Container / deploy
The container needs both `claude` and `acpx` on PATH plus a host-mounted
volume for ACPX's session store:
```yaml
# compose.yml (already configured)
volumes:
- /mnt/user/appdata/clawdforge/acpx-sessions:/root/.acpx/sessions
```
```dockerfile
ENV ACPX_BIN=acpx
RUN npm install -g acpx@latest
```
ACPX shares Claude Code auth from the same `/root/.claude/` volume the v0.1
runtime already used, so a single `claude /login` ceremony covers both
`/run` and `/sessions/*`.
## Notes
- The CLI is `@anthropic-ai/claude-code` (not the Python `anthropic` SDK).
- Default model is `sonnet`; per-request override via `model` field.
- ACPX is the upstream session driver — see https://github.com/openclaw/acpx
and `docs/CLI.md` in that repo for protocol semantics. Clawdforge owns
the per-app ledger and TTL policy; ACPX owns session content.
- Default model is `sonnet`; per-request override via `model` field on `/run`.
For sessions, model is fixed at create time (configurable later).
- Per-run working directory is staged under `RUNS_DIR` and torn down on exit, so
`claude` can't pollute the container's working tree.
- Per-session working directory is staged under `ACPX_SESSIONS_CWD` (default
`/data/acpx-cwds/<session_id>`) and torn down on close.
- File uploads are scoped to the uploading app — token A can't reference token B's files.

448
clawdforge/acpx_runner.py Normal file
View file

@ -0,0 +1,448 @@
"""ACPX-backed multi-turn session runner (v0.2).
ACPX (https://github.com/openclaw/acpx) is a headless ACP CLI. Its session
lifecycle is per-invocation: every command starts a fresh `acpx` subprocess
that talks to a per-session "queue owner" via local IPC. Sessions persist on
disk under ~/.acpx/sessions/<encoded-id>.json, scoped by
`(agentCommand, cwd, name?)`.
That means an `AcpxSession` here is NOT a long-lived stdio pipe it is a
thin handle (cwd, name, asyncio.Lock) that mediates clawdforge's calls into
acpx. Each turn = `await asyncio.create_subprocess_exec(acpx_bin, ...)` and
read NDJSON stdout to completion.
We give every clawdforge session its own cwd subdirectory + UUID name so
acpx's `(agentCommand, cwd, name)` scope key is collision-free across apps.
Spec: memory/spec-clawdforge-v0.2.md
ACPX CLI ref: https://github.com/openclaw/acpx/blob/main/docs/CLI.md
"""
from __future__ import annotations
import asyncio
import json
import os
import shutil
import time
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
# Exit codes from acpx CLI (docs/CLI.md)
ACPX_EXIT_OK = 0
ACPX_EXIT_AGENT_ERROR = 1
ACPX_EXIT_USAGE = 2
ACPX_EXIT_TIMEOUT = 3
ACPX_EXIT_NO_SESSION = 4
ACPX_EXIT_PERMISSION_DENIED = 5
class AcpxError(Exception):
"""Base class for acpx-runner failures surfaced to the API layer."""
class AcpxNotInstalled(AcpxError):
"""ACPX_BIN not on PATH or not executable."""
class AcpxPoolFull(AcpxError):
"""The bounded live-session pool has no free slot."""
class AcpxSessionNotFound(AcpxError):
"""No live AcpxSession in memory for the given session_id."""
class AcpxSessionClosed(AcpxError):
"""The session has been closed; turn is not allowed."""
class AcpxTurnFailed(AcpxError):
"""ACPX subprocess exited non-zero or produced unparseable output."""
def __init__(self, message: str, *, exit_code: int | None = None, stderr: str = ""):
super().__init__(message)
self.exit_code = exit_code
self.stderr = stderr
@dataclass
class AcpxTurnResult:
ok: bool
events: list[dict]
stop_reason: str | None
duration_ms: int
error: str | None = None
stderr_tail: str = ""
@dataclass
class AcpxSession:
"""In-memory handle for one acpx session record.
Owns: a per-session asyncio.Lock that serializes turns, the on-disk cwd
where acpx stores its session state (because acpx scopes by cwd), and the
UUID we passed as `--name` to acpx.
"""
session_id: str # also the acpx --name value
app_name: str
agent: str
cwd: Path
created_at: int
closed: bool = False
last_turn_at: int | None = None
turn_count: int = 0
_lock: asyncio.Lock = field(default_factory=asyncio.Lock, repr=False)
class AcpxManager:
"""Process-pool-ish manager around acpx subprocess invocations.
Holds the in-memory `AcpxSession` registry. The on-disk truth lives in
two places: clawdforge's SQLite (the per-app ledger) and acpx's own
~/.acpx/sessions/*.json files (which acpx owns).
The pool is bounded `MAX_LIVE_SESSIONS` caps how many open sessions
exist at a given moment across all apps. This protects against runaway
callers exhausting fd's or filling /root/.acpx.
"""
DEFAULT_TURN_TIMEOUT_SECS = 600 # generous; turns can be long-running
def __init__(
self,
*,
acpx_bin: str = "acpx",
sessions_cwd_root: str | Path = "/data/acpx-cwds",
max_live_sessions: int = 32,
turn_timeout_secs: int | None = None,
):
self.acpx_bin = acpx_bin
self.sessions_cwd_root = Path(sessions_cwd_root)
self.sessions_cwd_root.mkdir(parents=True, exist_ok=True)
self.max_live_sessions = max_live_sessions
self.turn_timeout_secs = turn_timeout_secs or self.DEFAULT_TURN_TIMEOUT_SECS
self._sessions: dict[str, AcpxSession] = {}
self._registry_lock = asyncio.Lock()
# ---- public API ------------------------------------------------------
async def create(self, *, app_name: str, agent: str = "claude") -> AcpxSession:
"""Create a new acpx-backed session.
Mints a UUID, makes a per-session cwd, runs `acpx <agent> sessions
new --name <uuid> --cwd <dir> --format json`, captures the
session_ensured event, returns the in-memory handle.
"""
async with self._registry_lock:
if self._count_open_locked() >= self.max_live_sessions:
raise AcpxPoolFull(
f"max_live_sessions={self.max_live_sessions} reached"
)
session_id = uuid.uuid4().hex
cwd = self.sessions_cwd_root / session_id
cwd.mkdir(parents=True, exist_ok=True)
# acpx sessions new --name <uuid> on this cwd. JSON output gives
# us a `session_ensured` line we can sanity-check.
cmd = [
self.acpx_bin,
"--format", "json",
"--cwd", str(cwd),
agent,
"sessions", "new",
"--name", session_id,
]
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
except FileNotFoundError as e:
shutil.rmtree(cwd, ignore_errors=True)
raise AcpxNotInstalled(f"acpx binary not found: {self.acpx_bin}") from e
try:
stdout_b, stderr_b = await asyncio.wait_for(proc.communicate(), timeout=60)
except asyncio.TimeoutError:
proc.kill()
await proc.wait()
shutil.rmtree(cwd, ignore_errors=True)
raise AcpxTurnFailed("acpx sessions new timed out after 60s")
stdout = stdout_b.decode("utf-8", "replace")
stderr = stderr_b.decode("utf-8", "replace")
if proc.returncode != ACPX_EXIT_OK:
shutil.rmtree(cwd, ignore_errors=True)
raise AcpxTurnFailed(
f"acpx sessions new exit {proc.returncode}",
exit_code=proc.returncode,
stderr=stderr[-2000:],
)
# We don't strictly need to parse the JSON — our --name is the
# canonical id — but doing so catches weird API drift early.
ensured = _first_json_line(stdout)
if ensured is not None:
action = ensured.get("action")
if action not in ("session_ensured", "session_created"):
# Soft warning only; action name is implementation-defined and
# we have all we need (the --name we passed in).
pass
sess = AcpxSession(
session_id=session_id,
app_name=app_name,
agent=agent,
cwd=cwd,
created_at=int(time.time()),
)
self._sessions[session_id] = sess
return sess
async def turn(
self,
*,
session_id: str,
prompt: str,
files: list[str] | None = None,
timeout_secs: int | None = None,
) -> AcpxTurnResult:
"""Send a single turn to an existing session, await `end_turn`.
Caller is responsible for ledger updates (session_events row,
last_turn_at bump). This method only drives the subprocess and
parses NDJSON.
"""
sess = self._sessions.get(session_id)
if sess is None:
raise AcpxSessionNotFound(session_id)
if sess.closed:
raise AcpxSessionClosed(session_id)
async with sess._lock:
# If files are passed, we prepend a [files] hint to the prompt.
# ACPX's `claude` adapter is Claude Code's ACP wrapper, which
# does not currently expose a file-attachment ACP method that
# acpx surfaces on the CLI. The agent has Read tool access in
# its session cwd so passing absolute paths in the prompt
# itself lets the agent open them via Read. This matches the
# /run subprocess behavior (it just uses --files which is also
# a "tell the model to open these" pattern).
full_prompt = _format_prompt_with_files(prompt, files)
cmd = [
self.acpx_bin,
"--format", "json",
"--json-strict",
"--approve-all", # non-interactive container; trusted callers
"--cwd", str(sess.cwd),
sess.agent,
"prompt",
"-s", sess.session_id,
full_prompt,
]
timeout = timeout_secs or self.turn_timeout_secs
started = time.monotonic()
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
except FileNotFoundError as e:
raise AcpxNotInstalled(f"acpx binary not found: {self.acpx_bin}") from e
try:
stdout_b, stderr_b = await asyncio.wait_for(
proc.communicate(), timeout=timeout
)
except asyncio.TimeoutError:
proc.kill()
await proc.wait()
duration_ms = int((time.monotonic() - started) * 1000)
return AcpxTurnResult(
ok=False,
events=[],
stop_reason="timeout",
duration_ms=duration_ms,
error=f"acpx turn timed out after {timeout}s",
)
duration_ms = int((time.monotonic() - started) * 1000)
stdout = stdout_b.decode("utf-8", "replace")
stderr = stderr_b.decode("utf-8", "replace")
events = _parse_ndjson(stdout)
stop_reason = _extract_stop_reason(events)
if proc.returncode != ACPX_EXIT_OK:
error_msg = _exit_code_to_error(proc.returncode)
return AcpxTurnResult(
ok=False,
events=events,
stop_reason=stop_reason or "error",
duration_ms=duration_ms,
error=error_msg,
stderr_tail=stderr[-2000:],
)
sess.last_turn_at = int(time.time())
sess.turn_count += 1
return AcpxTurnResult(
ok=True,
events=events,
stop_reason=stop_reason,
duration_ms=duration_ms,
stderr_tail=stderr[-1000:] if stderr else "",
)
async def close(self, session_id: str) -> bool:
"""Soft-close session via `acpx sessions close <name>`.
Returns True if this call closed it, False if it was already closed
or unknown. Idempotent.
"""
sess = self._sessions.get(session_id)
if sess is None:
return False
if sess.closed:
return False
async with sess._lock:
if sess.closed:
return False
cmd = [
self.acpx_bin,
"--format", "json",
"--cwd", str(sess.cwd),
sess.agent,
"sessions", "close",
sess.session_id,
]
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
await asyncio.wait_for(proc.communicate(), timeout=30)
except asyncio.TimeoutError:
proc.kill()
await proc.wait()
except FileNotFoundError:
# No acpx binary — best-effort flag in memory and bail
pass
sess.closed = True
# Clean up the per-session cwd. acpx keeps its own session
# record under ~/.acpx/sessions/, the cwd was just a scope key
# for us.
shutil.rmtree(sess.cwd, ignore_errors=True)
return True
async def forget(self, session_id: str) -> None:
"""Drop the in-memory handle. Caller must have closed first."""
async with self._registry_lock:
self._sessions.pop(session_id, None)
def get(self, session_id: str) -> AcpxSession | None:
return self._sessions.get(session_id)
def list_for_app(self, app_name: str) -> list[AcpxSession]:
return [s for s in self._sessions.values() if s.app_name == app_name]
def count_open(self) -> int:
return self._count_open_locked()
def _count_open_locked(self) -> int:
return sum(1 for s in self._sessions.values() if not s.closed)
# ---- module-level helpers -------------------------------------------------
def _first_json_line(stdout: str) -> dict | None:
for line in stdout.splitlines():
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
if isinstance(obj, dict):
return obj
except json.JSONDecodeError:
continue
return None
def _parse_ndjson(stdout: str) -> list[dict]:
"""Parse acpx --format json NDJSON output.
acpx emits raw ACP JSON-RPC messages, one per line. We collect every
parseable dict; bad lines are skipped silently.
"""
out: list[dict] = []
for line in stdout.splitlines():
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except json.JSONDecodeError:
continue
if isinstance(obj, dict):
out.append(obj)
return out
def _extract_stop_reason(events: list[dict]) -> str | None:
"""Find the result.stopReason from the JSON-RPC response envelope.
ACP terminal envelope: `{"jsonrpc":"2.0","id":"req-N","result":{"stopReason":"end_turn"}}`
"""
for ev in reversed(events):
result = ev.get("result")
if isinstance(result, dict) and "stopReason" in result:
return result.get("stopReason")
return None
def _exit_code_to_error(code: int) -> str:
return {
ACPX_EXIT_AGENT_ERROR: "agent or protocol error",
ACPX_EXIT_USAGE: "acpx CLI usage error",
ACPX_EXIT_TIMEOUT: "acpx-side timeout",
ACPX_EXIT_NO_SESSION: "session not found by acpx",
ACPX_EXIT_PERMISSION_DENIED: "permission denied during turn",
130: "interrupted",
}.get(code, f"acpx exit {code}")
def _format_prompt_with_files(prompt: str, files: list[str] | None) -> str:
if not files:
return prompt
# Files are absolute paths inside the container; the agent has Read access
# via its session cwd. Hint at the top of the prompt; the agent will
# decide whether to open them.
header_lines = ["[Attached files — open with Read tool as needed]"]
for p in files:
header_lines.append(f" - {p}")
return "\n".join(header_lines) + "\n\n" + prompt
def is_acpx_available(acpx_bin: str = "acpx") -> bool:
"""Cheap check used by /healthz + tests to skip if acpx isn't present."""
return shutil.which(acpx_bin) is not None

View file

@ -17,6 +17,14 @@ class Config:
runs_dir: str
db_path: str
# ACPX (v0.2 — multi-turn /sessions)
acpx_bin: str
acpx_sessions_cwd: str
max_live_sessions: int
session_ttl_secs: int
session_hard_ttl_secs: int
sweep_interval_secs: int
def load() -> Config:
return Config(
@ -31,4 +39,10 @@ def load() -> Config:
default_timeout_secs=int(os.environ.get("DEFAULT_TIMEOUT_SECS", "120")),
runs_dir=os.environ.get("RUNS_DIR", "/data/runs"),
db_path=os.environ.get("DB_PATH", "/data/clawdforge.db"),
acpx_bin=os.environ.get("ACPX_BIN", "acpx"),
acpx_sessions_cwd=os.environ.get("ACPX_SESSIONS_CWD", "/data/acpx-cwds"),
max_live_sessions=int(os.environ.get("CLAWDFORGE_MAX_LIVE_SESSIONS", "32")),
session_ttl_secs=int(os.environ.get("CLAWDFORGE_SESSION_TTL_SECS", "3600")),
session_hard_ttl_secs=int(os.environ.get("CLAWDFORGE_SESSION_HARD_TTL_SECS", "86400")),
sweep_interval_secs=int(os.environ.get("CLAWDFORGE_SWEEP_INTERVAL_SECS", "60")),
)

View file

@ -1,6 +1,23 @@
"""FastAPI app exposing /run, /files, /admin/tokens, /healthz."""
"""FastAPI app exposing /run, /files, /admin/tokens, /healthz, and (v0.2) /sessions.
v0.2 adds multi-turn session endpoints backed by ACPX:
POST /sessions create
POST /sessions/{id}/turn send a turn
GET /sessions/{id} read state
DELETE /sessions/{id} soft-close
GET /sessions list (per-token)
Per-app isolation: every session-scoped endpoint returns 404 (not 403) on
cross-token access to avoid leaking session existence across apps.
The /run endpoint is byte-identical to v0.1 and stays on the bare `claude -p`
subprocess path. ACPX is only used by /sessions.
"""
import asyncio
import logging
import os
import time
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Annotated
@ -8,12 +25,26 @@ from fastapi import FastAPI, Header, HTTPException, Request, UploadFile, File, F
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from .acpx_runner import (
AcpxManager,
AcpxNotInstalled,
AcpxPoolFull,
AcpxSessionClosed,
AcpxSessionNotFound,
AcpxTurnFailed,
is_acpx_available,
)
from .auth import Auth
from .config import load
from .runner import Runner
from .store import Store
log = logging.getLogger("clawdforge")
if not log.handlers:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
cfg = load()
store = Store(cfg.db_path)
auth = Auth(
@ -27,8 +58,42 @@ runner = Runner(
default_timeout=cfg.default_timeout_secs,
runs_dir=cfg.runs_dir,
)
acpx_manager = AcpxManager(
acpx_bin=cfg.acpx_bin,
sessions_cwd_root=cfg.acpx_sessions_cwd,
max_live_sessions=cfg.max_live_sessions,
)
app = FastAPI(title="clawdforge", version="0.1.0")
# ---------- lifespan: startup/shutdown ---------------------------------------
@asynccontextmanager
async def _lifespan(app: FastAPI):
# Best-effort GC of expired staged files at boot
try:
for p in store.gc_expired_files():
try:
os.remove(p)
except FileNotFoundError:
pass
except Exception:
pass
sweeper_task = asyncio.create_task(_session_sweeper())
log.info("clawdforge startup complete; session sweeper running every %ss", cfg.sweep_interval_secs)
try:
yield
finally:
sweeper_task.cancel()
try:
await sweeper_task
except asyncio.CancelledError:
pass
log.info("clawdforge shutdown: session sweeper stopped")
app = FastAPI(title="clawdforge", version="0.2.0", lifespan=_lifespan)
# ---------- schemas ----------------------------------------------------------
@ -47,6 +112,17 @@ class TokenCreateRequest(BaseModel):
ip_cidrs: list[str] = Field(default_factory=list)
class CreateSessionRequest(BaseModel):
agent: str = Field(default="claude", min_length=1, max_length=64, pattern=r"^[a-zA-Z0-9_-]+$")
meta: dict | None = None
class TurnRequest(BaseModel):
prompt: str = Field(min_length=1)
files: list[str] | None = None
timeout_secs: int | None = Field(default=None, ge=5, le=1800)
# ---------- endpoints --------------------------------------------------------
@ -63,7 +139,22 @@ def healthz(request: Request):
version = (r.stdout or r.stderr or "").strip().splitlines()[0] if (r.stdout or r.stderr) else None
except Exception as e:
version = f"err: {e}"
return {"ok": True, "claude_present": found, "claude_version": version}
acpx_present = is_acpx_available(cfg.acpx_bin)
acpx_version = None
if acpx_present:
try:
r = subprocess.run([cfg.acpx_bin, "--version"], capture_output=True, text=True, timeout=5)
acpx_version = (r.stdout or r.stderr or "").strip().splitlines()[0] if (r.stdout or r.stderr) else None
except Exception as e:
acpx_version = f"err: {e}"
return {
"ok": True,
"claude_present": found,
"claude_version": version,
"acpx_present": acpx_present,
"acpx_version": acpx_version,
"open_sessions": acpx_manager.count_open(),
}
@app.post("/run")
@ -154,6 +245,212 @@ def upload_file(
return {"file_token": file_token, "ttl_secs": ttl_secs, "size": target.stat().st_size}
# ---------- /sessions (v0.2) -------------------------------------------------
@app.post("/sessions")
async def create_session(
request: Request,
body: CreateSessionRequest,
authorization: Annotated[str | None, Header()] = None,
):
rec = auth.require_app(request, authorization)
try:
sess = await acpx_manager.create(app_name=rec["name"], agent=body.agent)
except AcpxPoolFull as e:
raise HTTPException(503, f"session pool full: {e}")
except AcpxNotInstalled as e:
raise HTTPException(503, f"acpx not installed: {e}")
except AcpxTurnFailed as e:
raise HTTPException(502, f"acpx session create failed: {e}")
row = store.insert_session(
session_id=sess.session_id,
app_name=rec["name"],
agent=body.agent,
cwd=str(sess.cwd),
meta=body.meta,
)
store.log_session_event(
session_id=sess.session_id,
app_name=rec["name"],
event="create",
meta={"agent": body.agent},
)
return {
"ok": True,
"session_id": sess.session_id,
"agent": body.agent,
"created_at": row["created_at"],
"cwd": str(sess.cwd),
}
@app.post("/sessions/{session_id}/turn")
async def session_turn(
session_id: str,
request: Request,
body: TurnRequest,
authorization: Annotated[str | None, Header()] = None,
):
rec = auth.require_app(request, authorization)
row = store.get_session(session_id)
if not row or row["app_name"] != rec["name"]:
# 404 (not 403) — don't leak that the id exists under another token
raise HTTPException(404, "session not found")
if row["closed_at"] is not None:
raise HTTPException(410, "session is closed")
# Resolve file_tokens to absolute paths
file_paths: list[str] = []
if body.files:
for ftoken in body.files:
path = store.resolve_file(ftoken, rec["name"])
if not path:
raise HTTPException(404, f"unknown or expired file token: {ftoken}")
file_paths.append(path)
sess = acpx_manager.get(session_id)
if sess is None:
# Ledger thinks it's open but the in-memory handle is gone (e.g.
# post-restart). For v0.2 we surface this as 410 — caller must
# create a new session. Future: re-bind to acpx record on demand.
store.mark_session_closed(session_id)
raise HTTPException(410, "session is no longer live in this process")
try:
res = await acpx_manager.turn(
session_id=session_id,
prompt=body.prompt,
files=file_paths or None,
timeout_secs=body.timeout_secs,
)
except AcpxSessionNotFound:
raise HTTPException(404, "session not found")
except AcpxSessionClosed:
raise HTTPException(410, "session is closed")
except AcpxNotInstalled as e:
raise HTTPException(503, f"acpx not installed: {e}")
if res.ok:
store.mark_session_turn(session_id)
store.log_session_event(
session_id=session_id,
app_name=rec["name"],
event="turn",
duration_ms=res.duration_ms,
meta={
"ok": res.ok,
"stop_reason": res.stop_reason,
"event_count": len(res.events),
"file_count": len(file_paths),
"error": res.error,
},
)
refreshed = store.get_session(session_id) or row
if not res.ok:
return JSONResponse(
status_code=502,
content={
"ok": False,
"session_id": session_id,
"turn_index": refreshed["turn_count"],
"events": res.events,
"stop_reason": res.stop_reason,
"duration_ms": res.duration_ms,
"error": res.error,
"stderr": res.stderr_tail,
},
)
return {
"ok": True,
"session_id": session_id,
"turn_index": refreshed["turn_count"],
"events": res.events,
"stop_reason": res.stop_reason,
"duration_ms": res.duration_ms,
}
@app.get("/sessions/{session_id}")
async def session_state(
session_id: str,
request: Request,
authorization: Annotated[str | None, Header()] = None,
):
rec = auth.require_app(request, authorization)
row = store.get_session(session_id)
if not row or row["app_name"] != rec["name"]:
raise HTTPException(404, "session not found")
sess = acpx_manager.get(session_id)
return {
"ok": True,
"session_id": session_id,
"agent": row["agent"],
"cwd": row["cwd"],
"created_at": row["created_at"],
"last_turn_at": row["last_turn_at"],
"turn_count": row["turn_count"],
"closed_at": row["closed_at"],
"live": sess is not None and not sess.closed,
"meta": row.get("meta"),
}
@app.delete("/sessions/{session_id}")
async def close_session(
session_id: str,
request: Request,
authorization: Annotated[str | None, Header()] = None,
):
rec = auth.require_app(request, authorization)
row = store.get_session(session_id)
if not row or row["app_name"] != rec["name"]:
raise HTTPException(404, "session not found")
if row["closed_at"] is not None:
# Idempotent — already closed is a success. We document this as
# "second close is no-op (200 ok)" rather than 410, so SDKs can
# safely call close() in finally/Drop blocks without try/except.
return {"ok": True, "already_closed": True}
try:
await acpx_manager.close(session_id)
except Exception as e:
# Even on acpx-side failure, mark closed in the ledger so we don't
# leak the slot. Log for diagnostics.
log.warning("acpx close failed for %s: %s", session_id, e)
store.mark_session_closed(session_id)
store.log_session_event(
session_id=session_id,
app_name=rec["name"],
event="close",
)
await acpx_manager.forget(session_id)
return {"ok": True}
@app.get("/sessions")
async def list_sessions(
request: Request,
authorization: Annotated[str | None, Header()] = None,
include_closed: bool = True,
):
rec = auth.require_app(request, authorization)
rows = store.list_sessions_for_app(rec["name"], include_closed=include_closed)
return {"ok": True, "sessions": rows, "count": len(rows)}
# ---------- /admin/tokens ----------------------------------------------------
@app.post("/admin/tokens")
def create_token(
request: Request,
@ -189,14 +486,74 @@ def revoke_token(
return {"ok": True}
@app.on_event("startup")
def _startup_gc():
# Best-effort GC of expired staged files at boot
# ---------- background sweeper -----------------------------------------------
async def _session_sweeper() -> None:
"""Periodic loop: close stale sessions past TTL, hard-delete past hard-TTL.
Runs every cfg.sweep_interval_secs. Cancels cleanly on app shutdown.
"""
while True:
try:
for p in store.gc_expired_files():
await asyncio.sleep(cfg.sweep_interval_secs)
await _sweep_once()
except asyncio.CancelledError:
raise
except Exception as e:
log.warning("session sweeper iteration failed: %s", e)
async def _sweep_once() -> dict:
"""Single sweep pass. Exposed for tests.
Returns a counter dict so callers/tests can assert on what happened.
"""
now = int(time.time())
ttl_cutoff = now - cfg.session_ttl_secs
hard_cutoff = now - cfg.session_hard_ttl_secs
soft_closed = 0
hard_deleted = 0
# 1. Soft-close stale open sessions
stale = store.find_stale_open_sessions(ttl_cutoff=ttl_cutoff)
for row in stale:
sid = row["session_id"]
try:
os.remove(p)
except FileNotFoundError:
pass
except Exception:
pass
await acpx_manager.close(sid)
except Exception as e:
log.warning("sweeper: acpx close failed for %s: %s", sid, e)
store.mark_session_closed(sid, now=now)
store.log_session_event(
session_id=sid,
app_name=row["app_name"],
event="sweep_close",
meta={"reason": "ttl", "ttl_secs": cfg.session_ttl_secs},
)
await acpx_manager.forget(sid)
soft_closed += 1
# 2. Hard-delete sessions closed long ago
deletable = store.find_hard_deletable_sessions(hard_cutoff=hard_cutoff)
for row in deletable:
sid = row["session_id"]
store.log_session_event(
session_id=sid,
app_name=row["app_name"],
event="hard_delete",
meta={"hard_ttl_secs": cfg.session_hard_ttl_secs},
)
store.hard_delete_session(sid)
# The acpx on-disk record under ~/.acpx/sessions/ is left for acpx
# itself to prune via `acpx <agent> sessions prune` — we do not own
# that filesystem layout. Our cwd was already cleaned up at close().
hard_deleted += 1
if soft_closed or hard_deleted:
log.info(
"sweep: soft_closed=%d hard_deleted=%d open_now=%d",
soft_closed, hard_deleted, acpx_manager.count_open(),
)
return {"soft_closed": soft_closed, "hard_deleted": hard_deleted}

View file

@ -1,4 +1,5 @@
"""SQLite store for app tokens + run audit log."""
"""SQLite store for app tokens, run audit log, and (v0.2) ACPX session ledger."""
import json
import sqlite3
import secrets
import time
@ -38,6 +39,41 @@ CREATE TABLE IF NOT EXISTS files (
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
-- v0.2: ACPX-backed multi-turn sessions ledger.
-- session_id is the value we pass to acpx as `--name` (a UUID we mint).
-- ACPX persists its own session metadata under ~/.acpx/sessions/*.json keyed by
-- (agentCommand, cwd, name); this table is clawdforge's per-app view of which
-- token spawned which session and when it was last used.
CREATE TABLE IF NOT EXISTS sessions (
session_id TEXT PRIMARY KEY,
app_name TEXT NOT NULL,
agent TEXT NOT NULL DEFAULT 'claude',
cwd TEXT NOT NULL,
created_at INTEGER NOT NULL,
last_turn_at INTEGER,
turn_count INTEGER NOT NULL DEFAULT 0,
closed_at INTEGER,
meta_json TEXT
);
CREATE INDEX IF NOT EXISTS idx_sessions_app_name ON sessions(app_name);
CREATE INDEX IF NOT EXISTS idx_sessions_closed_at ON sessions(closed_at);
-- v0.2: append-only audit of session-scoped events (parallel to `runs`).
CREATE TABLE IF NOT EXISTS session_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
app_name TEXT NOT NULL,
event TEXT NOT NULL,
duration_ms INTEGER,
meta_json TEXT,
created_at INTEGER NOT NULL,
FOREIGN KEY (session_id) REFERENCES sessions(session_id)
);
CREATE INDEX IF NOT EXISTS idx_session_events_sid ON session_events(session_id);
CREATE INDEX IF NOT EXISTS idx_session_events_app ON session_events(app_name);
"""
@ -165,3 +201,158 @@ class Store:
paths = [r["path"] for r in rows]
c.execute("DELETE FROM files WHERE expires_at<?", (now,))
return paths
# --- sessions (v0.2) ---------------------------------------------------
def insert_session(
self,
*,
session_id: str,
app_name: str,
agent: str,
cwd: str,
meta: dict | None = None,
) -> dict:
now = int(time.time())
meta_json = json.dumps(meta) if meta is not None else None
with self._conn() as c:
c.execute(
"INSERT INTO sessions (session_id, app_name, agent, cwd, created_at, meta_json) VALUES (?,?,?,?,?,?)",
(session_id, app_name, agent, cwd, now, meta_json),
)
return {
"session_id": session_id,
"app_name": app_name,
"agent": agent,
"cwd": cwd,
"created_at": now,
"last_turn_at": None,
"turn_count": 0,
"closed_at": None,
"meta": meta,
}
def get_session(self, session_id: str) -> dict | None:
with self._conn() as c:
row = c.execute(
"SELECT * FROM sessions WHERE session_id=?", (session_id,)
).fetchone()
if not row:
return None
d = dict(row)
mj = d.pop("meta_json", None)
d["meta"] = json.loads(mj) if mj else None
return d
def list_sessions_for_app(self, app_name: str, *, include_closed: bool = True) -> list[dict]:
sql = "SELECT * FROM sessions WHERE app_name=?"
params: list = [app_name]
if not include_closed:
sql += " AND closed_at IS NULL"
sql += " ORDER BY created_at DESC"
with self._conn() as c:
rows = c.execute(sql, params).fetchall()
out = []
for r in rows:
d = dict(r)
mj = d.pop("meta_json", None)
d["meta"] = json.loads(mj) if mj else None
out.append(d)
return out
def count_open_sessions(self) -> int:
with self._conn() as c:
r = c.execute("SELECT COUNT(*) AS n FROM sessions WHERE closed_at IS NULL").fetchone()
return int(r["n"]) if r else 0
def mark_session_turn(self, session_id: str, *, now: int | None = None) -> None:
now = now or int(time.time())
with self._conn() as c:
c.execute(
"UPDATE sessions SET last_turn_at=?, turn_count=turn_count+1 WHERE session_id=?",
(now, session_id),
)
def mark_session_closed(self, session_id: str, *, now: int | None = None) -> bool:
"""Returns True if this call was the one that flipped closed_at, False if it was already closed or missing."""
now = now or int(time.time())
with self._conn() as c:
cur = c.execute(
"UPDATE sessions SET closed_at=? WHERE session_id=? AND closed_at IS NULL",
(now, session_id),
)
return cur.rowcount > 0
def hard_delete_session(self, session_id: str) -> bool:
with self._conn() as c:
cur = c.execute("DELETE FROM sessions WHERE session_id=?", (session_id,))
return cur.rowcount > 0
def find_stale_open_sessions(self, *, ttl_cutoff: int) -> list[dict]:
"""Open sessions where (last_turn_at OR created_at) < ttl_cutoff."""
with self._conn() as c:
rows = c.execute(
"""
SELECT * FROM sessions
WHERE closed_at IS NULL
AND (
(last_turn_at IS NOT NULL AND last_turn_at < ?)
OR (last_turn_at IS NULL AND created_at < ?)
)
""",
(ttl_cutoff, ttl_cutoff),
).fetchall()
out = []
for r in rows:
d = dict(r)
mj = d.pop("meta_json", None)
d["meta"] = json.loads(mj) if mj else None
out.append(d)
return out
def find_hard_deletable_sessions(self, *, hard_cutoff: int) -> list[dict]:
"""Closed sessions whose closed_at < hard_cutoff."""
with self._conn() as c:
rows = c.execute(
"SELECT * FROM sessions WHERE closed_at IS NOT NULL AND closed_at < ?",
(hard_cutoff,),
).fetchall()
out = []
for r in rows:
d = dict(r)
mj = d.pop("meta_json", None)
d["meta"] = json.loads(mj) if mj else None
out.append(d)
return out
# --- session_events (v0.2) ---------------------------------------------
def log_session_event(
self,
*,
session_id: str,
app_name: str,
event: str,
duration_ms: int | None = None,
meta: dict | None = None,
) -> None:
with self._conn() as c:
c.execute(
"INSERT INTO session_events (session_id, app_name, event, duration_ms, meta_json, created_at) VALUES (?,?,?,?,?,?)",
(
session_id,
app_name,
event,
duration_ms,
json.dumps(meta) if meta is not None else None,
int(time.time()),
),
)
def list_session_events(self, session_id: str) -> list[dict]:
with self._conn() as c:
rows = c.execute(
"SELECT * FROM session_events WHERE session_id=? ORDER BY id ASC",
(session_id,),
).fetchall()
return [dict(r) for r in rows]

View file

@ -14,6 +14,8 @@ services:
- /mnt/user/appdata/clawdforge/data:/data
- /mnt/user/appdata/clawdforge/claude-config:/root/.claude
- /mnt/user/appdata/clawdforge/claude-alt-config:/root/.config/claude
# acpx persists session metadata to ~/.acpx/sessions/*.json — survives container rebuild
- /mnt/user/appdata/clawdforge/acpx-sessions:/root/.acpx/sessions
ports:
# LAN-only bind. 8800 picked to live near other forge-y services; bump if collides.
- "192.168.0.5:8800:8800"

0
tests/__init__.py Normal file
View file

234
tests/conftest.py Normal file
View file

@ -0,0 +1,234 @@
"""Shared pytest fixtures for the clawdforge server suite.
Provides:
- Per-test temp DB + acpx-cwd-root via env overrides (config.load is import-time-cached
in server.py, so we monkeypatch the module-level cfg/store/acpx_manager directly).
- A FakeAcpxManager that conforms to AcpxManager's surface but never spawns a real
subprocess. Use it to drive the FastAPI client without an installed `acpx` binary.
- A TestClient configured with an admin token + one app token already minted, so
tests can hit /sessions/* without going through the bootstrap dance.
"""
from __future__ import annotations
import asyncio
import os
import tempfile
import time
import uuid
from dataclasses import dataclass, field
from pathlib import Path
import pytest
@dataclass
class _FakeSession:
session_id: str
app_name: str
agent: str
cwd: Path
created_at: int
closed: bool = False
last_turn_at: int | None = None
turn_count: int = 0
class FakeAcpxManager:
"""Drop-in for AcpxManager that simulates acpx-side behavior in-process.
Records calls so tests can assert on shape without checking acpx binaries.
"""
def __init__(self, *, max_live_sessions: int = 32, sessions_cwd_root: str | Path = "/tmp/fake-acpx"):
self.max_live_sessions = max_live_sessions
self.sessions_cwd_root = Path(sessions_cwd_root)
self.sessions_cwd_root.mkdir(parents=True, exist_ok=True)
self._sessions: dict[str, _FakeSession] = {}
self.acpx_bin = "fake-acpx"
# Test injectable: override the next turn() result, or raise.
self.next_turn_events: list[dict] | None = None
self.next_turn_stop_reason: str = "end_turn"
self.next_turn_ok: bool = True
self.next_turn_error: str | None = None
self.calls: list[dict] = []
async def create(self, *, app_name: str, agent: str = "claude"):
from clawdforge.acpx_runner import AcpxPoolFull
if self._count_open() >= self.max_live_sessions:
raise AcpxPoolFull(f"max_live_sessions={self.max_live_sessions} reached")
sid = uuid.uuid4().hex
cwd = self.sessions_cwd_root / sid
cwd.mkdir(parents=True, exist_ok=True)
sess = _FakeSession(
session_id=sid,
app_name=app_name,
agent=agent,
cwd=cwd,
created_at=int(time.time()),
)
self._sessions[sid] = sess
self.calls.append({"op": "create", "session_id": sid, "app_name": app_name})
return sess
async def turn(self, *, session_id: str, prompt: str, files=None, timeout_secs=None):
from clawdforge.acpx_runner import (
AcpxSessionClosed,
AcpxSessionNotFound,
AcpxTurnResult,
)
sess = self._sessions.get(session_id)
if sess is None:
raise AcpxSessionNotFound(session_id)
if sess.closed:
raise AcpxSessionClosed(session_id)
self.calls.append({"op": "turn", "session_id": session_id, "prompt": prompt, "files": files})
events = self.next_turn_events if self.next_turn_events is not None else [
{
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionUpdate": "agent_message_chunk",
"content": {"type": "text", "text": "hello"},
},
},
{
"jsonrpc": "2.0",
"id": "req-1",
"result": {"stopReason": self.next_turn_stop_reason},
},
]
if self.next_turn_ok:
sess.last_turn_at = int(time.time())
sess.turn_count += 1
return AcpxTurnResult(
ok=self.next_turn_ok,
events=events,
stop_reason=self.next_turn_stop_reason,
duration_ms=42,
error=self.next_turn_error,
)
async def close(self, session_id: str) -> bool:
sess = self._sessions.get(session_id)
if sess is None or sess.closed:
return False
sess.closed = True
self.calls.append({"op": "close", "session_id": session_id})
return True
async def forget(self, session_id: str) -> None:
self._sessions.pop(session_id, None)
def get(self, session_id: str):
return self._sessions.get(session_id)
def list_for_app(self, app_name: str):
return [s for s in self._sessions.values() if s.app_name == app_name]
def count_open(self) -> int:
return self._count_open()
def _count_open(self) -> int:
return sum(1 for s in self._sessions.values() if not s.closed)
@pytest.fixture
def tmp_workspace(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
"""Set every clawdforge env var pointing at a fresh tmp dir before importing the server."""
db_path = tmp_path / "clawdforge.db"
runs_dir = tmp_path / "runs"
acpx_cwds = tmp_path / "acpx-cwds"
runs_dir.mkdir()
acpx_cwds.mkdir()
admin_token = "admin-test-bootstrap"
monkeypatch.setenv("DB_PATH", str(db_path))
monkeypatch.setenv("RUNS_DIR", str(runs_dir))
monkeypatch.setenv("ACPX_SESSIONS_CWD", str(acpx_cwds))
monkeypatch.setenv("ADMIN_BOOTSTRAP_TOKEN", admin_token)
monkeypatch.setenv("ALLOW_CIDRS", "127.0.0.0/8,::1/128")
monkeypatch.setenv("CLAUDE_BIN", "/bin/true") # /run path won't be hit unless asked
monkeypatch.setenv("ACPX_BIN", "/bin/true")
monkeypatch.setenv("CLAWDFORGE_SESSION_TTL_SECS", "3600")
monkeypatch.setenv("CLAWDFORGE_SESSION_HARD_TTL_SECS", "86400")
monkeypatch.setenv("CLAWDFORGE_SWEEP_INTERVAL_SECS", "60")
monkeypatch.setenv("CLAWDFORGE_MAX_LIVE_SESSIONS", "8")
yield {
"db_path": str(db_path),
"runs_dir": str(runs_dir),
"acpx_cwds": str(acpx_cwds),
"admin_token": admin_token,
}
@pytest.fixture
def client(tmp_workspace, monkeypatch: pytest.MonkeyPatch):
"""FastAPI TestClient with FakeAcpxManager swapped in. Pre-mints one app token.
Yields (TestClient, dict) where dict has: admin_token, app_token, app_name, fake_acpx, store.
"""
# Force-reimport so module-level cfg/store/acpx_manager pick up our env.
import importlib
import sys
for mod_name in [
"clawdforge.server",
"clawdforge.acpx_runner",
"clawdforge.config",
"clawdforge.store",
"clawdforge.runner",
"clawdforge.auth",
"clawdforge",
]:
sys.modules.pop(mod_name, None)
from clawdforge import server as srv # noqa: WPS433
from clawdforge import auth as auth_mod
# FastAPI's TestClient reports `request.client.host == "testclient"` which
# fails the IP allowlist check. Force loopback for tests.
monkeypatch.setattr(auth_mod, "_client_ip", lambda _req: "127.0.0.1")
# Replace the real AcpxManager with our fake. The server module already
# holds a reference; rebind it.
fake = FakeAcpxManager(
max_live_sessions=srv.cfg.max_live_sessions,
sessions_cwd_root=srv.cfg.acpx_sessions_cwd,
)
srv.acpx_manager = fake
from fastapi.testclient import TestClient
with TestClient(srv.app) as tc:
# Mint an app token via admin endpoint
app_name = "testapp"
r = tc.post(
"/admin/tokens",
headers={"Authorization": f"Bearer {tmp_workspace['admin_token']}"},
json={"name": app_name, "ip_cidrs": []},
)
assert r.status_code == 200, r.text
app_token = r.json()["token"]
# Mint a second app token for isolation tests
r2 = tc.post(
"/admin/tokens",
headers={"Authorization": f"Bearer {tmp_workspace['admin_token']}"},
json={"name": "otherapp", "ip_cidrs": []},
)
assert r2.status_code == 200, r2.text
other_token = r2.json()["token"]
yield tc, {
"admin_token": tmp_workspace["admin_token"],
"app_token": app_token,
"app_name": app_name,
"other_token": other_token,
"other_name": "otherapp",
"fake_acpx": fake,
"store": srv.store,
"cfg": srv.cfg,
"server": srv,
}

434
tests/test_sessions.py Normal file
View file

@ -0,0 +1,434 @@
"""Smoke tests for the v0.2 multi-turn /sessions surface.
Coverage:
- /sessions create requires bearer auth
- /sessions create returns a non-empty session_id
- One full turn round-trip via FakeAcpxManager (real acpx not required)
- Per-app isolation: token A cannot see token B's session (404, NOT 403)
- /sessions/{id} DELETE is idempotent (second call is no-op success)
- /sessions list filters strictly by app_name
- TTL sweeper closes stale sessions
- /run regression: existing v0.1 surface byte-shape stays intact
"""
from __future__ import annotations
import asyncio
import time
from unittest.mock import patch
import pytest
# ---- /sessions auth + create -----------------------------------------------
def test_create_session_requires_auth(client):
tc, _ = client
r = tc.post("/sessions", json={"agent": "claude"})
assert r.status_code == 401, r.text
def test_create_session_returns_id(client):
tc, ctx = client
r = tc.post(
"/sessions",
headers={"Authorization": f"Bearer {ctx['app_token']}"},
json={"agent": "claude"},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["ok"] is True
assert isinstance(body["session_id"], str) and len(body["session_id"]) >= 16
assert body["agent"] == "claude"
# Ledger row should exist
row = ctx["store"].get_session(body["session_id"])
assert row is not None
assert row["app_name"] == ctx["app_name"]
def test_create_session_with_meta(client):
tc, ctx = client
r = tc.post(
"/sessions",
headers={"Authorization": f"Bearer {ctx['app_token']}"},
json={"agent": "claude", "meta": {"purpose": "smoke"}},
)
assert r.status_code == 200
sid = r.json()["session_id"]
row = ctx["store"].get_session(sid)
assert row["meta"] == {"purpose": "smoke"}
# ---- turn round-trip --------------------------------------------------------
def test_turn_round_trip(client):
tc, ctx = client
r = tc.post(
"/sessions",
headers={"Authorization": f"Bearer {ctx['app_token']}"},
json={"agent": "claude"},
)
sid = r.json()["session_id"]
# Inject a controlled set of events for the turn
ctx["fake_acpx"].next_turn_events = [
{
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionUpdate": "agent_message_chunk",
"content": {"type": "text", "text": "hello"},
},
},
{"jsonrpc": "2.0", "id": "req-1", "result": {"stopReason": "end_turn"}},
]
r2 = tc.post(
f"/sessions/{sid}/turn",
headers={"Authorization": f"Bearer {ctx['app_token']}"},
json={"prompt": "say hello"},
)
assert r2.status_code == 200, r2.text
body = r2.json()
assert body["ok"] is True
assert body["session_id"] == sid
assert body["stop_reason"] == "end_turn"
assert body["turn_index"] == 1
assert isinstance(body["events"], list) and len(body["events"]) == 2
# Must contain the expected text event
chunk = next(
e for e in body["events"]
if e.get("method") == "session/update"
)
assert chunk["params"]["content"]["text"] == "hello"
# State endpoint reflects the turn
r3 = tc.get(
f"/sessions/{sid}",
headers={"Authorization": f"Bearer {ctx['app_token']}"},
)
assert r3.status_code == 200
assert r3.json()["turn_count"] == 1
assert r3.json()["last_turn_at"] is not None
# ---- per-app isolation -----------------------------------------------------
def test_session_isolation_404(client):
tc, ctx = client
# token A creates a session
r = tc.post(
"/sessions",
headers={"Authorization": f"Bearer {ctx['app_token']}"},
json={"agent": "claude"},
)
sid = r.json()["session_id"]
# token B asks for it: must be 404 (NOT 403, no existence leak)
for path in [f"/sessions/{sid}", f"/sessions/{sid}/turn"]:
if path.endswith("/turn"):
r2 = tc.post(
path,
headers={"Authorization": f"Bearer {ctx['other_token']}"},
json={"prompt": "hi"},
)
else:
r2 = tc.get(
path,
headers={"Authorization": f"Bearer {ctx['other_token']}"},
)
assert r2.status_code == 404, f"{path} returned {r2.status_code}: {r2.text}"
# And DELETE: same rule
r3 = tc.delete(
f"/sessions/{sid}",
headers={"Authorization": f"Bearer {ctx['other_token']}"},
)
assert r3.status_code == 404
# ---- close idempotency -----------------------------------------------------
def test_close_session_idempotent(client):
tc, ctx = client
r = tc.post(
"/sessions",
headers={"Authorization": f"Bearer {ctx['app_token']}"},
json={"agent": "claude"},
)
sid = r.json()["session_id"]
r1 = tc.delete(
f"/sessions/{sid}",
headers={"Authorization": f"Bearer {ctx['app_token']}"},
)
assert r1.status_code == 200
assert r1.json()["ok"] is True
# Second close: must be a 200 success no-op (we documented this in
# server.py and SDKs rely on it for safe Drop/finally usage).
r2 = tc.delete(
f"/sessions/{sid}",
headers={"Authorization": f"Bearer {ctx['app_token']}"},
)
assert r2.status_code == 200
assert r2.json().get("already_closed") is True
def test_turn_after_close_returns_410(client):
tc, ctx = client
r = tc.post(
"/sessions",
headers={"Authorization": f"Bearer {ctx['app_token']}"},
json={"agent": "claude"},
)
sid = r.json()["session_id"]
tc.delete(
f"/sessions/{sid}",
headers={"Authorization": f"Bearer {ctx['app_token']}"},
)
r2 = tc.post(
f"/sessions/{sid}/turn",
headers={"Authorization": f"Bearer {ctx['app_token']}"},
json={"prompt": "hi"},
)
assert r2.status_code == 410
# ---- list filtering --------------------------------------------------------
def test_list_sessions_filters_by_app_name(client):
tc, ctx = client
# token A creates two
sids_a = []
for _ in range(2):
r = tc.post(
"/sessions",
headers={"Authorization": f"Bearer {ctx['app_token']}"},
json={"agent": "claude"},
)
sids_a.append(r.json()["session_id"])
# token B creates one
rb = tc.post(
"/sessions",
headers={"Authorization": f"Bearer {ctx['other_token']}"},
json={"agent": "claude"},
)
sid_b = rb.json()["session_id"]
# token A list shows only its 2
la = tc.get(
"/sessions",
headers={"Authorization": f"Bearer {ctx['app_token']}"},
)
assert la.status_code == 200
body_a = la.json()
assert body_a["count"] == 2
listed_a = {s["session_id"] for s in body_a["sessions"]}
assert listed_a == set(sids_a)
assert sid_b not in listed_a
# token B list shows only its 1
lb = tc.get(
"/sessions",
headers={"Authorization": f"Bearer {ctx['other_token']}"},
)
assert lb.status_code == 200
body_b = lb.json()
assert body_b["count"] == 1
assert body_b["sessions"][0]["session_id"] == sid_b
# ---- TTL sweeper -----------------------------------------------------------
def test_ttl_sweep_closes_stale(client):
tc, ctx = client
r = tc.post(
"/sessions",
headers={"Authorization": f"Bearer {ctx['app_token']}"},
json={"agent": "claude"},
)
sid = r.json()["session_id"]
# Backdate created_at so the row is "stale" relative to the configured TTL
store = ctx["store"]
cfg = ctx["cfg"]
fake_now = int(time.time())
backdated = fake_now - cfg.session_ttl_secs - 60
with store._conn() as c:
c.execute(
"UPDATE sessions SET created_at=? WHERE session_id=?",
(backdated, sid),
)
# Run one sweep iteration directly (no need to wait for the task)
server_mod = ctx["server"]
counts = asyncio.get_event_loop().run_until_complete(server_mod._sweep_once())
assert counts["soft_closed"] == 1
row = store.get_session(sid)
assert row["closed_at"] is not None
# Audit event recorded
events = store.list_session_events(sid)
kinds = [e["event"] for e in events]
assert "create" in kinds
assert "sweep_close" in kinds
def test_ttl_sweep_hard_deletes_old_closed(client):
tc, ctx = client
r = tc.post(
"/sessions",
headers={"Authorization": f"Bearer {ctx['app_token']}"},
json={"agent": "claude"},
)
sid = r.json()["session_id"]
store = ctx["store"]
cfg = ctx["cfg"]
closed_long_ago = int(time.time()) - cfg.session_hard_ttl_secs - 60
with store._conn() as c:
c.execute(
"UPDATE sessions SET closed_at=? WHERE session_id=?",
(closed_long_ago, sid),
)
server_mod = ctx["server"]
counts = asyncio.get_event_loop().run_until_complete(server_mod._sweep_once())
assert counts["hard_deleted"] == 1
assert store.get_session(sid) is None
# ---- pool full -------------------------------------------------------------
def test_pool_full_returns_503(client, monkeypatch):
tc, ctx = client
fake = ctx["fake_acpx"]
monkeypatch.setattr(fake, "max_live_sessions", 2)
# First two create OK
for _ in range(2):
r = tc.post(
"/sessions",
headers={"Authorization": f"Bearer {ctx['app_token']}"},
json={"agent": "claude"},
)
assert r.status_code == 200
# Third hits the pool cap
r3 = tc.post(
"/sessions",
headers={"Authorization": f"Bearer {ctx['app_token']}"},
json={"agent": "claude"},
)
assert r3.status_code == 503
# ---- /run regression -------------------------------------------------------
def test_run_endpoint_unchanged(client, monkeypatch):
"""The /run path stays on the bare claude-p subprocess and v0.1 shape."""
tc, ctx = client
# We don't actually want to invoke claude. Patch Runner.run to return a
# canned RunResult that mirrors the v0.1 shape.
from clawdforge.runner import RunResult
server_mod = ctx["server"]
canned = RunResult(
ok=True,
result={"hello": "world"},
raw_stdout='{"type":"result","result":"{\\"hello\\":\\"world\\"}","stop_reason":"end_turn"}',
raw_stderr="",
duration_ms=123,
stop_reason="end_turn",
error=None,
)
monkeypatch.setattr(server_mod.runner, "run", lambda **kw: canned)
r = tc.post(
"/run",
headers={"Authorization": f"Bearer {ctx['app_token']}"},
json={"prompt": "Reply with JSON: {\"hello\":\"world\"}", "model": "sonnet"},
)
assert r.status_code == 200
body = r.json()
# Exact v0.1 shape: ok, result, duration_ms, stop_reason — nothing else added
assert set(body.keys()) == {"ok", "result", "duration_ms", "stop_reason"}
assert body["ok"] is True
assert body["result"] == {"hello": "world"}
assert body["stop_reason"] == "end_turn"
assert body["duration_ms"] == 123
def test_run_endpoint_unchanged_error_shape(client, monkeypatch):
"""v0.1's error shape (502 with ok/error/stderr/duration_ms/stop_reason) preserved."""
tc, ctx = client
from clawdforge.runner import RunResult
server_mod = ctx["server"]
canned = RunResult(
ok=False,
result=None,
raw_stdout="",
raw_stderr="boom",
duration_ms=10,
stop_reason="error",
error="claude exit 1",
)
monkeypatch.setattr(server_mod.runner, "run", lambda **kw: canned)
r = tc.post(
"/run",
headers={"Authorization": f"Bearer {ctx['app_token']}"},
json={"prompt": "x"},
)
assert r.status_code == 502
body = r.json()
assert set(body.keys()) >= {"ok", "error", "stderr", "duration_ms", "stop_reason"}
assert body["ok"] is False
# ---- acpx_runner unit-level helpers ----------------------------------------
def test_extract_stop_reason_from_ndjson():
from clawdforge.acpx_runner import _extract_stop_reason, _parse_ndjson
raw = (
'{"jsonrpc":"2.0","id":"req-1","method":"session/prompt","params":{}}\n'
'{"jsonrpc":"2.0","method":"session/update","params":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"Hi"}}}\n'
'{"jsonrpc":"2.0","id":"req-1","result":{"stopReason":"end_turn"}}\n'
)
events = _parse_ndjson(raw)
assert len(events) == 3
assert _extract_stop_reason(events) == "end_turn"
def test_parse_ndjson_skips_garbage():
from clawdforge.acpx_runner import _parse_ndjson
raw = '{"a":1}\nnot json\n{"b":2}\n\n \n{"c":3}\n'
events = _parse_ndjson(raw)
assert events == [{"a": 1}, {"b": 2}, {"c": 3}]
def test_format_prompt_with_files():
from clawdforge.acpx_runner import _format_prompt_with_files
plain = _format_prompt_with_files("hello", None)
assert plain == "hello"
annotated = _format_prompt_with_files("hello", ["/data/x.txt", "/data/y.txt"])
assert "/data/x.txt" in annotated and "/data/y.txt" in annotated
assert annotated.endswith("hello")