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: 5 additions & 2 deletions app/api/coven-proxy/[...path]/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Path-prefix bridge for direct daemon calls (status banner, curl tests).
// Sibling route at app/api/coven-proxy/route.ts handles the fumadocs-openapi
// playground's `?url=` style. Dialing logic lives in lib/coven-proxy-dial.ts.
// playground's `?url=` style. Dialing logic lives in lib/coven-proxy-dial.ts
// (Unix socket first, loopback TCP fallback on the standard port).

import { dialDaemon } from '@/lib/coven-proxy-dial';

Expand All @@ -19,7 +20,9 @@ async function proxy(
const hasBody = method !== 'GET' && method !== 'HEAD';
const body = hasBody ? Buffer.from(await req.arrayBuffer()) : undefined;

return dialDaemon(targetPath, method, req.headers, body);
return dialDaemon(targetPath, method, req.headers, body, {
selfHost: req.headers.get('host'),
});
}

export const GET = proxy;
Expand Down
67 changes: 54 additions & 13 deletions app/api/coven-proxy/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,24 @@
//
// GET /api/coven-proxy?url=<encoded-target>&cookie=
//
// We strip the docs-site prefix from the target URL's pathname and forward
// the remaining daemon-side path to the Unix socket via the shared
// dialDaemon() helper.
// The spec's server URL is `http://localhost:{port}/api/v1` (port variable,
// default 3000), so the target is an absolute loopback URL. We validate it is
// loopback-only, extract the port for the TCP fallback, and forward the
// daemon-side path via the shared dialDaemon() helper (Unix socket first,
// loopback TCP fallback). Legacy `/api/coven-proxy/...` relative targets from
// cached pages are still accepted.

import { dialDaemon, envelope } from '@/lib/coven-proxy-dial';
import {
DEFAULT_DAEMON_TCP_PORT,
dialDaemon,
envelope,
} from '@/lib/coven-proxy-dial';

export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';

const PROXY_PREFIX = '/api/coven-proxy';
const LEGACY_PROXY_PREFIX = '/api/coven-proxy';
const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']);
Comment on lines +22 to +23

