agentgather 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 +418 -0
- package/SECURITY.md +104 -0
- package/dist/src/auth/index.js +1 -0
- package/dist/src/auth/tokens.js +12 -0
- package/dist/src/browser/room.css +666 -0
- package/dist/src/browser/room.html +80 -0
- package/dist/src/browser/room.js +435 -0
- package/dist/src/cli/args.js +29 -0
- package/dist/src/cli/commands/attend/index.js +26 -0
- package/dist/src/cli/commands/broker/index.js +61 -0
- package/dist/src/cli/commands/doctor/index.js +93 -0
- package/dist/src/cli/commands/export/index.js +42 -0
- package/dist/src/cli/commands/handoff/index.js +41 -0
- package/dist/src/cli/commands/instructions/index.js +7 -0
- package/dist/src/cli/commands/message/index.js +50 -0
- package/dist/src/cli/commands/message/transport.js +108 -0
- package/dist/src/cli/commands/room/index.js +350 -0
- package/dist/src/cli/commands/tunnel/index.js +131 -0
- package/dist/src/cli/commands/watch/index.js +16 -0
- package/dist/src/cli/context.js +9 -0
- package/dist/src/cli/help.js +53 -0
- package/dist/src/cli/index.js +63 -0
- package/dist/src/cli/state.js +40 -0
- package/dist/src/protocol/attendance.js +20 -0
- package/dist/src/protocol/index.js +7 -0
- package/dist/src/protocol/instructions.js +29 -0
- package/dist/src/protocol/mentions.js +48 -0
- package/dist/src/protocol/messages.js +71 -0
- package/dist/src/protocol/types.js +1 -0
- package/dist/src/protocol/urls.js +9 -0
- package/dist/src/protocol/validation.js +21 -0
- package/dist/src/server/errors.js +12 -0
- package/dist/src/server/http.js +583 -0
- package/dist/src/server/index.js +2 -0
- package/dist/src/server/wait.js +44 -0
- package/dist/src/storage/index.js +4 -0
- package/dist/src/storage/lock.js +93 -0
- package/dist/src/storage/paths.js +18 -0
- package/dist/src/storage/room-store.js +302 -0
- package/dist/src/storage/secure-fs.js +28 -0
- package/dist/src/tunnel/broker.js +440 -0
- package/dist/src/tunnel/client.js +144 -0
- package/dist/src/tunnel/forwarding.js +176 -0
- package/dist/src/tunnel/host-session.js +133 -0
- package/dist/src/tunnel/index.js +8 -0
- package/dist/src/tunnel/limits.js +81 -0
- package/dist/src/tunnel/logging.js +70 -0
- package/dist/src/tunnel/protocol.js +46 -0
- package/dist/src/tunnel/relay.js +106 -0
- package/docs/FOUNDING-TICKETS.md +759 -0
- package/docs/PROPOSAL.md +2120 -0
- package/docs/agentgather-dev-deployment-guide.md +305 -0
- package/docs/agentgather-dev-tunnel-architecture.md +349 -0
- package/docs/deploy-rooms-agentgather-dev.md +152 -0
- package/docs/dogfood/release-dogfood.md +61 -0
- package/docs/dogfood/sanitized-room-log.jsonl +6 -0
- package/docs/host-guide.md +282 -0
- package/docs/operator-runbook.md +248 -0
- package/docs/remote-exposure.md +269 -0
- package/docs/room-brief-and-attend-card.md +110 -0
- package/package.json +49 -0
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { URL } from "node:url";
|
|
4
|
+
import { hashToken, verifyToken } from "../auth/index.js";
|
|
5
|
+
import { appendMessageResult, appendServerMessage, closeRoom, readBrief, readMessages, readParticipants, readRoomState, roomPaths, updateBrief, updateAttendancePolicy, upsertParticipant, MAX_BRIEF_LENGTH, RoomLogFullError } from "../storage/index.js";
|
|
6
|
+
import { assertSafeSlug, describeAttendancePolicy, normalizeBaseUrl, parseAttendancePolicy, renderAgentInstructions, roomUrl } from "../protocol/index.js";
|
|
7
|
+
import { errorBody, HttpError } from "./errors.js";
|
|
8
|
+
import { buildWaitResponse, defaultWaitHub } from "./wait.js";
|
|
9
|
+
const DEFAULT_OPTIONS = {
|
|
10
|
+
baseUrl: "http://127.0.0.1:8787",
|
|
11
|
+
maxBodyBytes: 64_000,
|
|
12
|
+
maxMessages: 50_000,
|
|
13
|
+
rateLimitPerMinute: 120,
|
|
14
|
+
loopGuardLimit: 30,
|
|
15
|
+
waitHoldMs: 25_000,
|
|
16
|
+
waitHub: defaultWaitHub,
|
|
17
|
+
allowInsecureRemote: false
|
|
18
|
+
};
|
|
19
|
+
const rateBuckets = new Map();
|
|
20
|
+
const loopCounts = new Map();
|
|
21
|
+
const ATTENDANCE_STALE_AFTER_MS = 90_000;
|
|
22
|
+
export function createRoomHttpServer(options) {
|
|
23
|
+
const resolved = resolveOptions(options);
|
|
24
|
+
return createServer((req, res) => {
|
|
25
|
+
const url = new URL(req.url ?? "/", resolved.baseUrl);
|
|
26
|
+
void handleRequest({ req, res, url, options: resolved }).catch((error) => {
|
|
27
|
+
sendError(res, error);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
async function handleRequest(context) {
|
|
32
|
+
enforceExposure(context);
|
|
33
|
+
if (isWriteMethod(context.req.method))
|
|
34
|
+
enforceSameOrigin(context);
|
|
35
|
+
const { pathname } = context.url;
|
|
36
|
+
if (context.req.method === "GET" && pathname === "/")
|
|
37
|
+
return serveBrowserShell(context);
|
|
38
|
+
if (context.req.method === "GET" && pathname === "/room.css")
|
|
39
|
+
return serveBrowserAsset(context, "room.css", "text/css; charset=utf-8");
|
|
40
|
+
if (context.req.method === "GET" && pathname === "/room.js")
|
|
41
|
+
return serveBrowserAsset(context, "room.js", "text/javascript; charset=utf-8");
|
|
42
|
+
if (context.req.method === "GET" && pathname === "/brief")
|
|
43
|
+
return getBrief(context);
|
|
44
|
+
if (context.req.method === "POST" && pathname === "/brief")
|
|
45
|
+
return postBrief(context);
|
|
46
|
+
if (context.req.method === "POST" && pathname === "/attendance")
|
|
47
|
+
return postAttendance(context);
|
|
48
|
+
if (context.req.method === "GET" && pathname === "/profile")
|
|
49
|
+
return getProfile(context);
|
|
50
|
+
if (context.req.method === "POST" && pathname === "/profile")
|
|
51
|
+
return postProfile(context);
|
|
52
|
+
if (context.req.method === "GET" && pathname === "/card")
|
|
53
|
+
return getCard(context);
|
|
54
|
+
if (context.req.method === "POST" && pathname === "/join")
|
|
55
|
+
return postJoin(context);
|
|
56
|
+
if (context.req.method === "GET" && pathname === "/messages")
|
|
57
|
+
return getMessages(context);
|
|
58
|
+
if (context.req.method === "POST" && pathname === "/messages")
|
|
59
|
+
return postMessage(context);
|
|
60
|
+
if (context.req.method === "GET" && pathname === "/wait")
|
|
61
|
+
return getWait(context);
|
|
62
|
+
if (context.req.method === "POST" && pathname === "/leave")
|
|
63
|
+
return postLeave(context);
|
|
64
|
+
if (context.req.method === "POST" && pathname === "/close")
|
|
65
|
+
return postClose(context);
|
|
66
|
+
if (context.req.method === "GET" && pathname === "/status")
|
|
67
|
+
return getStatus(context);
|
|
68
|
+
throw new HttpError(404, "not_found", "endpoint not found");
|
|
69
|
+
}
|
|
70
|
+
async function serveBrowserShell(context) {
|
|
71
|
+
return serveBrowserAsset(context, "room.html", "text/html; charset=utf-8");
|
|
72
|
+
}
|
|
73
|
+
async function serveBrowserAsset(context, asset, contentType) {
|
|
74
|
+
const body = await readFile(new URL(`../browser/${asset}`, import.meta.url), "utf8");
|
|
75
|
+
sendText(context.res, 200, body, contentType);
|
|
76
|
+
}
|
|
77
|
+
async function getBrief(context) {
|
|
78
|
+
await requireParticipant(context);
|
|
79
|
+
sendJson(context.res, 200, { ok: true, brief: await readBrief(context.options.root, context.options.roomId) });
|
|
80
|
+
}
|
|
81
|
+
async function postBrief(context) {
|
|
82
|
+
const auth = await requireParticipant(context);
|
|
83
|
+
requireHost(auth.participant);
|
|
84
|
+
const body = await readJsonBody(context);
|
|
85
|
+
if (typeof body.body !== "string")
|
|
86
|
+
throw new HttpError(400, "invalid_body", "brief body is required");
|
|
87
|
+
if (body.body.length > MAX_BRIEF_LENGTH) {
|
|
88
|
+
throw new HttpError(413, "brief_too_large", `brief body must be <= ${MAX_BRIEF_LENGTH} characters`);
|
|
89
|
+
}
|
|
90
|
+
const brief = await updateBrief({
|
|
91
|
+
root: context.options.root,
|
|
92
|
+
roomId: context.options.roomId,
|
|
93
|
+
body: body.body,
|
|
94
|
+
updatedBy: auth.participant.alias
|
|
95
|
+
});
|
|
96
|
+
await appendSystem(context, `Room brief updated to v${brief.brief_version}`);
|
|
97
|
+
context.options.waitHub.notify(context.options.roomId);
|
|
98
|
+
sendJson(context.res, 200, { ok: true, brief });
|
|
99
|
+
}
|
|
100
|
+
async function getCard(context) {
|
|
101
|
+
const auth = await requireParticipant(context, { allowQueryToken: true });
|
|
102
|
+
const paths = roomPaths(context.options.root, context.options.roomId);
|
|
103
|
+
const [brief, state] = await Promise.all([
|
|
104
|
+
readBrief(context.options.root, context.options.roomId),
|
|
105
|
+
readRoomState(paths)
|
|
106
|
+
]);
|
|
107
|
+
sendPlain(context.res, 200, renderAttendCard(advertisedBaseUrl(context), auth.participant.alias, auth.token, brief, state.attendance_policy));
|
|
108
|
+
}
|
|
109
|
+
async function postAttendance(context) {
|
|
110
|
+
const auth = await requireParticipant(context);
|
|
111
|
+
requireHost(auth.participant);
|
|
112
|
+
const body = await readJsonBody(context);
|
|
113
|
+
if (typeof body.policy !== "string")
|
|
114
|
+
throw new HttpError(400, "invalid_policy", "attendance policy is required");
|
|
115
|
+
let policy;
|
|
116
|
+
try {
|
|
117
|
+
policy = parseAttendancePolicy(body.policy);
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
throw new HttpError(400, "invalid_policy", error instanceof Error ? error.message : "invalid attendance policy");
|
|
121
|
+
}
|
|
122
|
+
const state = await updateAttendancePolicy({
|
|
123
|
+
root: context.options.root,
|
|
124
|
+
roomId: context.options.roomId,
|
|
125
|
+
policy,
|
|
126
|
+
updatedBy: auth.participant.alias
|
|
127
|
+
});
|
|
128
|
+
await appendSystem(context, `Attendance policy set to ${policy}`);
|
|
129
|
+
context.options.waitHub.notify(context.options.roomId);
|
|
130
|
+
sendJson(context.res, 200, { ok: true, attendance_policy: state.attendance_policy });
|
|
131
|
+
}
|
|
132
|
+
async function getProfile(context) {
|
|
133
|
+
const auth = await requireParticipant(context);
|
|
134
|
+
sendJson(context.res, 200, { ok: true, participant: publicParticipant(auth.participant) });
|
|
135
|
+
}
|
|
136
|
+
async function postProfile(context) {
|
|
137
|
+
const auth = await requireParticipant(context);
|
|
138
|
+
const body = await readJsonBody(context);
|
|
139
|
+
if (typeof body.display_name !== "string") {
|
|
140
|
+
throw new HttpError(400, "invalid_display_name", "display_name is required");
|
|
141
|
+
}
|
|
142
|
+
const displayName = parseDisplayName(body.display_name);
|
|
143
|
+
const participants = await readParticipants(roomPaths(context.options.root, context.options.roomId));
|
|
144
|
+
const duplicate = participants.find((participant) => participant.alias !== auth.participant.alias &&
|
|
145
|
+
(participant.display_name ?? participant.alias).toLowerCase() === displayName.toLowerCase());
|
|
146
|
+
if (duplicate !== undefined) {
|
|
147
|
+
throw new HttpError(409, "display_name_taken", "display name is already in use");
|
|
148
|
+
}
|
|
149
|
+
const updated = {
|
|
150
|
+
...auth.participant,
|
|
151
|
+
display_name: displayName,
|
|
152
|
+
lastSeenAt: new Date().toISOString()
|
|
153
|
+
};
|
|
154
|
+
await upsertParticipant(context.options.root, context.options.roomId, updated);
|
|
155
|
+
sendJson(context.res, 200, { ok: true, participant: publicParticipant(updated) });
|
|
156
|
+
}
|
|
157
|
+
async function postJoin(context) {
|
|
158
|
+
const auth = await requireParticipant(context);
|
|
159
|
+
const now = new Date().toISOString();
|
|
160
|
+
const { removed_at: _removedAt, ...baseParticipant } = auth.participant;
|
|
161
|
+
const participant = {
|
|
162
|
+
...baseParticipant,
|
|
163
|
+
attention: "attending",
|
|
164
|
+
joinedAt: baseParticipant.joinedAt || now,
|
|
165
|
+
lastSeenAt: now
|
|
166
|
+
};
|
|
167
|
+
await upsertParticipant(context.options.root, context.options.roomId, participant);
|
|
168
|
+
await appendSystem(context, `${auth.participant.alias} joined`);
|
|
169
|
+
context.options.waitHub.notify(context.options.roomId);
|
|
170
|
+
sendJson(context.res, 200, { ok: true, participant: auth.participant.alias });
|
|
171
|
+
}
|
|
172
|
+
async function getMessages(context) {
|
|
173
|
+
const auth = await requireParticipant(context);
|
|
174
|
+
await touchParticipant(context, auth.participant, auth.participant.attention);
|
|
175
|
+
const sinceId = parseSinceId(context.url.searchParams.get("since_id"));
|
|
176
|
+
const messages = (await readMessages(context.options.root, context.options.roomId)).filter((message) => message.id > sinceId);
|
|
177
|
+
sendJson(context.res, 200, {
|
|
178
|
+
ok: true,
|
|
179
|
+
messages,
|
|
180
|
+
next_since_id: messages.at(-1)?.id ?? sinceId
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
async function postMessage(context) {
|
|
184
|
+
const auth = await requireParticipant(context);
|
|
185
|
+
const body = await readJsonBody(context);
|
|
186
|
+
await touchParticipant(context, auth.participant, auth.participant.attention);
|
|
187
|
+
await requireRoomOpen(context);
|
|
188
|
+
enforceRateLimit(`${context.options.roomId}:${auth.participant.alias}`, context.options.rateLimitPerMinute);
|
|
189
|
+
enforceLoopGuard(context.options.roomId, auth.participant, context.options.loopGuardLimit);
|
|
190
|
+
const result = await appendMessageResult({
|
|
191
|
+
root: context.options.root,
|
|
192
|
+
roomId: context.options.roomId,
|
|
193
|
+
from: auth.participant.alias,
|
|
194
|
+
input: body,
|
|
195
|
+
maxMessages: context.options.maxMessages
|
|
196
|
+
});
|
|
197
|
+
if (result.idempotent) {
|
|
198
|
+
sendJson(context.res, 200, { ok: true, message: result.message, idempotent: true });
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
context.options.waitHub.notify(context.options.roomId);
|
|
202
|
+
sendJson(context.res, 201, { ok: true, message: result.message });
|
|
203
|
+
}
|
|
204
|
+
async function getWait(context) {
|
|
205
|
+
const auth = await requireParticipant(context, { allowRemoved: true });
|
|
206
|
+
if (auth.participant.removed_at === undefined) {
|
|
207
|
+
await touchParticipant(context, auth.participant, "attending");
|
|
208
|
+
}
|
|
209
|
+
const sinceId = parseSinceId(context.url.searchParams.get("since_id"));
|
|
210
|
+
const participantParam = context.url.searchParams.get("participant");
|
|
211
|
+
if (participantParam !== null && participantParam !== auth.participant.alias) {
|
|
212
|
+
throw new HttpError(403, "participant_mismatch", "participant query does not match token");
|
|
213
|
+
}
|
|
214
|
+
if (auth.participant.removed_at !== undefined) {
|
|
215
|
+
const state = await closeExpiredRoomIfNeeded(context);
|
|
216
|
+
sendJson(context.res, 200, {
|
|
217
|
+
ok: true,
|
|
218
|
+
room: context.options.roomId,
|
|
219
|
+
room_status: state.status,
|
|
220
|
+
participant: auth.participant.alias,
|
|
221
|
+
participant_status: "removed",
|
|
222
|
+
heartbeat: false,
|
|
223
|
+
messages: [],
|
|
224
|
+
mentioned: false,
|
|
225
|
+
next_since_id: sinceId,
|
|
226
|
+
keep_waiting: false,
|
|
227
|
+
next_cmd: null
|
|
228
|
+
});
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const immediate = await waitSnapshot(context, auth.participant.alias, sinceId);
|
|
232
|
+
if (immediate !== null) {
|
|
233
|
+
sendJson(context.res, 200, immediate);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const controller = new AbortController();
|
|
237
|
+
const timer = setTimeout(() => controller.abort(), context.options.waitHoldMs);
|
|
238
|
+
try {
|
|
239
|
+
await context.options.waitHub.wait(context.options.roomId, controller.signal);
|
|
240
|
+
}
|
|
241
|
+
finally {
|
|
242
|
+
clearTimeout(timer);
|
|
243
|
+
}
|
|
244
|
+
const afterWait = await waitSnapshot(context, auth.participant.alias, sinceId);
|
|
245
|
+
if (afterWait !== null) {
|
|
246
|
+
sendJson(context.res, 200, afterWait);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
sendJson(context.res, 200, buildWaitResponse({
|
|
250
|
+
room: context.options.roomId,
|
|
251
|
+
roomStatus: "open",
|
|
252
|
+
participant: auth.participant.alias,
|
|
253
|
+
messages: [],
|
|
254
|
+
sinceId,
|
|
255
|
+
baseUrl: advertisedBaseUrl(context),
|
|
256
|
+
heartbeat: true,
|
|
257
|
+
keepWaiting: true
|
|
258
|
+
}));
|
|
259
|
+
}
|
|
260
|
+
async function postLeave(context) {
|
|
261
|
+
const auth = await requireParticipant(context);
|
|
262
|
+
await upsertParticipant(context.options.root, context.options.roomId, {
|
|
263
|
+
...auth.participant,
|
|
264
|
+
attention: "away",
|
|
265
|
+
lastSeenAt: new Date().toISOString()
|
|
266
|
+
});
|
|
267
|
+
await appendSystem(context, `${auth.participant.alias} left`);
|
|
268
|
+
context.options.waitHub.notify(context.options.roomId);
|
|
269
|
+
sendJson(context.res, 200, { ok: true });
|
|
270
|
+
}
|
|
271
|
+
async function postClose(context) {
|
|
272
|
+
const auth = await requireParticipant(context);
|
|
273
|
+
requireHost(auth.participant);
|
|
274
|
+
const state = await closeRoom(context.options.root, context.options.roomId);
|
|
275
|
+
await appendSystem(context, "room closed");
|
|
276
|
+
context.options.waitHub.notify(context.options.roomId);
|
|
277
|
+
sendJson(context.res, 200, { ok: true, room_status: state.status });
|
|
278
|
+
}
|
|
279
|
+
async function getStatus(context) {
|
|
280
|
+
const auth = await requireParticipant(context);
|
|
281
|
+
const paths = roomPaths(context.options.root, context.options.roomId);
|
|
282
|
+
const [state, participants] = await Promise.all([readRoomState(paths), readParticipants(paths)]);
|
|
283
|
+
const now = Date.now();
|
|
284
|
+
sendJson(context.res, 200, {
|
|
285
|
+
ok: true,
|
|
286
|
+
room: state.id,
|
|
287
|
+
me: auth.participant.alias,
|
|
288
|
+
is_host: auth.participant.is_host,
|
|
289
|
+
room_status: state.status,
|
|
290
|
+
brief_version: state.brief_version,
|
|
291
|
+
attendance_policy: state.attendance_policy,
|
|
292
|
+
stale_after_ms: ATTENDANCE_STALE_AFTER_MS,
|
|
293
|
+
brief_updated_at: state.brief_updated_at,
|
|
294
|
+
brief_updated_by: state.brief_updated_by,
|
|
295
|
+
participants: participants.map((participant) => publicParticipant(participant, state.attendance_policy, now))
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
function publicParticipant(participant, attendancePolicy = "manual-ok", now = Date.now()) {
|
|
299
|
+
const publicFields = { ...participant };
|
|
300
|
+
delete publicFields.token_hash;
|
|
301
|
+
const lastSeenAt = Date.parse(participant.lastSeenAt);
|
|
302
|
+
const lastSeenAgeMs = Number.isFinite(lastSeenAt) ? Math.max(0, now - lastSeenAt) : Number.MAX_SAFE_INTEGER;
|
|
303
|
+
const attendanceRequired = isForegroundRequired(attendancePolicy, participant);
|
|
304
|
+
const foreground = participant.attention === "attending" || participant.attention === "managed";
|
|
305
|
+
const stale = foreground && lastSeenAgeMs > ATTENDANCE_STALE_AFTER_MS;
|
|
306
|
+
return {
|
|
307
|
+
...publicFields,
|
|
308
|
+
attendance_required: attendanceRequired,
|
|
309
|
+
attendance_state: attendanceRequired && !foreground ? "not_attending" : stale ? "stale" : participant.attention,
|
|
310
|
+
last_seen_age_ms: lastSeenAgeMs,
|
|
311
|
+
stale_after_ms: ATTENDANCE_STALE_AFTER_MS
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
function isForegroundRequired(policy, participant) {
|
|
315
|
+
if (participant.removed_at !== undefined || participant.kind === "system")
|
|
316
|
+
return false;
|
|
317
|
+
if (policy === "agents-foreground")
|
|
318
|
+
return participant.kind === "agent";
|
|
319
|
+
if (policy === "all-foreground")
|
|
320
|
+
return true;
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
async function touchParticipant(context, participant, attention) {
|
|
324
|
+
await upsertParticipant(context.options.root, context.options.roomId, {
|
|
325
|
+
...participant,
|
|
326
|
+
attention,
|
|
327
|
+
lastSeenAt: new Date().toISOString()
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
async function requireParticipant(context, options = {}) {
|
|
331
|
+
const token = bearerToken(context.req) ?? (options.allowQueryToken ? context.url.searchParams.get("token") : null);
|
|
332
|
+
if (token === null)
|
|
333
|
+
throw new HttpError(401, "unauthorized", "bearer token is required");
|
|
334
|
+
const paths = roomPaths(context.options.root, context.options.roomId);
|
|
335
|
+
const participants = await readParticipants(paths);
|
|
336
|
+
const participant = participants.find((candidate) => candidate.token_hash !== undefined && verifyToken(token, candidate.token_hash));
|
|
337
|
+
if (participant === undefined || (participant.removed_at !== undefined && !options.allowRemoved)) {
|
|
338
|
+
throw new HttpError(403, "forbidden", "participant token is not allowed");
|
|
339
|
+
}
|
|
340
|
+
return { participant, token };
|
|
341
|
+
}
|
|
342
|
+
async function waitSnapshot(context, participant, sinceId) {
|
|
343
|
+
const state = await closeExpiredRoomIfNeeded(context);
|
|
344
|
+
if (state.status === "closed") {
|
|
345
|
+
return buildWaitResponse({
|
|
346
|
+
room: context.options.roomId,
|
|
347
|
+
roomStatus: "closed",
|
|
348
|
+
participant,
|
|
349
|
+
messages: [],
|
|
350
|
+
sinceId,
|
|
351
|
+
baseUrl: advertisedBaseUrl(context),
|
|
352
|
+
heartbeat: false,
|
|
353
|
+
keepWaiting: false
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
const messages = (await readMessages(context.options.root, context.options.roomId)).filter((message) => message.id > sinceId);
|
|
357
|
+
if (messages.length === 0)
|
|
358
|
+
return null;
|
|
359
|
+
return buildWaitResponse({
|
|
360
|
+
room: context.options.roomId,
|
|
361
|
+
roomStatus: "open",
|
|
362
|
+
participant,
|
|
363
|
+
messages,
|
|
364
|
+
sinceId,
|
|
365
|
+
baseUrl: context.options.baseUrl,
|
|
366
|
+
heartbeat: false,
|
|
367
|
+
keepWaiting: false
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
function requireHost(participant) {
|
|
371
|
+
if (!participant.is_host)
|
|
372
|
+
throw new HttpError(403, "host_required", "host access is required");
|
|
373
|
+
}
|
|
374
|
+
async function requireRoomOpen(context) {
|
|
375
|
+
const state = await closeExpiredRoomIfNeeded(context);
|
|
376
|
+
if (state.status !== "open") {
|
|
377
|
+
throw new HttpError(403, "room_closed", "room is closed");
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
async function closeExpiredRoomIfNeeded(context) {
|
|
381
|
+
const paths = roomPaths(context.options.root, context.options.roomId);
|
|
382
|
+
const state = await readRoomState(paths);
|
|
383
|
+
if (state.status === "open" && state.expires_at !== undefined && Date.now() >= Date.parse(state.expires_at)) {
|
|
384
|
+
const closed = await closeRoom(context.options.root, context.options.roomId);
|
|
385
|
+
await appendSystem(context, "room closed by ttl");
|
|
386
|
+
context.options.waitHub.notify(context.options.roomId);
|
|
387
|
+
return closed;
|
|
388
|
+
}
|
|
389
|
+
return state;
|
|
390
|
+
}
|
|
391
|
+
async function appendSystem(context, text) {
|
|
392
|
+
await appendServerMessage({
|
|
393
|
+
root: context.options.root,
|
|
394
|
+
roomId: context.options.roomId,
|
|
395
|
+
from: "system",
|
|
396
|
+
text
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
async function readJsonBody(context) {
|
|
400
|
+
let size = 0;
|
|
401
|
+
const chunks = [];
|
|
402
|
+
for await (const chunk of context.req) {
|
|
403
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
404
|
+
size += buffer.length;
|
|
405
|
+
if (size > context.options.maxBodyBytes) {
|
|
406
|
+
throw new HttpError(413, "body_too_large", "request body is too large");
|
|
407
|
+
}
|
|
408
|
+
chunks.push(buffer);
|
|
409
|
+
}
|
|
410
|
+
if (chunks.length === 0)
|
|
411
|
+
return {};
|
|
412
|
+
try {
|
|
413
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
throw new HttpError(400, "invalid_json", "request body must be valid JSON");
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
function enforceRateLimit(alias, limit) {
|
|
420
|
+
const now = Date.now();
|
|
421
|
+
const bucket = rateBuckets.get(alias);
|
|
422
|
+
if (bucket === undefined || now >= bucket.resetAt) {
|
|
423
|
+
rateBuckets.set(alias, { resetAt: now + 60_000, count: 1 });
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
bucket.count += 1;
|
|
427
|
+
if (bucket.count > limit)
|
|
428
|
+
throw new HttpError(429, "rate_limited", "rate limit exceeded");
|
|
429
|
+
}
|
|
430
|
+
function enforceLoopGuard(roomId, participant, limit) {
|
|
431
|
+
if (participant.kind === "human") {
|
|
432
|
+
for (const key of loopCounts.keys()) {
|
|
433
|
+
if (key.startsWith(`${roomId}:`))
|
|
434
|
+
loopCounts.delete(key);
|
|
435
|
+
}
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
const key = `${roomId}:${participant.alias}`;
|
|
439
|
+
const next = (loopCounts.get(key) ?? 0) + 1;
|
|
440
|
+
loopCounts.set(key, next);
|
|
441
|
+
if (next > limit)
|
|
442
|
+
throw new HttpError(429, "loop_guard", "loop guard stopped repeated agent messages");
|
|
443
|
+
}
|
|
444
|
+
function enforceExposure(context) {
|
|
445
|
+
if (context.options.allowInsecureRemote)
|
|
446
|
+
return;
|
|
447
|
+
const host = context.req.headers.host ?? "";
|
|
448
|
+
if (isLocalhost(host))
|
|
449
|
+
return;
|
|
450
|
+
throw new HttpError(403, "insecure_remote", "plain HTTP exposure beyond localhost is not allowed");
|
|
451
|
+
}
|
|
452
|
+
function enforceSameOrigin(context) {
|
|
453
|
+
const origin = context.req.headers.origin;
|
|
454
|
+
const referer = context.req.headers.referer;
|
|
455
|
+
const expected = new URL(context.options.baseUrl).origin;
|
|
456
|
+
if (typeof origin === "string" && origin !== expected) {
|
|
457
|
+
throw new HttpError(403, "bad_origin", "origin is not allowed");
|
|
458
|
+
}
|
|
459
|
+
if (typeof referer === "string") {
|
|
460
|
+
try {
|
|
461
|
+
if (new URL(referer).origin !== expected) {
|
|
462
|
+
throw new HttpError(403, "bad_referer", "referer is not allowed");
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
catch {
|
|
466
|
+
throw new HttpError(403, "bad_referer", "referer is not allowed");
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
function isLocalhost(hostHeader) {
|
|
471
|
+
const host = hostHeader.split(":")[0] ?? "";
|
|
472
|
+
return host === "localhost" || host === "127.0.0.1" || host === "[::1]" || host === "::1";
|
|
473
|
+
}
|
|
474
|
+
function isWriteMethod(method) {
|
|
475
|
+
return method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE";
|
|
476
|
+
}
|
|
477
|
+
function bearerToken(req) {
|
|
478
|
+
const authorization = req.headers.authorization;
|
|
479
|
+
if (typeof authorization !== "string")
|
|
480
|
+
return null;
|
|
481
|
+
const match = /^Bearer (.+)$/.exec(authorization);
|
|
482
|
+
return match?.[1] ?? null;
|
|
483
|
+
}
|
|
484
|
+
function parseSinceId(raw) {
|
|
485
|
+
if (raw === null)
|
|
486
|
+
return 0;
|
|
487
|
+
const parsed = Number(raw);
|
|
488
|
+
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
|
489
|
+
throw new HttpError(400, "invalid_since_id", "since_id must be a non-negative integer");
|
|
490
|
+
}
|
|
491
|
+
return parsed;
|
|
492
|
+
}
|
|
493
|
+
function parseDisplayName(value) {
|
|
494
|
+
const trimmed = value.trim().replace(/\s+/g, " ");
|
|
495
|
+
if (trimmed.length === 0 || trimmed.length > 60 || /[\u0000-\u001f\u007f]/.test(trimmed)) {
|
|
496
|
+
throw new HttpError(400, "invalid_display_name", "display name must be 1-60 characters without control characters");
|
|
497
|
+
}
|
|
498
|
+
return trimmed;
|
|
499
|
+
}
|
|
500
|
+
export function renderAttendCard(baseUrl, alias, token, brief, attendancePolicy = "manual-ok") {
|
|
501
|
+
return [
|
|
502
|
+
`# Agent Gather Attend Card: ${alias}`,
|
|
503
|
+
"",
|
|
504
|
+
"## Room Brief",
|
|
505
|
+
brief.body || "(empty)",
|
|
506
|
+
"",
|
|
507
|
+
"## Attendance Policy",
|
|
508
|
+
`Policy: ${attendancePolicy}`,
|
|
509
|
+
describeAttendancePolicy(attendancePolicy),
|
|
510
|
+
"",
|
|
511
|
+
"## Commands",
|
|
512
|
+
`curl -s "${roomUrl(baseUrl, `/card?participant=${alias}&token=${token}`)}"`,
|
|
513
|
+
`curl -s -X POST "${roomUrl(baseUrl, "/join")}" -H "Authorization: Bearer ${token}"`,
|
|
514
|
+
`curl -s "${roomUrl(baseUrl, `/wait?participant=${alias}&since_id=0`)}" -H "Authorization: Bearer ${token}"`,
|
|
515
|
+
`agentgather attend --json`,
|
|
516
|
+
`curl -s "${roomUrl(baseUrl, "/messages?since_id=0")}" -H "Authorization: Bearer ${token}"`,
|
|
517
|
+
`curl -s -X POST "${roomUrl(baseUrl, "/messages")}" -H "Authorization: Bearer ${token}" -H "Content-Type: application/json" --data '{"text":"hello"}'`,
|
|
518
|
+
"",
|
|
519
|
+
"## Attendance Recovery",
|
|
520
|
+
"If you run a tool command or shell script, return to foreground attendance immediately after it finishes:",
|
|
521
|
+
"agentgather attend --json",
|
|
522
|
+
"If a shell command contains pipes, quotes, or `${...}`, ask the host for a script file and run one quote-free command such as `bash /path/to/script.sh`.",
|
|
523
|
+
"If the attend loop stops, Agent Gather v0.1 cannot wake this session automatically; the host will see you as stale until you rejoin or attend again.",
|
|
524
|
+
"",
|
|
525
|
+
renderAgentInstructions()
|
|
526
|
+
].join("\n");
|
|
527
|
+
}
|
|
528
|
+
function sendJson(res, status, value) {
|
|
529
|
+
const body = `${JSON.stringify(value)}\n`;
|
|
530
|
+
res.writeHead(status, {
|
|
531
|
+
"content-type": "application/json; charset=utf-8",
|
|
532
|
+
"content-length": Buffer.byteLength(body)
|
|
533
|
+
});
|
|
534
|
+
res.end(body);
|
|
535
|
+
}
|
|
536
|
+
function sendText(res, status, body, contentType = "text/html; charset=utf-8") {
|
|
537
|
+
res.writeHead(status, {
|
|
538
|
+
"content-type": contentType,
|
|
539
|
+
"content-length": Buffer.byteLength(body)
|
|
540
|
+
});
|
|
541
|
+
res.end(body);
|
|
542
|
+
}
|
|
543
|
+
function sendPlain(res, status, body) {
|
|
544
|
+
res.writeHead(status, {
|
|
545
|
+
"content-type": "text/plain; charset=utf-8",
|
|
546
|
+
"content-length": Buffer.byteLength(body)
|
|
547
|
+
});
|
|
548
|
+
res.end(body);
|
|
549
|
+
}
|
|
550
|
+
function sendError(res, error) {
|
|
551
|
+
const httpError = error instanceof HttpError
|
|
552
|
+
? error
|
|
553
|
+
: error instanceof RoomLogFullError
|
|
554
|
+
? new HttpError(507, "room_log_full", error.message)
|
|
555
|
+
: new HttpError(500, "internal_error", error instanceof Error ? error.message : "internal error");
|
|
556
|
+
sendJson(res, httpError.status, errorBody(httpError.code, httpError.message));
|
|
557
|
+
}
|
|
558
|
+
function resolveOptions(options) {
|
|
559
|
+
assertSafeSlug(options.roomId, "room id");
|
|
560
|
+
const baseUrl = normalizeBaseUrl(options.baseUrl ?? DEFAULT_OPTIONS.baseUrl);
|
|
561
|
+
return {
|
|
562
|
+
...DEFAULT_OPTIONS,
|
|
563
|
+
...options,
|
|
564
|
+
baseUrl,
|
|
565
|
+
publicBaseUrl: options.publicBaseUrl ?? (() => baseUrl)
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
// Public base URL for onboarding cards and /wait.next_cmd. Falls back to the
|
|
569
|
+
// local base URL if the resolver returns an empty or invalid value.
|
|
570
|
+
function advertisedBaseUrl(context) {
|
|
571
|
+
const candidate = context.options.publicBaseUrl();
|
|
572
|
+
if (typeof candidate !== "string" || candidate.length === 0)
|
|
573
|
+
return context.options.baseUrl;
|
|
574
|
+
try {
|
|
575
|
+
return normalizeBaseUrl(candidate);
|
|
576
|
+
}
|
|
577
|
+
catch {
|
|
578
|
+
return context.options.baseUrl;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
export function participantTokenHash(token) {
|
|
582
|
+
return hashToken(token);
|
|
583
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { roomUrl } from "../protocol/index.js";
|
|
2
|
+
export class WaitHub {
|
|
3
|
+
waiters = new Map();
|
|
4
|
+
wait(roomId, signal) {
|
|
5
|
+
return new Promise((resolve) => {
|
|
6
|
+
const waiters = this.waiters.get(roomId) ?? new Set();
|
|
7
|
+
this.waiters.set(roomId, waiters);
|
|
8
|
+
const done = () => {
|
|
9
|
+
signal.removeEventListener("abort", done);
|
|
10
|
+
waiters.delete(done);
|
|
11
|
+
if (waiters.size === 0)
|
|
12
|
+
this.waiters.delete(roomId);
|
|
13
|
+
resolve();
|
|
14
|
+
};
|
|
15
|
+
waiters.add(done);
|
|
16
|
+
signal.addEventListener("abort", done, { once: true });
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
notify(roomId) {
|
|
20
|
+
const waiters = this.waiters.get(roomId);
|
|
21
|
+
if (waiters === undefined)
|
|
22
|
+
return;
|
|
23
|
+
for (const done of [...waiters])
|
|
24
|
+
done();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export const defaultWaitHub = new WaitHub();
|
|
28
|
+
export function buildWaitResponse(options) {
|
|
29
|
+
const nextSinceId = options.messages.at(-1)?.id ?? options.sinceId;
|
|
30
|
+
return {
|
|
31
|
+
ok: true,
|
|
32
|
+
room: options.room,
|
|
33
|
+
room_status: options.roomStatus,
|
|
34
|
+
participant: options.participant,
|
|
35
|
+
heartbeat: options.heartbeat,
|
|
36
|
+
messages: options.messages,
|
|
37
|
+
mentioned: options.messages.some((message) => message.mentions.includes(options.participant)),
|
|
38
|
+
next_since_id: nextSinceId,
|
|
39
|
+
keep_waiting: options.keepWaiting,
|
|
40
|
+
next_cmd: options.keepWaiting
|
|
41
|
+
? `curl -s "${roomUrl(options.baseUrl, `/wait?participant=${options.participant}&since_id=${nextSinceId}`)}" -H "Authorization: Bearer $TOKEN"`
|
|
42
|
+
: null
|
|
43
|
+
};
|
|
44
|
+
}
|