Skip to content
Draft
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
3 changes: 3 additions & 0 deletions hermes-sensor-bridge/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules
dist
*.tsbuildinfo
55 changes: 55 additions & 0 deletions hermes-sensor-bridge/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# @world2agent/hermes-sensor-bridge

World2Agent bridge for [Hermes Agent](https://hermes-agent.nousresearch.com/).

Runs W2A sensors as supervised Node subprocesses and delivers their signals into Hermes via the gateway's native webhook subscriptions. Each signal triggers a fresh `AIAgent.run_conversation()` with the corresponding handler skill auto-loaded by Hermes.

> Status: in development. See [`docs/channel-hermes-agent-design.md`](../docs/channel-hermes-agent-design.md) for the design.

## Layout

```
src/
runner/ Node sensor-runner subprocess (one per enabled sensor)
supervisor/ Independent local daemon — spawns/monitors runners,
exposes 127.0.0.1 control HTTP for reload/list/health
cli/ `world2agent-hermes` CLI (start/stop/status/add/remove/list)
skills/
world2agent-manage/ Agent-facing skill that wraps the CLI for
natural-language sensor management
```

## Bins

- `world2agent-hermes` — user-facing CLI
- `world2agent-hermes-supervisor` — daemon (started by `world2agent-hermes start`)
- `world2agent-sensor-runner` — per-sensor subprocess (spawned by the supervisor)

## Current CLI Flow

`world2agent-hermes add` currently expects a hand-written config JSON file:

```bash
world2agent-hermes add @world2agent/sensor-hackernews \
--config-file ./hackernews.json
```

Supported add-time overrides:

- `--config-file <path>` — bypasses interactive setup and writes the manifest directly
- `--webhook-url <url>` — provide the target webhook URL yourself
- `--hmac-secret <secret>` — override the shared bridge HMAC secret
- `--no-hermes-subscribe` — skip the `hermes webhook subscribe` shellout entirely

The last three flags are intended mainly for local development and testing. In
the normal path, the bridge calls `hermes webhook subscribe`, stores the
returned webhook URL in the manifest, and reloads the local supervisor.

When a sensor package does not ship a machine-runnable setup helper, the bridge
generates a generic Hermes skill for that sensor instead of a fully customized
handler. The package's `SETUP.md` remains the source of truth for richer,
sensor-specific behavior.

## Relation to `claude-code-channel`

Sibling package. `claude-code-channel` is an in-process MCP channel for Claude Code; this package is an out-of-process bridge for Hermes. Both load the same `@world2agent/sensor-*` packages without modification.
5 changes: 5 additions & 0 deletions hermes-sensor-bridge/e2e/hackernews.config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"top_n": 5,
"min_score": 1,
"interval_seconds": 30
}
86 changes: 86 additions & 0 deletions hermes-sensor-bridge/e2e/mock-hermes-receiver.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
#!/usr/bin/env node

import { createHmac } from "node:crypto";
import { createServer } from "node:http";

const port = Number(process.env.MOCK_HERMES_PORT ?? "8786");
const secret = process.env.MOCK_HERMES_SECRET ?? "test-secret";

const server = createServer((req, res) => {
const chunks = [];

req.on("data", (chunk) => {
chunks.push(chunk);
});

req.on("end", () => {
const body = Buffer.concat(chunks).toString("utf8");
const signature = req.headers["x-webhook-signature"];
const requestId = req.headers["x-request-id"];

if (typeof signature !== "string") {
res.statusCode = 400;
res.end("missing X-Webhook-Signature");
return;
}

if (signature.startsWith("sha256=")) {
res.statusCode = 400;
res.end("signature must be raw hex");
return;
}

const expected = createHmac("sha256", secret).update(body).digest("hex");
if (signature !== expected) {
res.statusCode = 401;
res.end("invalid signature");
return;
}

let payload;
try {
payload = JSON.parse(body);
} catch {
res.statusCode = 400;
res.end("invalid json");
return;
}

const signalId = payload?.signal?.signal_id;
if (typeof signalId !== "string") {
res.statusCode = 400;
res.end("missing signal.signal_id");
return;
}

if (requestId !== signalId) {
res.statusCode = 400;
res.end("X-Request-ID mismatch");
return;
}

process.stdout.write(
JSON.stringify(
{
ok: true,
signature_prefix: signature.slice(0, 16),
request_id: requestId,
signal_id: signalId,
event_type: payload?.signal?.event?.type ?? null,
body: payload,
},
null,
2,
) + "\n",
);

res.statusCode = 200;
res.end("ok");
});
});

server.listen(port, "127.0.0.1", () => {
process.stdout.write(
JSON.stringify({ ok: true, listening: `http://127.0.0.1:${port}`, secret }, null, 2) + "\n",
);
});
208 changes: 208 additions & 0 deletions hermes-sensor-bridge/e2e/test-delivery.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
#!/usr/bin/env node
/**
* Smoke test for the supervisor's delivery worker. Stands up a tiny
* http.createServer that mimics the contract Hermes's webhook adapter
* imposes (HMAC raw-hex match + body shape + X-Request-ID), then drives
* `httpPost` and `renderPrompt` directly to verify:
*
* 1. Body has shape `{ prompt, signal }` with prompt ending in a JSON
* code fence containing the original signal.
* 2. X-Request-ID equals signal.signal_id.
* 3. X-Webhook-Signature is the HMAC-SHA256 of the body, raw hex (no
* `sha256=` prefix).
* 4. 5xx triggers retry; 4xx fails immediately.
*
* Usage:
* node e2e/test-delivery.mjs
*/

