diff --git a/CLAUDE.md b/CLAUDE.md index 4c6c9759..c6c4bd98 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,7 +47,7 @@ The production server caches static files for 1 year, `immutable` (`maxAge: '1y' ## COM Shorthand (Deployment) -Uses [Semantic Versioning](https://semver.org/) (`MAJOR.MINOR.PATCH`) via `@changesets/cli`. What SemVer actually covers (the CLI + documented env vars are public; the HTTP/SSE API, on-disk state, and experimental features are internal/unstable) is defined in `docs/versioning-policy.md`. Security reporting + known limitations live in `.github/SECURITY.md`. +Uses [Semantic Versioning](https://semver.org/) (`MAJOR.MINOR.PATCH`) via `@changesets/cli`. What SemVer actually covers (the CLI, documented env vars, **and the HTTP/SSE API under `/api/v1`**: endpoint paths, response envelope, `errorCode` values and SSE event names are public/stable; on-disk state, internal TS modules, and experimental features are internal/unstable) is defined in `docs/versioning-policy.md`. Third-party integration surfaces are documented in `docs/extending-codeman.md`. Security reporting + known limitations live in `.github/SECURITY.md`. When user says "COM": diff --git a/docs/extending-codeman.md b/docs/extending-codeman.md new file mode 100644 index 00000000..24f95e58 --- /dev/null +++ b/docs/extending-codeman.md @@ -0,0 +1,228 @@ +# Extending Codeman + +Codeman has no plugin runtime, and that is a deliberate choice rather than a +missing feature. A plugin runtime means running third-party code inside a process +that spawns agents with your credentials, on a server people routinely expose +over a tunnel or Tailscale. Codeman's security model is one of its reasons to +exist, so it does not hand that away for an extension mechanism. + +Instead there are four seams that already work, from any language, with nothing +installed: + +| You want to | Use | Runs where | +| --- | --- | --- | +| Show your own UI inside Codeman | [Web tabs](#seam-1-web-tabs) | Your own process, rendered as a tab | +| React when an agent needs you | [SSE events](#seam-2-sse-events) | Anywhere that can hold an HTTP connection | +| Drive Codeman from a script | [HTTP API](#seam-3-http-api-and-cli) or the `codeman` CLI | Anywhere | +| React inside a Claude session | [Hooks](#seam-4-hooks) | The agent's own machine | + +Everything below is covered by the stability promise in +[`versioning-policy.md`](versioning-policy.md): endpoint paths, the response +envelope, `errorCode` values, and SSE event names are stable. Additive changes +(new endpoints, new optional fields, new events) are non-breaking. Breaking +changes ship under a new prefix (`/api/v2`). + +## Before you start + +**Base URL.** `http://127.0.0.1:3000` by default. Prefer the versioned prefix +`/api/v1/...` for anything you publish; the unversioned `/api/...` is an alias. + +**Auth.** If `CODEMAN_PASSWORD` is set, send HTTP Basic on every request, or +authenticate once and keep the `codeman_session` cookie. With no password set, +Codeman is loopback-only and unauthenticated. + +```bash +curl -u admin:$CODEMAN_PASSWORD http://127.0.0.1:3000/api/v1/sessions +``` + +**Envelope.** Every response is `{"success": true, "data": ...}` or +`{"success": false, "error": "...", "errorCode": "..."}`. Check the HTTP status +or `body.success`, then read `body.data`. The full `errorCode` to status mapping +is in [`api-reference.md`](api-reference.md). + +## Seam 1: Web tabs + +The highest-leverage seam. Any web app you can serve locally becomes a tab beside +your agent sessions. You write a normal web page; Codeman handles embedding it. + +```bash +curl -u admin:$PASS -X POST http://127.0.0.1:3000/api/v1/webviews \ + -H 'Content-Type: application/json' \ + -d '{"name":"My Dashboard","url":"http://127.0.0.1:8787","icon":"📊"}' +``` + +Fields: `name` (1 to 60 chars), `url`, and optionally `icon` (a single glyph, max +8 code units), `embedMode` (`proxy` by default, or `direct`), and `trusted`. + +Related endpoints: `GET /api/v1/webviews`, `PATCH /api/v1/webviews/:id`, +`DELETE /api/v1/webviews/:id`, `POST /api/v1/webviews/probe` (reachability and +framing check), `POST /api/v1/webviews/:id/open`. + +### Why it is proxied + +By default your page is served through Codeman's own origin at `/webview/:cap/*` +rather than framed directly. A direct iframe fails three ways at once: production +is HTTPS so `http://` targets are blocked as mixed content, many dashboards send +`X-Frame-Options: DENY`, and Codeman's own `default-src 'self'` CSP blocks +cross-origin frames. Proxying solves all three without weakening the CSP. + +### The two things that will confuse you + +A proxied frame is sandboxed and therefore **opaque-origin** unless you set +`trusted: true`. Two consequences look like bugs in your own app: + +1. **Root-absolute URLs built at runtime** (`/assets/x.png` assembled in JS) + escape the injected `` tag. Codeman injects a `runtimeUrlShim()` that + patches the common DOM sinks, but if you construct URLs in an unusual way, + prefer relative paths. +2. **Same-host `fetch` and `XHR` are CORS-checked with `Origin: null`.** Codeman + handles this with `buildProxyCorsHeaders()`, and the proxy is exempt from the + global `OPTIONS` short-circuit. If you see "Failed to fetch" while the page + itself renders fine, this is the area to look at. + +⚠️ `trusted: true` opts out of the sandbox. A proxied page is served from +Codeman's origin, so `allow-same-origin` lets it read the Codeman page and call +the API that spawns agents. Only mark your own trusted code. + +## Seam 2: SSE events + +`GET /api/v1/events` is a Server-Sent Events stream. Each message is +`event: ` plus `data: `. There are 149 event names following a +`domain:action` convention, registered in `src/web/sse-events.ts`. + +The ones most integrations want: + +| Event | Meaning | +| --- | --- | +| `session:created`, `session:deleted` | A session appeared or went away | +| `session:idle` | The agent stopped working | +| `session:completion` | A completion message was detected | +| `session:exit`, `session:error` | The session ended or failed | +| `hook:permission_prompt` | The agent is asking for permission | +| `hook:idle_prompt`, `hook:stop` | The agent is waiting on you, or stopped | +| `hook:task_completed`, `task:completed` | Work finished | +| `subagent:discovered`, `subagent:completed` | Background agent lifecycle | +| `mux:died` | A multiplexer session died unexpectedly | +| `cron:runCreated`, `cron:runUpdated` | Scheduled job activity | + +### Filtering + +`?sessions=id1,id2` suppresses only the high-volume `session:terminal` stream for +sessions you did not list. Lifecycle and metadata events are always delivered, so +you cannot accidentally filter away the thing you are listening for. + +Pass `?clientId=` to enable live filter updates through +`POST /api/v1/events/subscribe` without reconnecting the stream. + +### Example: notify when any agent needs you + +```js +const res = await fetch('http://127.0.0.1:3000/api/v1/events', { + headers: { Authorization: 'Basic ' + btoa(`admin:${process.env.CODEMAN_PASSWORD}`) }, +}); +const reader = res.body.getReader(); +const decoder = new TextDecoder(); +let buf = ''; +const WANTED = new Set(['hook:permission_prompt', 'hook:idle_prompt', 'session:idle']); + +for (;;) { + const { value, done } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + const frames = buf.split('\n\n'); + buf = frames.pop() ?? ''; + for (const frame of frames) { + const name = frame.match(/^event: (.+)$/m)?.[1]; + const data = frame.match(/^data: (.+)$/m)?.[1]; + if (name && WANTED.has(name)) notify(name, JSON.parse(data ?? '{}')); + } +} +``` + +## Seam 3: HTTP API and CLI + +Around 199 handlers across 21 route files cover sessions, cases, files, cron, +respawn, Ralph, the orchestrator, search, and admin. Each route module carries an +`@fileoverview` describing its endpoints. + +The common ones: + +```bash +# List sessions (live + persisted + transcript history, deduped) +curl -u admin:$PASS http://127.0.0.1:3000/api/v1/sessions/unified + +# Create a session +curl -u admin:$PASS -X POST http://127.0.0.1:3000/api/v1/sessions \ + -H 'Content-Type: application/json' \ + -d '{"workingDir":"/home/me/project","mode":"claude"}' + +# Send a prompt (single-line only, "\r" submits) +curl -u admin:$PASS -X POST http://127.0.0.1:3000/api/v1/sessions/$ID/input \ + -H 'Content-Type: application/json' \ + -d '{"input":"run the tests","useScreen":true}' +``` + +For shell scripting, the `codeman` CLI is the same surface without the HTTP +plumbing: + +``` +codeman session start|stop|list|logs codeman task add|list|status|remove|clear +codeman ralph start|stop|status|reset codeman users add|passwd|list +codeman status | list | attach codeman doctor +``` + +## Seam 4: Hooks + +Claude Code hooks post to `POST /api/v1/hook-event` from inside an agent session. +Codeman installs its own hooks automatically, but the endpoint is open to yours. + +```json +{ "event": "task_completed", "sessionId": "abc123", "data": { "any": "json" } } +``` + +`event` must be one of `permission_prompt`, `elicitation_dialog`, `idle_prompt`, +`stop`, `teammate_idle`, `task_completed`. Each becomes the matching `hook:*` SSE +event. + +⚠️ This endpoint skips Basic auth so hooks keep working, but when auth is active +the loopback bypass requires the `X-Codeman-Hook-Secret` header +(`~/.codeman/hook-secret`) unconditionally. + +## Gotchas + +Every one of these has cost somebody real time. + +- **CORS is localhost-only.** `Access-Control-Allow-Origin` is echoed only for + `localhost`, `127.0.0.1`, and `::1`. A browser app on any other origin cannot + call the API. Integrate server-side. +- **A missing `Origin` header is allowed**, which is why curl, CLIs, and hooks + work. Cross-site origins are blocked by the CSRF guard. +- **Reverse-proxy domains are rejected** by the anti-DNS-rebinding Host allowlist + unless added via `CODEMAN_ALLOWED_HOSTS=host,.suffix`. +- **`null` is not `undefined`.** Request schemas use Zod `.optional()`, which + accepts `undefined` only. `JSON.stringify({ field: null })` keeps the null on + the wire and fails with `INVALID_INPUT`. Omit the key instead. This has caused + shipped bugs more than once. +- **`text/plain` bodies stay raw.** Auto-parsing them as JSON enabled + simple-request CSRF, so it is deliberate. Send `application/json`. +- **Prompts are single-line.** Input is delivered as text plus a separate Enter; + a multi-line string breaks the agent's input handling. Send `\r` to submit. +- **Unwrap the envelope** before reading fields. `data` is not the response body. + +## Publishing your integration + +There is no registry and no review queue. Add the GitHub topic +**`codeman-integration`** to your public repository so others can find it, and +link back to Codeman in your README. + +If a real ecosystem of these appears, a manifest format and an install command +become worth building. Until then, these four seams are the contract, and they +require nothing of you but HTTP. + +## What Codeman deliberately does not have + +- **No in-process plugin runtime.** See the reasoning at the top of this page. +- **No build or startup hooks** for third-party code. Run your own process. +- **No per-plugin config or state directories.** Manage your own files. +- **No sandbox**, because there is nothing to sandbox. Your integration is your + process, with your permissions, talking HTTP.