copilot-proxy-web 1.0.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/lib/api.js ADDED
@@ -0,0 +1,564 @@
1
+ const express = require("express");
2
+ const { makePasteSeq } = require("./pty");
3
+ const {
4
+ recordFailure,
5
+ clearFailures,
6
+ isBlocked,
7
+ getRemaining,
8
+ } = require("./auth-rate-limit");
9
+
10
+ function readValue(value) {
11
+ if (typeof value === "function") return value();
12
+ return value;
13
+ }
14
+
15
+ function firstForwardedIp(value) {
16
+ if (typeof value !== "string" || value.length === 0) return "";
17
+ const first = value.split(",")[0].trim();
18
+ return first || "";
19
+ }
20
+
21
+ function getClientIp(req, useXForwardedFor = false) {
22
+ if (useXForwardedFor) {
23
+ const forwarded = firstForwardedIp(req.headers["x-forwarded-for"]);
24
+ if (forwarded) return forwarded;
25
+ }
26
+ return req.socket?.remoteAddress || "";
27
+ }
28
+
29
+ function resolveSession(req, sessionManager) {
30
+ const id =
31
+ req.params?.sessionId ||
32
+ req.query?.sessionId ||
33
+ req.headers["x-session-id"];
34
+ if (id) {
35
+ const session = sessionManager.getSession(String(id));
36
+ if (session) return session;
37
+ }
38
+ return sessionManager.getDefaultSession();
39
+ }
40
+
41
+ function isValidSessionId(value) {
42
+ return typeof value === "string" && /^[A-Za-z0-9._-]+$/.test(value);
43
+ }
44
+
45
+ function createApiRouter({
46
+ sessionManager,
47
+ idleMs,
48
+ webEnabled,
49
+ apiEnabled,
50
+ version,
51
+ user,
52
+ authToken,
53
+ useXForwardedFor = false,
54
+ accessLog,
55
+ accessLogMode = "auth",
56
+ }) {
57
+ const router = express.Router();
58
+
59
+ function shouldLogHttpAccess(req, res) {
60
+ if (accessLogMode === "off") return false;
61
+ const path = req.originalUrl || req.url || "";
62
+ const isApiPath = path.startsWith("/api/");
63
+ if (accessLogMode === "all") return isApiPath;
64
+ if (!isApiPath) return false;
65
+ const authResult = req._authResult || "none";
66
+ if (authResult === "failed" || authResult === "blocked") return true;
67
+ return res.statusCode >= 500;
68
+ }
69
+
70
+ router.use((req, res, next) => {
71
+ const startedAt = Date.now();
72
+ res.on("finish", () => {
73
+ if (!accessLog) return;
74
+ if (!shouldLogHttpAccess(req, res)) return;
75
+ accessLog({
76
+ type: "http",
77
+ ip: getClientIp(req, useXForwardedFor),
78
+ method: req.method,
79
+ path: req.originalUrl || req.url,
80
+ status: res.statusCode,
81
+ durationMs: Date.now() - startedAt,
82
+ auth: req._authResult || "none",
83
+ });
84
+ });
85
+ next();
86
+ });
87
+
88
+ router.use((req, res, next) => {
89
+ const ip = getClientIp(req, useXForwardedFor);
90
+ if (ip) {
91
+ res.setHeader("X-Client-IP", ip);
92
+ }
93
+ if (version) res.setHeader("X-App-Version", String(version));
94
+ if (!authToken) {
95
+ req._authResult = "disabled";
96
+ return next();
97
+ }
98
+ if (isBlocked(ip)) {
99
+ req._authResult = "blocked";
100
+ res.setHeader("X-Auth-Failures-Remaining", "0");
101
+ res.status(429).json({ ok: false, error: "too many auth failures" });
102
+ return;
103
+ }
104
+ const header = req.headers.authorization || "";
105
+ if (!header) {
106
+ req._authResult = "missing";
107
+ res.status(401).json({ ok: false, error: "unauthorized" });
108
+ return;
109
+ }
110
+ if (header === `Bearer ${authToken}`) {
111
+ req._authResult = "ok";
112
+ clearFailures(ip);
113
+ return next();
114
+ }
115
+ req._authResult = "failed";
116
+ recordFailure(ip);
117
+ res.setHeader("X-Auth-Failures-Remaining", String(getRemaining(ip)));
118
+ res.status(401).json({ ok: false, error: "unauthorized" });
119
+ });
120
+
121
+ function focusIn(session) {
122
+ if (session.writeInput) session.writeInput("\u001b[I");
123
+ else session.term.write("\u001b[I");
124
+ }
125
+
126
+ function focusOut(session) {
127
+ if (session.writeInput) session.writeInput("\u001b[O");
128
+ else session.term.write("\u001b[O");
129
+ }
130
+
131
+ function sendPaste(session, text) {
132
+ const seq = makePasteSeq(text);
133
+ if (session.writeInput) session.writeInput(seq);
134
+ else session.term.write(seq);
135
+ }
136
+
137
+ function submitEnter(session) {
138
+ if (session.writeInput) session.writeInput("\r");
139
+ else session.term.write("\r");
140
+ }
141
+
142
+ router.post("/api/sessions", (req, res) => {
143
+ const id = typeof req.body?.id === "string" ? req.body.id : undefined;
144
+ if (id !== undefined && !isValidSessionId(id)) {
145
+ res.status(400).json({ ok: false, error: "invalid session id" });
146
+ return;
147
+ }
148
+ const command = typeof req.body?.command === "string" ? req.body.command : undefined;
149
+ const cmd = typeof req.body?.cmd === "string" ? req.body.cmd : undefined;
150
+ const args = Array.isArray(req.body?.args) ? req.body.args : undefined;
151
+ const autoStart = req.body?.autoStart === true;
152
+ const cwd = typeof req.body?.cwd === "string" ? req.body.cwd : undefined;
153
+ const shellRaw = req.body?.shell;
154
+ const shell = shellRaw === undefined ? undefined : Boolean(shellRaw);
155
+ const shellInteractiveRaw = req.body?.shellInteractive;
156
+ const shellInteractive =
157
+ shellInteractiveRaw === undefined ? undefined : Boolean(shellInteractiveRaw);
158
+ const conversationProfileRaw = req.body?.conversationProfile;
159
+ const conversationProfile =
160
+ typeof conversationProfileRaw === "string" ? conversationProfileRaw : undefined;
161
+ const payload = command
162
+ ? { cmd: command, args: [], shell: true }
163
+ : { cmd, args };
164
+ if (shell !== undefined) payload.shell = shell;
165
+ if (shellInteractive !== undefined) payload.shellInteractive = shellInteractive;
166
+ if (conversationProfile !== undefined) payload.conversationProfile = conversationProfile;
167
+ const session = sessionManager.createSession({ id, cwd, autoStart, ...payload });
168
+ res.json({
169
+ ok: true,
170
+ session: { id: session.id, started: Boolean(session.term) },
171
+ });
172
+ });
173
+
174
+ router.get("/api/sessions", (_req, res) => {
175
+ res.json({ ok: true, sessions: sessionManager.listSessions() });
176
+ });
177
+
178
+ router.delete("/api/sessions/:sessionId", (req, res) => {
179
+ if (!isValidSessionId(req.params.sessionId)) {
180
+ res.status(400).json({ ok: false, error: "invalid session id" });
181
+ return;
182
+ }
183
+ const removed = sessionManager.deleteSession(req.params.sessionId);
184
+ if (!removed) {
185
+ res.status(404).json({ ok: false, error: "session not found" });
186
+ return;
187
+ }
188
+ res.json({ ok: true });
189
+ });
190
+
191
+ router.post("/api/sessions/:sessionId/start", (req, res) => {
192
+ if (!isValidSessionId(req.params.sessionId)) {
193
+ res.status(400).json({ ok: false, error: "invalid session id" });
194
+ return;
195
+ }
196
+ const command = typeof req.body?.command === "string" ? req.body.command : undefined;
197
+ const cmd = typeof req.body?.cmd === "string" ? req.body.cmd : undefined;
198
+ const args = Array.isArray(req.body?.args) ? req.body.args : undefined;
199
+ const cwd = typeof req.body?.cwd === "string" ? req.body.cwd : undefined;
200
+ const shellRaw = req.body?.shell;
201
+ const shell = shellRaw === undefined ? undefined : Boolean(shellRaw);
202
+ const shellInteractiveRaw = req.body?.shellInteractive;
203
+ const shellInteractive =
204
+ shellInteractiveRaw === undefined ? undefined : Boolean(shellInteractiveRaw);
205
+ const conversationProfileRaw = req.body?.conversationProfile;
206
+ const conversationProfile =
207
+ typeof conversationProfileRaw === "string" ? conversationProfileRaw : undefined;
208
+ const payload = command
209
+ ? { cmd: command, args: [], shell: true }
210
+ : { cmd, args, cwd };
211
+ if (shell !== undefined) payload.shell = shell;
212
+ if (shellInteractive !== undefined) payload.shellInteractive = shellInteractive;
213
+ if (conversationProfile !== undefined) payload.conversationProfile = conversationProfile;
214
+ const session = sessionManager.startSession(req.params.sessionId, { cwd, ...payload });
215
+ if (!session) {
216
+ res.status(404).json({ ok: false, error: "session not found" });
217
+ return;
218
+ }
219
+ res.json({ ok: true, session: { id: session.id, started: Boolean(session.term) } });
220
+ });
221
+
222
+ function sendHandler(req, res) {
223
+ const session = resolveSession(req, sessionManager);
224
+ if (!session) {
225
+ res.status(404).json({ ok: false, error: "session not found" });
226
+ return;
227
+ }
228
+ const text = typeof req.body?.text === "string" ? req.body.text : "";
229
+ const submit = req.body?.submit !== false;
230
+ const enterRaw = typeof req.body?.enter === "string" ? req.body.enter : "";
231
+ const enter = enterRaw.length > 0 && enterRaw.length <= 16 ? enterRaw : "";
232
+ if (!text.trim()) {
233
+ res.status(400).json({ ok: false, error: "text is required" });
234
+ return;
235
+ }
236
+ if (!session.term) {
237
+ res.status(409).json({ ok: false, error: "session not started" });
238
+ return;
239
+ }
240
+ if (sessionManager.addConversationEvent) {
241
+ sessionManager.addConversationEvent(session, {
242
+ role: "user",
243
+ markdown: text.trim(),
244
+ ts: new Date().toISOString(),
245
+ meta: { source: "send", format: "text" },
246
+ });
247
+ }
248
+ focusOut(session);
249
+ focusIn(session);
250
+ sendPaste(session, text);
251
+ focusOut(session);
252
+ if (submit) {
253
+ setTimeout(() => {
254
+ if (enter) {
255
+ if (session.writeInput) session.writeInput(enter);
256
+ else session.term.write(enter);
257
+ } else {
258
+ submitEnter(session);
259
+ }
260
+ }, 50);
261
+ }
262
+ res.json({ ok: true });
263
+ }
264
+
265
+ function submitHandler(req, res) {
266
+ const session = resolveSession(req, sessionManager);
267
+ if (!session) {
268
+ res.status(404).json({ ok: false, error: "session not found" });
269
+ return;
270
+ }
271
+ if (!session.term) {
272
+ res.status(409).json({ ok: false, error: "session not started" });
273
+ return;
274
+ }
275
+ focusIn(session);
276
+ submitEnter(session);
277
+ res.json({ ok: true });
278
+ }
279
+
280
+ function keysHandler(req, res) {
281
+ const session = resolveSession(req, sessionManager);
282
+ if (!session) {
283
+ res.status(404).json({ ok: false, error: "session not found" });
284
+ return;
285
+ }
286
+ const data = typeof req.body?.data === "string" ? req.body.data : "";
287
+ if (!data) {
288
+ res.status(400).json({ ok: false, error: "data is required" });
289
+ return;
290
+ }
291
+ if (!session.term) {
292
+ res.status(409).json({ ok: false, error: "session not started" });
293
+ return;
294
+ }
295
+ if (session.writeInput) session.writeInput(data);
296
+ else session.term.write(data);
297
+ res.json({ ok: true });
298
+ }
299
+
300
+ function statusHandler(req, res) {
301
+ const session = resolveSession(req, sessionManager);
302
+ if (!session) {
303
+ const exposeUser = req._authResult === "ok";
304
+ res.status(404).json({
305
+ ok: false,
306
+ error: "session not found",
307
+ version,
308
+ user: exposeUser ? user : undefined,
309
+ });
310
+ return;
311
+ }
312
+ const exposeUser = req._authResult === "ok";
313
+ res.json({
314
+ ok: true,
315
+ sessionId: session.id,
316
+ version,
317
+ user: exposeUser ? user : undefined,
318
+ started: Boolean(session.term),
319
+ cmd: session.cmd,
320
+ args: session.args,
321
+ cwd: session.cwd,
322
+ idleActive: session.idleActive,
323
+ idleMs: readValue(idleMs),
324
+ lastOutputTs: session.lastOutputTs,
325
+ cols: session.term?.cols || session.initialCols,
326
+ rows: session.term?.rows || session.initialRows,
327
+ initialCols: session.initialCols,
328
+ initialRows: session.initialRows,
329
+ webEnabled: readValue(webEnabled),
330
+ apiEnabled: readValue(apiEnabled),
331
+ });
332
+ }
333
+
334
+ function sizeHandler(req, res) {
335
+ const session = resolveSession(req, sessionManager);
336
+ if (!session) {
337
+ res.status(404).json({ ok: false, error: "session not found" });
338
+ return;
339
+ }
340
+ res.json({
341
+ ok: true,
342
+ sessionId: session.id,
343
+ cols: session.term?.cols || session.initialCols,
344
+ rows: session.term?.rows || session.initialRows,
345
+ initialCols: session.initialCols,
346
+ initialRows: session.initialRows,
347
+ });
348
+ }
349
+
350
+ function resizeHandler(req, res) {
351
+ const session = resolveSession(req, sessionManager);
352
+ if (!session) {
353
+ res.status(404).json({ ok: false, error: "session not found" });
354
+ return;
355
+ }
356
+ if (!session.term) {
357
+ res.status(409).json({ ok: false, error: "session not started" });
358
+ return;
359
+ }
360
+ const cols = Number(req.body?.cols);
361
+ const rows = Number(req.body?.rows);
362
+ if (!Number.isFinite(cols) || !Number.isFinite(rows)) {
363
+ res.status(400).json({ ok: false, error: "cols/rows required" });
364
+ return;
365
+ }
366
+ session.term.resize(cols, rows);
367
+ if (session.screen) session.screen.resize(cols, rows);
368
+ res.json({ ok: true, sessionId: session.id, cols, rows });
369
+ }
370
+
371
+ function eventsHandler(req, res) {
372
+ const session = resolveSession(req, sessionManager);
373
+ if (!session) {
374
+ res.status(404).json({ ok: false, error: "session not found" });
375
+ return;
376
+ }
377
+ res.writeHead(200, {
378
+ "Content-Type": "text/event-stream",
379
+ "Cache-Control": "no-cache",
380
+ Connection: "keep-alive",
381
+ });
382
+ res.write("\n");
383
+ session.sseClients.add(res);
384
+ req.on("close", () => {
385
+ session.sseClients.delete(res);
386
+ });
387
+ }
388
+
389
+ function hooksHandler(req, res) {
390
+ const session = resolveSession(req, sessionManager);
391
+ if (!session) {
392
+ res.status(404).json({ ok: false, error: "session not found" });
393
+ return;
394
+ }
395
+ res.json({ ok: true, sessionId: session.id, events: session.hookEvents || [] });
396
+ }
397
+
398
+ function conversationSnapshotHandler(req, res) {
399
+ const session = resolveSession(req, sessionManager);
400
+ if (!session) {
401
+ res.status(404).json({ ok: false, error: "session not found" });
402
+ return;
403
+ }
404
+ res.json({
405
+ ok: true,
406
+ sessionId: session.id,
407
+ events: session.conversation || [],
408
+ lastSeq: session.conversationSeq || 0,
409
+ });
410
+ }
411
+
412
+ function hooksStatusHandler(req, res) {
413
+ const session = resolveSession(req, sessionManager);
414
+ if (!session) {
415
+ res.status(404).json({ ok: false, error: "session not found" });
416
+ return;
417
+ }
418
+ const errors = session.hookErrors || [];
419
+ const last = errors[errors.length - 1] || null;
420
+ res.json({
421
+ ok: true,
422
+ sessionId: session.id,
423
+ errorCount: errors.length,
424
+ lastError: last,
425
+ });
426
+ }
427
+
428
+ function hooksClearHandler(req, res) {
429
+ const session = resolveSession(req, sessionManager);
430
+ if (!session) {
431
+ res.status(404).json({ ok: false, error: "session not found" });
432
+ return;
433
+ }
434
+ session.hookEvents = [];
435
+ session.hookErrors = [];
436
+ res.json({ ok: true, sessionId: session.id });
437
+ }
438
+
439
+ router.post("/api/send", sendHandler);
440
+ router.post("/api/submit", submitHandler);
441
+ router.post("/api/keys", keysHandler);
442
+ router.get("/api/status", statusHandler);
443
+ router.get("/api/size", sizeHandler);
444
+ router.post("/api/resize", resizeHandler);
445
+ router.get("/api/events", eventsHandler);
446
+ router.get("/api/hooks", hooksHandler);
447
+ router.get("/api/hooks/status", hooksStatusHandler);
448
+ router.post("/api/hooks/clear", hooksClearHandler);
449
+
450
+ router.post("/api/sessions/:sessionId/send", (req, res) => {
451
+ if (!isValidSessionId(req.params.sessionId)) {
452
+ res.status(400).json({ ok: false, error: "invalid session id" });
453
+ return;
454
+ }
455
+ req.query.sessionId = req.params.sessionId;
456
+ return sendHandler(req, res);
457
+ });
458
+
459
+ router.post("/api/sessions/:sessionId/submit", (req, res) => {
460
+ if (!isValidSessionId(req.params.sessionId)) {
461
+ res.status(400).json({ ok: false, error: "invalid session id" });
462
+ return;
463
+ }
464
+ req.query.sessionId = req.params.sessionId;
465
+ return submitHandler(req, res);
466
+ });
467
+
468
+ router.post("/api/sessions/:sessionId/keys", (req, res) => {
469
+ if (!isValidSessionId(req.params.sessionId)) {
470
+ res.status(400).json({ ok: false, error: "invalid session id" });
471
+ return;
472
+ }
473
+ req.query.sessionId = req.params.sessionId;
474
+ return keysHandler(req, res);
475
+ });
476
+
477
+ router.get("/api/sessions/:sessionId/status", (req, res) => {
478
+ if (!isValidSessionId(req.params.sessionId)) {
479
+ res.status(400).json({ ok: false, error: "invalid session id" });
480
+ return;
481
+ }
482
+ req.query.sessionId = req.params.sessionId;
483
+ return statusHandler(req, res);
484
+ });
485
+
486
+ router.get("/api/sessions/:sessionId/size", (req, res) => {
487
+ if (!isValidSessionId(req.params.sessionId)) {
488
+ res.status(400).json({ ok: false, error: "invalid session id" });
489
+ return;
490
+ }
491
+ req.query.sessionId = req.params.sessionId;
492
+ return sizeHandler(req, res);
493
+ });
494
+
495
+ router.get("/api/conversation", conversationSnapshotHandler);
496
+ router.get("/api/sessions/:sessionId/conversation", (req, res) => {
497
+ if (!isValidSessionId(req.params.sessionId)) {
498
+ res.status(400).json({ ok: false, error: "invalid session id" });
499
+ return;
500
+ }
501
+ req.query.sessionId = req.params.sessionId;
502
+ return conversationSnapshotHandler(req, res);
503
+ });
504
+
505
+ router.post("/api/sessions/:sessionId/resize", (req, res) => {
506
+ if (!isValidSessionId(req.params.sessionId)) {
507
+ res.status(400).json({ ok: false, error: "invalid session id" });
508
+ return;
509
+ }
510
+ req.query.sessionId = req.params.sessionId;
511
+ return resizeHandler(req, res);
512
+ });
513
+
514
+ router.get("/api/sessions/:sessionId/events", (req, res) => {
515
+ if (!isValidSessionId(req.params.sessionId)) {
516
+ res.status(400).json({ ok: false, error: "invalid session id" });
517
+ return;
518
+ }
519
+ req.query.sessionId = req.params.sessionId;
520
+ return eventsHandler(req, res);
521
+ });
522
+
523
+
524
+ router.get("/api/sessions/:sessionId/hooks", (req, res) => {
525
+ if (!isValidSessionId(req.params.sessionId)) {
526
+ res.status(400).json({ ok: false, error: "invalid session id" });
527
+ return;
528
+ }
529
+ req.query.sessionId = req.params.sessionId;
530
+ return hooksHandler(req, res);
531
+ });
532
+
533
+ router.get("/api/sessions/:sessionId/hooks/status", (req, res) => {
534
+ if (!isValidSessionId(req.params.sessionId)) {
535
+ res.status(400).json({ ok: false, error: "invalid session id" });
536
+ return;
537
+ }
538
+ req.query.sessionId = req.params.sessionId;
539
+ return hooksStatusHandler(req, res);
540
+ });
541
+
542
+ router.post("/api/sessions/:sessionId/hooks/clear", (req, res) => {
543
+ if (!isValidSessionId(req.params.sessionId)) {
544
+ res.status(400).json({ ok: false, error: "invalid session id" });
545
+ return;
546
+ }
547
+ req.query.sessionId = req.params.sessionId;
548
+ return hooksClearHandler(req, res);
549
+ });
550
+
551
+ return router;
552
+ }
553
+
554
+ function createApiApp(options) {
555
+ const app = express();
556
+ app.use(express.json({ limit: "1mb" }));
557
+ app.use(createApiRouter(options));
558
+ return app;
559
+ }
560
+
561
+ module.exports = {
562
+ createApiRouter,
563
+ createApiApp,
564
+ };
@@ -0,0 +1,59 @@
1
+ const WINDOW_MS = 10 * 60 * 1000;
2
+ const MAX_FAILS = 100;
3
+
4
+ const entries = new Map();
5
+
6
+ function getEntry(key) {
7
+ if (!entries.has(key)) {
8
+ entries.set(key, {
9
+ firstTs: Date.now(),
10
+ count: 0,
11
+ blocked: false,
12
+ });
13
+ }
14
+ return entries.get(key);
15
+ }
16
+
17
+ function recordFailure(key) {
18
+ if (!key) return;
19
+ const entry = getEntry(key);
20
+ if (entry.blocked) return;
21
+ const now = Date.now();
22
+ if (now - entry.firstTs > WINDOW_MS) {
23
+ entry.firstTs = now;
24
+ entry.count = 1;
25
+ } else {
26
+ entry.count += 1;
27
+ }
28
+ if (entry.count >= MAX_FAILS) {
29
+ entry.blocked = true;
30
+ }
31
+ }
32
+
33
+ function clearFailures(key) {
34
+ if (!key) return;
35
+ entries.delete(key);
36
+ }
37
+
38
+ function isBlocked(key) {
39
+ if (!key) return false;
40
+ const entry = entries.get(key);
41
+ return Boolean(entry && entry.blocked);
42
+ }
43
+
44
+ function getRemaining(key, now = Date.now()) {
45
+ if (!key) return MAX_FAILS;
46
+ const entry = entries.get(key);
47
+ if (!entry) return MAX_FAILS;
48
+ if (entry.blocked) return 0;
49
+ if (now - entry.firstTs > WINDOW_MS) return MAX_FAILS;
50
+ const remaining = MAX_FAILS - entry.count;
51
+ return remaining > 0 ? remaining : 0;
52
+ }
53
+
54
+ module.exports = {
55
+ recordFailure,
56
+ clearFailures,
57
+ isBlocked,
58
+ getRemaining,
59
+ };