async function proxy(req: Request): Promise<Response> {
const reqUrl = new URL(req.url);
Expand All @@ -28,7 +36,7 @@ async function proxy(req: Request): Promise<Response> {

let targetUrl: URL;
try {
targetUrl = new URL(target);
targetUrl = new URL(target, reqUrl.origin);
} catch {
return envelope(
'invalid_request',
Expand All @@ -39,25 +47,58 @@ async function proxy(req: Request): Promise<Response> {
}

// The playground composes URLs like:
// spec server: /api/coven-proxy/api/v1
// spec server: http://localhost:{port}/api/v1 (port default 3000)
// operation: /health
// final URL: http://localhost:3000/api/coven-proxy/api/v1/health
// Strip the docs-site prefix to recover the daemon-side path (/api/v1/...).
if (!targetUrl.pathname.startsWith(PROXY_PREFIX)) {
// final URL: http://localhost:3000/api/v1/health
// Legacy cached specs used the relative server `/api/coven-proxy/api/v1`,
// which resolves against the docs-site origin — strip that prefix instead.
let targetPath: string;
let tcpPort = DEFAULT_DAEMON_TCP_PORT;

if (targetUrl.pathname.startsWith(LEGACY_PROXY_PREFIX)) {
targetPath =
targetUrl.pathname.slice(LEGACY_PROXY_PREFIX.length) + targetUrl.search;
} else {
if (targetUrl.protocol !== 'http:') {
return envelope(
'invalid_request',
`Target URL must use http: ${target}`,
{ source: 'playground' },
400,
);
}
if (!LOOPBACK_HOSTNAMES.has(targetUrl.hostname.toLowerCase())) {
return envelope(
'invalid_request',
`Target URL host must be loopback (localhost / 127.0.0.1 / [::1]): ${target}`,
{ source: 'playground' },
400,
);
}
const parsedPort = Number(targetUrl.port);
if (Number.isInteger(parsedPort) && parsedPort > 0 && parsedPort <= 65535) {
tcpPort = parsedPort;
}
targetPath = targetUrl.pathname + targetUrl.search;
}

if (!targetPath.startsWith('/api/')) {
return envelope(
'invalid_request',
`Target URL pathname must start with ${PROXY_PREFIX}: ${targetUrl.pathname}`,
`Target URL path must start with /api/: ${targetPath}`,
{ source: 'playground' },
400,
);
}
const targetPath = targetUrl.pathname.slice(PROXY_PREFIX.length) + targetUrl.search;

const method = req.method.toUpperCase();
const hasBody = method !== 'GET' && method !== 'HEAD';
const body = hasBody ? Buffer.from(await req.arrayBuffer()) : undefined;

return dialDaemon(targetPath, method, req.headers, body);
return dialDaemon(targetPath, method, req.headers, body, {
tcpPort,
selfHost: req.headers.get('host'),
});
}

export const GET = proxy;
Expand Down
4 changes: 2 additions & 2 deletions content/docs/daemon/socket-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,14 @@ The socket is local by default:

```bash
curl --unix-socket "$HOME/.coven/coven.sock" \
http://localhost/api/v1/health
http://localhost/api/v1/health | jq .
```

For the full endpoint reference, see [Coven local socket API](/docs/reference/api). For diagrams of the handshake, event cursors, launch lifecycle, and authority boundary, see [API architecture diagrams](/docs/reference/api-architecture).

## Transports

The Unix socket at `$COVEN_HOME/coven.sock` is the default and recommended transport. The daemon can additionally bind a TCP listener with `coven daemon serve --tcp <addr>`, guarded so it stays local:
The Unix socket at `$COVEN_HOME/coven.sock` is the default and recommended transport. The daemon can additionally bind a TCP listener with `coven daemon serve --tcp <addr>` — the standard loopback address is `127.0.0.1:3000`, which is also the default port the docs-site Try It panels fall back to — guarded so it stays local:

- The bind address must resolve to loopback (`127.0.0.1` / `::1`); the daemon refuses non-loopback addresses.
- Each TCP request's `Host` and `Origin` headers must be loopback, or exactly match a host passed via the repeatable `--allow-host <host>` flag (for a trusted reverse proxy that forwards a fixed hostname, such as a Tailscale-served FQDN).
Expand Down
10 changes: 6 additions & 4 deletions content/docs/openapi/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ The Coven daemon serves a small HTTP/1.1 API over a Unix domain socket. This sec

## Start a daemon

The Try It panels on each endpoint page bridge browser requests through a Next.js route handler to your local `$COVEN_HOME/coven.sock`. They only work when this docs site is running on the same machine as a live daemon.
The Try It panels on each endpoint page target `http://localhost:{port}/api/v1` — click the server URL above any panel to change the `port` variable (default `3000`). Requests are bridged through a Next.js route handler that dials your local `$COVEN_HOME/coven.sock` first and falls back to a loopback TCP listener on the chosen port (`coven daemon serve --tcp 127.0.0.1:3000`). JSON responses are pretty-printed by the bridge. The panels only work when this docs site is running on the same machine as a live daemon (or a daemon tunneled to a local port).

```bash
coven daemon start
Expand All @@ -23,9 +23,10 @@ coven daemon status # verify pid + socket
<Callout type="info" title="Sanity-check from the terminal">
Without leaving the shell:
```bash
curl --unix-socket "$HOME/.coven/coven.sock" http://localhost/api/v1/health
curl --unix-socket "$HOME/.coven/coven.sock" \
http://localhost/api/v1/health | jq .
```
A healthy daemon returns `{"ok":true,"apiVersion":"coven.daemon.v1",...}`.
A healthy daemon returns `{ "ok": true, "apiVersion": "coven.daemon.v1", ... }`.
</Callout>

## Transport and trust model
Expand All @@ -34,6 +35,7 @@ coven daemon status # verify pid + socket
| --- | --- |
| Protocol | HTTP/1.1 over Unix domain socket |
| Default socket | `~/.coven/coven.sock` (override via `$COVEN_HOME`) |
| Optional TCP | Loopback-only listener via `coven daemon serve --tcp 127.0.0.1:3000` (standard port `3000`) |
| Auth | File-system permissions on the socket — no Bearer / JWT / API-key / cookie |
| Contract | `coven.daemon.v1` served under `/api/v1` |

Expand Down Expand Up @@ -75,4 +77,4 @@ Additive fields are allowed inside `v1`; breaking changes require a new route-pr

## Code samples

Every endpoint includes runnable samples in **curl** (with `--unix-socket`), **TypeScript** (undici `Agent({ connect: { socketPath } })`), **Python** (`httpx.HTTPTransport(uds=...)`), and **Rust** (`hyperlocal::UnixConnector`). Each sample reads `$COVEN_HOME` with a `~/.coven` fallback, so they work as-is once a daemon is running.
Every endpoint includes runnable samples in **curl** (with `--unix-socket`, piped through `jq` for formatted output), **TypeScript** (undici `Agent({ connect: { socketPath } })`), **Python** (`httpx.HTTPTransport(uds=...)`), and **Rust** (`hyperlocal::UnixConnector`). Each sample reads `$COVEN_HOME` with a `~/.coven` fallback and pretty-prints the JSON response, so they work as-is once a daemon is running.
2 changes: 1 addition & 1 deletion content/docs/reference/api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ _Last updated: 2026-07-21_

Coven exposes an HTTP API over the local Unix socket at `<covenHome>/coven.sock`. The Rust daemon is the authority boundary: clients may validate for UX, but the daemon still validates project roots, cwd, harness ids, session ids, input, and live-session state before acting.

The Unix socket is the default transport. The daemon can also bind a TCP listener via `coven daemon serve --tcp <addr>`: the bind address must be loopback, and each request's `Host`/`Origin` headers must be loopback or exactly match a host added with the repeatable `--allow-host <host>` flag (for a trusted reverse proxy such as a Tailscale-served FQDN). The API is unauthenticated on both transports, so any non-loopback reach must be fronted by an authenticated transport.
The Unix socket is the default transport. The daemon can also bind a TCP listener via `coven daemon serve --tcp <addr>` (standard loopback address: `127.0.0.1:3000`): the bind address must be loopback, and each request's `Host`/`Origin` headers must be loopback or exactly match a host added with the repeatable `--allow-host <host>` flag (for a trusted reverse proxy such as a Tailscale-served FQDN). The API is unauthenticated on both transports, so any non-loopback reach must be fronted by an authenticated transport.

For a consolidated visual map of route topology, compatibility handshake, error flow, event cursors, live control, launch lifecycle, and authority boundaries, see [API architecture diagrams](/docs/reference/api-architecture).

Expand Down
Loading