import { createServer } from "node:http";
import { createHmac } from "node:crypto";
import { httpPost, renderPrompt } from "../dist/supervisor/spawn.js";

let failures = 0;
function check(label, cond, detail) {
const ok = !!cond;
process.stdout.write(`${ok ? "PASS" : "FAIL"} ${label}\n`);
if (!ok) {
failures++;
if (detail !== undefined) process.stdout.write(` ${detail}\n`);
}
}

const SECRET = "test-secret-deadbeef";

function startServer(handler) {
return new Promise((resolve) => {
const srv = createServer(async (req, res) => {
let buf = "";
for await (const chunk of req) buf += chunk;
handler(req, buf, res);
});
srv.listen(0, "127.0.0.1", () => {
const addr = srv.address();
resolve({ srv, url: `http://127.0.0.1:${addr.port}` });
});
});
}

const fakeSignal = {
signal_id: "test-sig-123",
schema_version: "0.1.0",
source: { sensor_id: "test-sensor" },
event: {
type: "news.story.trending",
summary: "Test story summary",
occurred_at: "2026-04-27T12:00:00Z",
},
attachments: [{ media_type: "text/markdown", title: "body" }],
};

// case 1: happy path — verify body, headers, prompt shape
{
let captured;
const { srv, url } = await startServer((req, body, res) => {
captured = { headers: req.headers, body };
res.statusCode = 202;
res.end("ok");
});
try {
const body = JSON.stringify({
prompt: renderPrompt(fakeSignal),
signal: fakeSignal,
});
const sig = createHmac("sha256", SECRET).update(body).digest("hex");
await httpPost(
url,
body,
{
"content-type": "application/json",
"x-request-id": fakeSignal.signal_id,
"x-webhook-signature": sig,
},
{ timeoutMs: 5_000, maxAttempts: 1, baseDelayMs: 100 },
);

check("happy: server received POST", !!captured);
check(
"happy: x-request-id == signal.signal_id",
captured.headers["x-request-id"] === fakeSignal.signal_id,
);
check(
"happy: x-webhook-signature is raw hex (no sha256= prefix)",
typeof captured.headers["x-webhook-signature"] === "string" &&
/^[0-9a-f]{64}$/.test(captured.headers["x-webhook-signature"]),
`got: ${captured.headers["x-webhook-signature"]}`,
);
check(
"happy: signature matches recomputed HMAC",
captured.headers["x-webhook-signature"] === sig,
);

const parsed = JSON.parse(captured.body);
check("happy: body has prompt + signal", typeof parsed.prompt === "string" && !!parsed.signal);
check(
"happy: signal in body matches input",
parsed.signal.signal_id === fakeSignal.signal_id,
);
check(
"happy: prompt body has type + summary",
parsed.prompt.includes("news.story.trending") && parsed.prompt.includes("Test story summary"),
);
check(
"happy: prompt body ends with JSON code fence containing signal",
/```json[\s\S]*"signal_id": "test-sig-123"[\s\S]*```/.test(parsed.prompt),
);
} finally {
srv.close();
}
}

// case 2: 4xx — fail fast, no retry
{
let calls = 0;
const { srv, url } = await startServer((_req, _body, res) => {
calls++;
res.statusCode = 401;
res.end("unauthorized");
});
try {
let threw = false;
try {
await httpPost(
url,
"{}",
{},
{ timeoutMs: 2_000, maxAttempts: 3, baseDelayMs: 10 },
);
} catch (error) {
threw = true;
check("4xx: error mentions 401", String(error).includes("401"));
}
check("4xx: throws", threw);
check("4xx: only one call (no retry)", calls === 1);
} finally {
srv.close();
}
}

// case 3: 5xx — retry up to maxAttempts, eventually throws
{
let calls = 0;
const { srv, url } = await startServer((_req, _body, res) => {
calls++;
res.statusCode = 503;
res.end("flaky");
});
try {
let threw = false;
try {
await httpPost(
url,
"{}",
{},
{ timeoutMs: 2_000, maxAttempts: 3, baseDelayMs: 10 },
);
} catch (error) {
threw = true;
check("5xx: error mentions 503", String(error).includes("503"));
}
check("5xx: throws after retries", threw);
check("5xx: called maxAttempts times", calls === 3, `calls=${calls}`);
} finally {
srv.close();
}
}

// case 4: 5xx then 200 — retry succeeds
{
let calls = 0;
const { srv, url } = await startServer((_req, _body, res) => {
calls++;
if (calls < 2) {
res.statusCode = 503;
res.end("flaky");
} else {
res.statusCode = 200;
res.end("ok");
}
});
try {
await httpPost(
url,
"{}",
{},
{ timeoutMs: 2_000, maxAttempts: 3, baseDelayMs: 10 },
);
check("5xx-then-200: succeeded after retry", true);
check("5xx-then-200: exactly 2 calls", calls === 2, `calls=${calls}`);
} finally {
srv.close();
}
}

if (failures > 0) {
process.stderr.write(`\n${failures} check(s) failed.\n`);
process.exit(1);
}
process.stdout.write("\nAll checks passed.\n");
Loading
Loading