@agentproto/relay 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.mjs +456 -0
- package/dist/cli.mjs.map +1 -0
- package/dist/index.d.ts +147 -0
- package/dist/index.mjs +363 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +68 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jeremy (ref.sh)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# @agentproto/relay
|
|
2
|
+
|
|
3
|
+
A small, standalone companion service that lets **one** external HTTP webhook
|
|
4
|
+
wake up **one** specific, pre-configured agentproto daemon session.
|
|
5
|
+
|
|
6
|
+
This is generic OSS infrastructure. It has no knowledge of, and no code
|
|
7
|
+
specific to, any particular external product, CRM, or messaging platform.
|
|
8
|
+
Wiring this up to a specific external system (mapping that system's webhook
|
|
9
|
+
payload into `{"text": "..."}`, handling its signature scheme, etc.) is
|
|
10
|
+
integration work that happens outside this package — this package only knows
|
|
11
|
+
how to accept `{"text": "..."}` and forward it to a daemon session.
|
|
12
|
+
|
|
13
|
+
## Security model — read this before exposing it publicly
|
|
14
|
+
|
|
15
|
+
This service is designed to run behind a public tunnel. The main risk of
|
|
16
|
+
"webhook wakes up an agent" is a leaked or guessed credential letting an
|
|
17
|
+
attacker inject prompts into *any* of your live sessions. This package's
|
|
18
|
+
answer to that:
|
|
19
|
+
|
|
20
|
+
- **Exactly one target session, fixed at startup.** The session id/name is
|
|
21
|
+
read once from `AGENTPROTO_RELAY_TARGET_SESSION` at process start. It is
|
|
22
|
+
**never** accepted as part of an inbound request — there is no `sessionId`
|
|
23
|
+
field anywhere in the `/relay/inbound` API. Even a fully leaked bearer
|
|
24
|
+
token only lets a caller wake up the one session the operator explicitly
|
|
25
|
+
chose to expose.
|
|
26
|
+
- **Mandatory bearer token, constant-time compare.** `AGENTPROTO_RELAY_TOKEN`
|
|
27
|
+
is required — the process refuses to start without it. There is no
|
|
28
|
+
no-auth fallback, ever. The comparison uses an HMAC-blinded constant-time
|
|
29
|
+
compare (not `===`, not a naive `timingSafeEqual` on raw buffers) so a
|
|
30
|
+
network attacker can't use response-timing differences to recover the
|
|
31
|
+
token one byte at a time.
|
|
32
|
+
- **Basic rate limiting.** A publicly reachable endpoint that triggers an LLM
|
|
33
|
+
turn is a real cost/abuse vector even behind a valid-looking token. See
|
|
34
|
+
below for the default and how to tune it.
|
|
35
|
+
- **Inbound-only.** This service relays *into* a session. Getting output back
|
|
36
|
+
out is the target session's own job, using whatever tools/credentials it's
|
|
37
|
+
separately given — that's intentionally not this package's concern.
|
|
38
|
+
|
|
39
|
+
## What it exposes
|
|
40
|
+
|
|
41
|
+
### `POST /relay/inbound`
|
|
42
|
+
|
|
43
|
+
Body is a flexible JSON object. The **only** field read is a top-level
|
|
44
|
+
`text` string — everything else is ignored. This is deliberate: it makes the
|
|
45
|
+
endpoint compatible with webhook payloads from arbitrary external senders
|
|
46
|
+
(which typically include a `text`/`message`/`body` field buried among sender
|
|
47
|
+
metadata this package has no reason to understand) without needing per-sender
|
|
48
|
+
adapter code.
|
|
49
|
+
|
|
50
|
+
Requires `Authorization: Bearer <AGENTPROTO_RELAY_TOKEN>`.
|
|
51
|
+
|
|
52
|
+
On a valid, authenticated request:
|
|
53
|
+
|
|
54
|
+
1. Confirms the configured target session exists and is alive via the
|
|
55
|
+
daemon's `GET /sessions/:id`.
|
|
56
|
+
2. Delivers `text` to it via the configured delivery mode — either
|
|
57
|
+
`agent_prompt` (`POST /sessions/:id/prompt`, fire-and-forget) or
|
|
58
|
+
`terminal_input` (a WebSocket write to a PTY session's stdin).
|
|
59
|
+
3. Returns `202 { ok: true, sessionId, via }` once the daemon has accepted
|
|
60
|
+
the message. This does **not** wait for the agent to finish its turn —
|
|
61
|
+
webhook senders often have short timeouts, and there's no reason to hold
|
|
62
|
+
the connection open for an entire LLM turn just to relay one message in.
|
|
63
|
+
|
|
64
|
+
Error responses are plain JSON with an `error` code and a human-readable
|
|
65
|
+
`message`: `401 unauthorized`, `429 rate_limited`, `400 missing_text` /
|
|
66
|
+
`invalid_body`, `502 target_session_unavailable` / `relay_delivery_failed`.
|
|
67
|
+
|
|
68
|
+
### `GET /relay/health`
|
|
69
|
+
|
|
70
|
+
Trivial healthcheck. No auth required.
|
|
71
|
+
|
|
72
|
+
## Configuration
|
|
73
|
+
|
|
74
|
+
All via environment variables:
|
|
75
|
+
|
|
76
|
+
| Variable | Required | Default | Meaning |
|
|
77
|
+
| --- | --- | --- | --- |
|
|
78
|
+
| `AGENTPROTO_RELAY_TARGET_SESSION` | yes | — | The id or name of the ONE session this relay may wake up. |
|
|
79
|
+
| `AGENTPROTO_RELAY_TOKEN` | yes | — | Shared-secret bearer token for `/relay/inbound`. The process refuses to start if this is unset or empty. |
|
|
80
|
+
| `AGENTPROTO_RELAY_TARGET_VIA` | no | `agent` | `agent` (deliver via `agent_prompt`) or `terminal` (deliver via `terminal_input`). |
|
|
81
|
+
| `AGENTPROTO_DAEMON_URL` | no | `http://127.0.0.1:18790` | Base URL of the agentproto daemon to talk to. |
|
|
82
|
+
| `AGENTPROTO_RELAY_RATE_LIMIT` | no | `20` | Max `/relay/inbound` requests allowed per rate-limit window. |
|
|
83
|
+
| `AGENTPROTO_RELAY_RATE_WINDOW_MS` | no | `60000` | Rate-limit window size, in milliseconds. |
|
|
84
|
+
|
|
85
|
+
The rate limit is **global**, not per-caller — this relay is designed for
|
|
86
|
+
exactly one legitimate caller (whoever holds the token), so there's no
|
|
87
|
+
per-IP bucketing to bypass or exhaust via header spoofing.
|
|
88
|
+
|
|
89
|
+
## Running it
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
# from the monorepo root, after `pnpm install` + `pnpm build`
|
|
93
|
+
AGENTPROTO_RELAY_TARGET_SESSION=my-session \
|
|
94
|
+
AGENTPROTO_RELAY_TOKEN=$(openssl rand -hex 32) \
|
|
95
|
+
node packages/relay/dist/cli.mjs --port 8790
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Add `--tunnel` to also ask the daemon (via its `tunnel_create`/`/tunnels`
|
|
99
|
+
REST route) to spawn a public cloudflared quick tunnel for `--port`, and
|
|
100
|
+
print the resulting public URL:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
AGENTPROTO_RELAY_TARGET_SESSION=my-session \
|
|
104
|
+
AGENTPROTO_RELAY_TOKEN=$(openssl rand -hex 32) \
|
|
105
|
+
node packages/relay/dist/cli.mjs --port 8790 --tunnel
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
Public relay URL — paste this into whatever external system sends the webhook:
|
|
110
|
+
https://random-words-1234.trycloudflare.com/relay/inbound
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
That printed URL — `<tunnel-url>/relay/inbound` — is what you paste into
|
|
114
|
+
whatever external system delivers the webhook. Without `--tunnel`, the relay
|
|
115
|
+
just binds to `127.0.0.1:<port>` for local testing.
|
|
116
|
+
|
|
117
|
+
## curl example
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
curl -X POST http://127.0.0.1:8790/relay/inbound \
|
|
121
|
+
-H "Authorization: Bearer $AGENTPROTO_RELAY_TOKEN" \
|
|
122
|
+
-H "Content-Type: application/json" \
|
|
123
|
+
-d '{"text": "a customer just replied, take a look"}'
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
```json
|
|
127
|
+
{"ok":true,"sessionId":"sess_abc12345","via":"agent"}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Explicitly out of scope
|
|
131
|
+
|
|
132
|
+
- Any specific external integration — mapping a particular product's webhook
|
|
133
|
+
shape into `{"text": "..."}`, verifying that product's signature scheme,
|
|
134
|
+
etc. That's integration-specific glue that lives outside this repo.
|
|
135
|
+
- Outbound delivery. This is inbound-only: external webhook → wake a
|
|
136
|
+
session. Sending messages back out is the target session's own job.
|
|
137
|
+
- Multi-session or dynamic target selection. Deliberately not supported —
|
|
138
|
+
see the security model above.
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/cli.mjs
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createServer } from 'http';
|
|
3
|
+
import { readFile } from 'fs/promises';
|
|
4
|
+
import { homedir } from 'os';
|
|
5
|
+
import { join } from 'path';
|
|
6
|
+
import WebSocket from 'ws';
|
|
7
|
+
import { randomBytes, createHmac, timingSafeEqual } from 'crypto';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @agentproto/relay v0.1.0-alpha
|
|
11
|
+
* Standalone webhook-to-session relay: one fixed target session, one bearer token.
|
|
12
|
+
*/
|
|
13
|
+
var __defProp = Object.defineProperty;
|
|
14
|
+
var __export = (target, all) => {
|
|
15
|
+
for (var name in all)
|
|
16
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// src/config.ts
|
|
20
|
+
var DEFAULT_DAEMON_URL = "http://127.0.0.1:18790";
|
|
21
|
+
var DEFAULT_TARGET_VIA = "agent";
|
|
22
|
+
var DEFAULT_RATE_LIMIT_MAX = 20;
|
|
23
|
+
var DEFAULT_RATE_LIMIT_WINDOW_MS = 6e4;
|
|
24
|
+
function loadConfigFromEnv(env = process.env) {
|
|
25
|
+
const targetSession = (env.AGENTPROTO_RELAY_TARGET_SESSION ?? "").trim();
|
|
26
|
+
if (!targetSession) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
"AGENTPROTO_RELAY_TARGET_SESSION is required \u2014 set it to the id or name of the ONE session this relay is allowed to wake up. It is never accepted as a request parameter, by design."
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
const rawVia = (env.AGENTPROTO_RELAY_TARGET_VIA ?? DEFAULT_TARGET_VIA).trim();
|
|
32
|
+
if (rawVia !== "agent" && rawVia !== "terminal") {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`AGENTPROTO_RELAY_TARGET_VIA must be "agent" or "terminal" (got "${rawVia}").`
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
const token = env.AGENTPROTO_RELAY_TOKEN ?? "";
|
|
38
|
+
if (!token) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
"AGENTPROTO_RELAY_TOKEN is required \u2014 refusing to start without a shared secret. This service is designed to be exposed publicly; there is no no-auth fallback."
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
const daemonUrl = (env.AGENTPROTO_DAEMON_URL || DEFAULT_DAEMON_URL).replace(/\/+$/, "");
|
|
44
|
+
return {
|
|
45
|
+
targetSession,
|
|
46
|
+
targetVia: rawVia,
|
|
47
|
+
token,
|
|
48
|
+
daemonUrl,
|
|
49
|
+
rateLimit: {
|
|
50
|
+
max: parsePositiveInt(env.AGENTPROTO_RELAY_RATE_LIMIT, DEFAULT_RATE_LIMIT_MAX),
|
|
51
|
+
windowMs: parsePositiveInt(
|
|
52
|
+
env.AGENTPROTO_RELAY_RATE_WINDOW_MS,
|
|
53
|
+
DEFAULT_RATE_LIMIT_WINDOW_MS
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
function parsePositiveInt(raw, fallback) {
|
|
59
|
+
if (!raw) return fallback;
|
|
60
|
+
const n = Number(raw);
|
|
61
|
+
return Number.isInteger(n) && n > 0 ? n : fallback;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// src/daemon-client.ts
|
|
65
|
+
var daemon_client_exports = {};
|
|
66
|
+
__export(daemon_client_exports, {
|
|
67
|
+
checkSessionAlive: () => checkSessionAlive,
|
|
68
|
+
resolveDaemonToken: () => resolveDaemonToken,
|
|
69
|
+
sendAgentPrompt: () => sendAgentPrompt,
|
|
70
|
+
sendTerminalInput: () => sendTerminalInput
|
|
71
|
+
});
|
|
72
|
+
var DEAD_STATUSES = /* @__PURE__ */ new Set(["exited", "killed", "error"]);
|
|
73
|
+
async function checkSessionAlive(daemonUrl, target, fetchImpl = fetch) {
|
|
74
|
+
let res;
|
|
75
|
+
try {
|
|
76
|
+
res = await fetchImpl(`${daemonUrl}/sessions/${encodeURIComponent(target)}`, {
|
|
77
|
+
signal: AbortSignal.timeout(1e4)
|
|
78
|
+
});
|
|
79
|
+
} catch (err) {
|
|
80
|
+
return {
|
|
81
|
+
ok: false,
|
|
82
|
+
reason: `could not reach daemon at ${daemonUrl}: ${err instanceof Error ? err.message : String(err)}`
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
if (res.status === 404) {
|
|
86
|
+
return { ok: false, reason: `no session "${target}" on the daemon` };
|
|
87
|
+
}
|
|
88
|
+
if (!res.ok) {
|
|
89
|
+
return { ok: false, reason: `daemon returned HTTP ${res.status} for GET /sessions/${target}` };
|
|
90
|
+
}
|
|
91
|
+
let desc;
|
|
92
|
+
try {
|
|
93
|
+
desc = await res.json();
|
|
94
|
+
} catch {
|
|
95
|
+
return { ok: false, reason: "daemon returned a non-JSON session descriptor" };
|
|
96
|
+
}
|
|
97
|
+
if (typeof desc.id !== "string") {
|
|
98
|
+
return { ok: false, reason: "daemon's session descriptor is missing an id" };
|
|
99
|
+
}
|
|
100
|
+
const status = typeof desc.status === "string" ? desc.status : void 0;
|
|
101
|
+
if (status && DEAD_STATUSES.has(status)) {
|
|
102
|
+
return { ok: false, id: desc.id, status, reason: `session "${target}" has status "${status}"` };
|
|
103
|
+
}
|
|
104
|
+
if (desc.processAlive === false) {
|
|
105
|
+
return { ok: false, id: desc.id, status, reason: `session "${target}" process is not alive` };
|
|
106
|
+
}
|
|
107
|
+
return { ok: true, id: desc.id, status };
|
|
108
|
+
}
|
|
109
|
+
function isPidAlive(pid) {
|
|
110
|
+
try {
|
|
111
|
+
process.kill(pid, 0);
|
|
112
|
+
return true;
|
|
113
|
+
} catch (err) {
|
|
114
|
+
return err.code === "EPERM";
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
async function resolveDaemonToken(daemonUrl, opts = {}) {
|
|
118
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
119
|
+
const homeDir = opts.homeDir ?? homedir();
|
|
120
|
+
const fromRegistry = await readRegistryToken(daemonUrl, homeDir);
|
|
121
|
+
if (fromRegistry) return fromRegistry;
|
|
122
|
+
return readWorkspaceToken(daemonUrl, fetchImpl);
|
|
123
|
+
}
|
|
124
|
+
async function readRegistryToken(daemonUrl, homeDir) {
|
|
125
|
+
let port;
|
|
126
|
+
try {
|
|
127
|
+
port = new URL(daemonUrl).port;
|
|
128
|
+
} catch {
|
|
129
|
+
return void 0;
|
|
130
|
+
}
|
|
131
|
+
if (!port) return void 0;
|
|
132
|
+
try {
|
|
133
|
+
const raw = await readFile(join(homeDir, ".agentproto", "daemons", `${port}.json`), "utf8");
|
|
134
|
+
const meta = JSON.parse(raw);
|
|
135
|
+
if (typeof meta.pid === "number" && !isPidAlive(meta.pid)) return void 0;
|
|
136
|
+
return typeof meta.token === "string" && meta.token ? meta.token : void 0;
|
|
137
|
+
} catch {
|
|
138
|
+
return void 0;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
async function readWorkspaceToken(daemonUrl, fetchImpl) {
|
|
142
|
+
try {
|
|
143
|
+
const res = await fetchImpl(`${daemonUrl}/health`, { signal: AbortSignal.timeout(5e3) });
|
|
144
|
+
if (!res.ok) return void 0;
|
|
145
|
+
const health = await res.json();
|
|
146
|
+
if (typeof health.workspace !== "string" || !health.workspace) return void 0;
|
|
147
|
+
const raw = await readFile(join(health.workspace, ".agentproto", "runtime.json"), "utf8");
|
|
148
|
+
const meta = JSON.parse(raw);
|
|
149
|
+
return typeof meta.token === "string" && meta.token ? meta.token : void 0;
|
|
150
|
+
} catch {
|
|
151
|
+
return void 0;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
async function sendAgentPrompt(daemonUrl, sessionId, text, token, fetchImpl = fetch) {
|
|
155
|
+
try {
|
|
156
|
+
const res = await fetchImpl(
|
|
157
|
+
`${daemonUrl}/sessions/${encodeURIComponent(sessionId)}/prompt?wait=false`,
|
|
158
|
+
{
|
|
159
|
+
method: "POST",
|
|
160
|
+
headers: {
|
|
161
|
+
"content-type": "application/json",
|
|
162
|
+
...token ? { authorization: `Bearer ${token}` } : {}
|
|
163
|
+
},
|
|
164
|
+
body: JSON.stringify({ prompt: text }),
|
|
165
|
+
signal: AbortSignal.timeout(15e3)
|
|
166
|
+
}
|
|
167
|
+
);
|
|
168
|
+
if (res.ok) return { ok: true, status: res.status };
|
|
169
|
+
return { ok: false, status: res.status, message: await describeErrorResponse(res) };
|
|
170
|
+
} catch (err) {
|
|
171
|
+
return { ok: false, message: err instanceof Error ? err.message : String(err) };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
async function describeErrorResponse(res) {
|
|
175
|
+
try {
|
|
176
|
+
const body = await res.json();
|
|
177
|
+
if (typeof body.message === "string") return body.message;
|
|
178
|
+
if (typeof body.error === "string") return body.error;
|
|
179
|
+
return JSON.stringify(body);
|
|
180
|
+
} catch {
|
|
181
|
+
return `HTTP ${res.status}`;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
async function sendTerminalInput(daemonUrl, sessionId, text, token, timeoutMs = 1e4) {
|
|
185
|
+
const wsUrl = `${daemonUrl.replace(/^http/, "ws")}/sessions/${encodeURIComponent(sessionId)}/pty`;
|
|
186
|
+
return new Promise((resolvePromise) => {
|
|
187
|
+
let settled = false;
|
|
188
|
+
const settle = (result) => {
|
|
189
|
+
if (settled) return;
|
|
190
|
+
settled = true;
|
|
191
|
+
clearTimeout(timer);
|
|
192
|
+
resolvePromise(result);
|
|
193
|
+
};
|
|
194
|
+
const ws = new WebSocket(wsUrl, {
|
|
195
|
+
headers: token ? { authorization: `Bearer ${token}` } : {}
|
|
196
|
+
});
|
|
197
|
+
const timer = setTimeout(() => {
|
|
198
|
+
ws.terminate();
|
|
199
|
+
settle({ ok: false, message: "terminal_input: timed out connecting to the daemon" });
|
|
200
|
+
}, timeoutMs);
|
|
201
|
+
ws.once("open", () => {
|
|
202
|
+
ws.send(JSON.stringify({ kind: "input", text }));
|
|
203
|
+
ws.close(1e3);
|
|
204
|
+
settle({ ok: true });
|
|
205
|
+
});
|
|
206
|
+
ws.once("unexpected-response", (_req, res) => {
|
|
207
|
+
ws.terminate();
|
|
208
|
+
settle({
|
|
209
|
+
ok: false,
|
|
210
|
+
status: res.statusCode,
|
|
211
|
+
message: `terminal_input: daemon rejected the WebSocket upgrade with HTTP ${res.statusCode}`
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
ws.once("error", (err) => {
|
|
215
|
+
settle({ ok: false, message: err instanceof Error ? err.message : String(err) });
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// src/rate-limiter.ts
|
|
221
|
+
function createRateLimiter(opts) {
|
|
222
|
+
const { max, windowMs } = opts;
|
|
223
|
+
const now = opts.now ?? Date.now;
|
|
224
|
+
const timestamps = [];
|
|
225
|
+
return {
|
|
226
|
+
allow() {
|
|
227
|
+
const t = now();
|
|
228
|
+
const cutoff = t - windowMs;
|
|
229
|
+
while (timestamps.length > 0 && timestamps[0] <= cutoff) {
|
|
230
|
+
timestamps.shift();
|
|
231
|
+
}
|
|
232
|
+
if (timestamps.length >= max) return false;
|
|
233
|
+
timestamps.push(t);
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
function timingSafeEqualStrings(a, b) {
|
|
239
|
+
const key = randomBytes(32);
|
|
240
|
+
const digestA = createHmac("sha256", key).update(a, "utf8").digest();
|
|
241
|
+
const digestB = createHmac("sha256", key).update(b, "utf8").digest();
|
|
242
|
+
return timingSafeEqual(digestA, digestB);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// src/server.ts
|
|
246
|
+
var MAX_BODY_BYTES = 1e6;
|
|
247
|
+
function createRelayServer(opts) {
|
|
248
|
+
const daemonClient = opts.daemonClient ?? daemon_client_exports;
|
|
249
|
+
const rateLimiter = opts.rateLimiter ?? createRateLimiter(opts.config.rateLimit);
|
|
250
|
+
return createServer((req, res) => {
|
|
251
|
+
handleRequest(req, res, opts.config, daemonClient, rateLimiter).catch((err) => {
|
|
252
|
+
if (!res.headersSent) {
|
|
253
|
+
writeJson(res, 500, {
|
|
254
|
+
error: "internal_error",
|
|
255
|
+
message: err instanceof Error ? err.message : String(err)
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
async function handleRequest(req, res, config, daemonClient, rateLimiter) {
|
|
262
|
+
const url = new URL(req.url ?? "/", "http://relay.local");
|
|
263
|
+
if (url.pathname === "/relay/health" && req.method === "GET") {
|
|
264
|
+
writeJson(res, 200, { ok: true });
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (url.pathname === "/relay/inbound" && req.method === "POST") {
|
|
268
|
+
await handleInbound(req, res, config, daemonClient, rateLimiter);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
writeJson(res, 404, { error: "not_found" });
|
|
272
|
+
}
|
|
273
|
+
async function handleInbound(req, res, config, daemonClient, rateLimiter) {
|
|
274
|
+
if (!rateLimiter.allow()) {
|
|
275
|
+
writeJson(res, 429, {
|
|
276
|
+
error: "rate_limited",
|
|
277
|
+
message: `Too many requests \u2014 limit is ${config.rateLimit.max} per ${config.rateLimit.windowMs}ms.`
|
|
278
|
+
});
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const authHeader = req.headers.authorization ?? "";
|
|
282
|
+
const expected = `Bearer ${config.token}`;
|
|
283
|
+
if (!timingSafeEqualStrings(authHeader, expected)) {
|
|
284
|
+
writeJson(res, 401, { error: "unauthorized" });
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
let body;
|
|
288
|
+
try {
|
|
289
|
+
body = await readJsonBody(req, MAX_BODY_BYTES);
|
|
290
|
+
} catch (err) {
|
|
291
|
+
writeJson(res, 400, {
|
|
292
|
+
error: "invalid_body",
|
|
293
|
+
message: err instanceof Error ? err.message : String(err)
|
|
294
|
+
});
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (body === INVALID_JSON) {
|
|
298
|
+
writeJson(res, 400, { error: "invalid_body", message: "Body must be valid JSON." });
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
const text = body?.text;
|
|
302
|
+
if (typeof text !== "string" || text.length === 0) {
|
|
303
|
+
writeJson(res, 400, {
|
|
304
|
+
error: "missing_text",
|
|
305
|
+
message: "Body must include a non-empty top-level `text` string. All other fields are ignored."
|
|
306
|
+
});
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
const alive = await daemonClient.checkSessionAlive(config.daemonUrl, config.targetSession);
|
|
310
|
+
if (!alive.ok || !alive.id) {
|
|
311
|
+
writeJson(res, 502, {
|
|
312
|
+
error: "target_session_unavailable",
|
|
313
|
+
message: alive.reason ?? "target session is not available"
|
|
314
|
+
});
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
const token = await daemonClient.resolveDaemonToken(config.daemonUrl);
|
|
318
|
+
const result = config.targetVia === "terminal" ? await daemonClient.sendTerminalInput(config.daemonUrl, alive.id, text, token) : await daemonClient.sendAgentPrompt(config.daemonUrl, alive.id, text, token);
|
|
319
|
+
if (!result.ok) {
|
|
320
|
+
const status = result.status && result.status >= 400 && result.status < 500 ? result.status : 502;
|
|
321
|
+
writeJson(res, status, {
|
|
322
|
+
error: "relay_delivery_failed",
|
|
323
|
+
message: result.message ?? "delivery to the target session failed"
|
|
324
|
+
});
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
writeJson(res, 202, { ok: true, sessionId: alive.id, via: config.targetVia });
|
|
328
|
+
}
|
|
329
|
+
function writeJson(res, status, body) {
|
|
330
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
331
|
+
res.end(JSON.stringify(body));
|
|
332
|
+
}
|
|
333
|
+
var INVALID_JSON = /* @__PURE__ */ Symbol("invalid_json");
|
|
334
|
+
function readJsonBody(req, maxBytes) {
|
|
335
|
+
return new Promise((resolve, reject) => {
|
|
336
|
+
let size = 0;
|
|
337
|
+
const chunks = [];
|
|
338
|
+
req.on("data", (chunk) => {
|
|
339
|
+
size += chunk.length;
|
|
340
|
+
if (size > maxBytes) {
|
|
341
|
+
reject(new Error(`request body exceeds ${maxBytes} bytes`));
|
|
342
|
+
req.destroy();
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
chunks.push(chunk);
|
|
346
|
+
});
|
|
347
|
+
req.on("end", () => {
|
|
348
|
+
if (chunks.length === 0) {
|
|
349
|
+
resolve(null);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
try {
|
|
353
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
|
|
354
|
+
} catch {
|
|
355
|
+
resolve(INVALID_JSON);
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
req.on("error", reject);
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// src/cli.ts
|
|
363
|
+
function parseArgs(argv) {
|
|
364
|
+
let port = 8790;
|
|
365
|
+
let tunnel = false;
|
|
366
|
+
for (let i = 0; i < argv.length; i++) {
|
|
367
|
+
const arg = argv[i];
|
|
368
|
+
if (arg === "--port") {
|
|
369
|
+
const value = argv[++i];
|
|
370
|
+
port = Number(value);
|
|
371
|
+
} else if (arg?.startsWith("--port=")) {
|
|
372
|
+
port = Number(arg.slice("--port=".length));
|
|
373
|
+
} else if (arg === "--tunnel") {
|
|
374
|
+
tunnel = true;
|
|
375
|
+
} else if (arg === "--help" || arg === "-h") {
|
|
376
|
+
printHelp();
|
|
377
|
+
process.exit(0);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
381
|
+
throw new Error(`invalid --port value \u2014 expected an integer 1-65535`);
|
|
382
|
+
}
|
|
383
|
+
return { port, tunnel };
|
|
384
|
+
}
|
|
385
|
+
function printHelp() {
|
|
386
|
+
console.log(
|
|
387
|
+
[
|
|
388
|
+
"agentproto-relay \u2014 wake one pre-configured agentproto session from an external webhook.",
|
|
389
|
+
"",
|
|
390
|
+
"Usage:",
|
|
391
|
+
" agentproto-relay --port <port> [--tunnel]",
|
|
392
|
+
"",
|
|
393
|
+
"Options:",
|
|
394
|
+
" --port <n> Local port to bind (default 8790).",
|
|
395
|
+
" --tunnel Also spawn a public cloudflared quick tunnel for --port via the",
|
|
396
|
+
" daemon's tunnel_create, and print the public /relay/inbound URL.",
|
|
397
|
+
" --help Show this message.",
|
|
398
|
+
"",
|
|
399
|
+
"Required env vars: AGENTPROTO_RELAY_TARGET_SESSION, AGENTPROTO_RELAY_TOKEN.",
|
|
400
|
+
"See the package README for the full list."
|
|
401
|
+
].join("\n")
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
async function createPublicTunnel(daemonUrl, port) {
|
|
405
|
+
const res = await fetch(`${daemonUrl}/tunnels`, {
|
|
406
|
+
method: "POST",
|
|
407
|
+
headers: { "content-type": "application/json" },
|
|
408
|
+
body: JSON.stringify({ targetPort: port, label: "agentproto-relay" })
|
|
409
|
+
});
|
|
410
|
+
if (!res.ok) {
|
|
411
|
+
const text = await res.text().catch(() => "");
|
|
412
|
+
throw new Error(`failed to create tunnel: HTTP ${res.status} ${text}`);
|
|
413
|
+
}
|
|
414
|
+
const desc = await res.json();
|
|
415
|
+
if (typeof desc.publicUrl !== "string" || !desc.publicUrl) {
|
|
416
|
+
throw new Error(`daemon did not return a publicUrl: ${JSON.stringify(desc)}`);
|
|
417
|
+
}
|
|
418
|
+
return desc.publicUrl;
|
|
419
|
+
}
|
|
420
|
+
async function main() {
|
|
421
|
+
const { port, tunnel } = parseArgs(process.argv.slice(2));
|
|
422
|
+
const config = loadConfigFromEnv(process.env);
|
|
423
|
+
const server = createRelayServer({ config });
|
|
424
|
+
await new Promise((resolve, reject) => {
|
|
425
|
+
server.once("error", reject);
|
|
426
|
+
server.listen(port, "127.0.0.1", () => resolve());
|
|
427
|
+
});
|
|
428
|
+
console.log(`agentproto-relay listening on http://127.0.0.1:${port}`);
|
|
429
|
+
console.log(` target session : ${config.targetSession} (via ${config.targetVia})`);
|
|
430
|
+
console.log(` daemon : ${config.daemonUrl}`);
|
|
431
|
+
console.log(
|
|
432
|
+
` rate limit : ${config.rateLimit.max} requests / ${config.rateLimit.windowMs}ms`
|
|
433
|
+
);
|
|
434
|
+
if (tunnel) {
|
|
435
|
+
console.log("");
|
|
436
|
+
console.log(`creating public tunnel for port ${port}...`);
|
|
437
|
+
const publicUrl = await createPublicTunnel(config.daemonUrl, port);
|
|
438
|
+
console.log("");
|
|
439
|
+
console.log("Public relay URL \u2014 paste this into whatever external system sends the webhook:");
|
|
440
|
+
console.log(` ${publicUrl}/relay/inbound`);
|
|
441
|
+
console.log("");
|
|
442
|
+
}
|
|
443
|
+
const shutdown = (signal) => {
|
|
444
|
+
console.log(`
|
|
445
|
+
received ${signal}, shutting down...`);
|
|
446
|
+
server.close(() => process.exit(0));
|
|
447
|
+
};
|
|
448
|
+
process.once("SIGINT", () => shutdown("SIGINT"));
|
|
449
|
+
process.once("SIGTERM", () => shutdown("SIGTERM"));
|
|
450
|
+
}
|
|
451
|
+
main().catch((err) => {
|
|
452
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
453
|
+
process.exit(1);
|
|
454
|
+
});
|
|
455
|
+
//# sourceMappingURL=cli.mjs.map
|
|
456
|
+
//# sourceMappingURL=cli.mjs.map
|