@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 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