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.
@@ -0,0 +1,302 @@
1
+ class TerminalBuffer {
2
+ constructor(cols = 80, rows = 24) {
3
+ this.cols = cols;
4
+ this.rows = rows;
5
+ this.scrollback = [];
6
+ this.buffer = this._makeBuffer(rows, cols);
7
+ this.cursorRow = 0;
8
+ this.cursorCol = 0;
9
+ this.dirtyRows = new Set();
10
+ this.state = "text";
11
+ this.csi = "";
12
+ this.osc = "";
13
+ this.oscEsc = false;
14
+ }
15
+
16
+ _makeBuffer(rows, cols) {
17
+ const buf = [];
18
+ for (let r = 0; r < rows; r += 1) {
19
+ const line = new Array(cols);
20
+ line.fill(" ");
21
+ buf.push(line);
22
+ }
23
+ return buf;
24
+ }
25
+
26
+ _blankLine() {
27
+ const line = new Array(this.cols);
28
+ line.fill(" ");
29
+ return line;
30
+ }
31
+
32
+ _rtrim(str) {
33
+ return str.replace(/\s+$/, "");
34
+ }
35
+
36
+ resize(cols, rows) {
37
+ const nextCols = Math.max(1, cols || this.cols);
38
+ const nextRows = Math.max(1, rows || this.rows);
39
+ if (nextCols !== this.cols) {
40
+ for (const line of this.buffer) {
41
+ if (line.length < nextCols) {
42
+ line.push(...new Array(nextCols - line.length).fill(" "));
43
+ } else if (line.length > nextCols) {
44
+ line.length = nextCols;
45
+ }
46
+ }
47
+ this.cols = nextCols;
48
+ if (this.cursorCol >= this.cols) this.cursorCol = this.cols - 1;
49
+ }
50
+ if (nextRows !== this.rows) {
51
+ if (nextRows < this.rows) {
52
+ const drop = this.rows - nextRows;
53
+ for (let i = 0; i < drop; i += 1) {
54
+ this.scrollback.push(this._rtrim(this.buffer[i].join("")));
55
+ }
56
+ this.buffer = this.buffer.slice(drop);
57
+ this.cursorRow = Math.max(0, this.cursorRow - drop);
58
+ } else {
59
+ for (let i = 0; i < nextRows - this.rows; i += 1) {
60
+ this.buffer.push(this._blankLine());
61
+ }
62
+ }
63
+ this.rows = nextRows;
64
+ if (this.cursorRow >= this.rows) this.cursorRow = this.rows - 1;
65
+ }
66
+ for (let r = 0; r < this.rows; r += 1) {
67
+ this.dirtyRows.add(r);
68
+ }
69
+ }
70
+
71
+ _scrollUp(lines = 1) {
72
+ for (let i = 0; i < lines; i += 1) {
73
+ const removed = this.buffer.shift();
74
+ if (removed) this.scrollback.push(this._rtrim(removed.join("")));
75
+ this.buffer.push(this._blankLine());
76
+ }
77
+ for (let r = 0; r < this.rows; r += 1) {
78
+ this.dirtyRows.add(r);
79
+ }
80
+ }
81
+
82
+ _newline() {
83
+ this.cursorRow += 1;
84
+ if (this.cursorRow >= this.rows) {
85
+ this._scrollUp(1);
86
+ this.cursorRow = this.rows - 1;
87
+ }
88
+ }
89
+
90
+ _putChar(ch) {
91
+ if (this.cursorCol >= this.cols) {
92
+ this._newline();
93
+ this.cursorCol = 0;
94
+ }
95
+ this.buffer[this.cursorRow][this.cursorCol] = ch;
96
+ this.dirtyRows.add(this.cursorRow);
97
+ this.cursorCol += 1;
98
+ if (this.cursorCol > this.cols) {
99
+ this.cursorCol = this.cols;
100
+ }
101
+ }
102
+
103
+ _eraseInLine(mode = 0) {
104
+ const line = this.buffer[this.cursorRow];
105
+ if (!line) return;
106
+ if (mode === 2) {
107
+ line.fill(" ");
108
+ this.dirtyRows.add(this.cursorRow);
109
+ return;
110
+ }
111
+ if (mode === 1) {
112
+ for (let i = 0; i <= this.cursorCol; i += 1) line[i] = " ";
113
+ this.dirtyRows.add(this.cursorRow);
114
+ return;
115
+ }
116
+ for (let i = this.cursorCol; i < this.cols; i += 1) line[i] = " ";
117
+ this.dirtyRows.add(this.cursorRow);
118
+ }
119
+
120
+ _eraseInDisplay(mode = 0) {
121
+ if (mode === 2) {
122
+ for (let r = 0; r < this.rows; r += 1) {
123
+ this.buffer[r].fill(" ");
124
+ this.dirtyRows.add(r);
125
+ }
126
+ return;
127
+ }
128
+ if (mode === 1) {
129
+ for (let r = 0; r < this.cursorRow; r += 1) {
130
+ this.buffer[r].fill(" ");
131
+ this.dirtyRows.add(r);
132
+ }
133
+ this._eraseInLine(1);
134
+ return;
135
+ }
136
+ this._eraseInLine(0);
137
+ for (let r = this.cursorRow + 1; r < this.rows; r += 1) {
138
+ this.buffer[r].fill(" ");
139
+ this.dirtyRows.add(r);
140
+ }
141
+ }
142
+
143
+ _handleCsi(sequence) {
144
+ const code = sequence.slice(-1);
145
+ const params = sequence.slice(0, -1).replace(/^\?/, "");
146
+ const parts = params.length ? params.split(";") : [];
147
+ const nums = parts.map((p) => (p === "" ? NaN : Number(p)));
148
+ const n1 = Number.isFinite(nums[0]) ? nums[0] : 1;
149
+ const n2 = Number.isFinite(nums[1]) ? nums[1] : 1;
150
+ switch (code) {
151
+ case "A":
152
+ this.cursorRow = Math.max(0, this.cursorRow - n1);
153
+ break;
154
+ case "B":
155
+ this.cursorRow = Math.min(this.rows - 1, this.cursorRow + n1);
156
+ break;
157
+ case "C":
158
+ this.cursorCol = Math.min(this.cols - 1, this.cursorCol + n1);
159
+ break;
160
+ case "D":
161
+ this.cursorCol = Math.max(0, this.cursorCol - n1);
162
+ break;
163
+ case "G":
164
+ this.cursorCol = Math.min(this.cols - 1, Math.max(0, n1 - 1));
165
+ break;
166
+ case "H":
167
+ case "f":
168
+ this.cursorRow = Math.min(this.rows - 1, Math.max(0, n1 - 1));
169
+ this.cursorCol = Math.min(this.cols - 1, Math.max(0, n2 - 1));
170
+ break;
171
+ case "J":
172
+ this._eraseInDisplay(Number.isFinite(nums[0]) ? nums[0] : 0);
173
+ break;
174
+ case "K":
175
+ this._eraseInLine(Number.isFinite(nums[0]) ? nums[0] : 0);
176
+ break;
177
+ case "m":
178
+ default:
179
+ break;
180
+ }
181
+ }
182
+
183
+ write(data = "") {
184
+ for (const ch of String(data)) {
185
+ if (this.state === "text") {
186
+ if (ch === "\u001b") {
187
+ this.state = "escape";
188
+ continue;
189
+ }
190
+ if (ch === "\r") {
191
+ this.cursorCol = 0;
192
+ continue;
193
+ }
194
+ if (ch === "\n") {
195
+ this._newline();
196
+ continue;
197
+ }
198
+ if (ch === "\b") {
199
+ this.cursorCol = Math.max(0, this.cursorCol - 1);
200
+ continue;
201
+ }
202
+ if (ch === "\t") {
203
+ const nextTab = Math.floor(this.cursorCol / 8 + 1) * 8;
204
+ this.cursorCol = Math.min(this.cols - 1, nextTab);
205
+ continue;
206
+ }
207
+ if (ch < " ") {
208
+ continue;
209
+ }
210
+ this._putChar(ch);
211
+ continue;
212
+ }
213
+
214
+ if (this.state === "escape") {
215
+ if (ch === "[") {
216
+ this.state = "csi";
217
+ this.csi = "";
218
+ continue;
219
+ }
220
+ if (ch === "]") {
221
+ this.state = "osc";
222
+ this.osc = "";
223
+ this.oscEsc = false;
224
+ continue;
225
+ }
226
+ if (ch === "E") {
227
+ this.cursorCol = 0;
228
+ this._newline();
229
+ } else if (ch === "D") {
230
+ this._newline();
231
+ } else if (ch === "M") {
232
+ this.cursorRow = Math.max(0, this.cursorRow - 1);
233
+ }
234
+ this.state = "text";
235
+ continue;
236
+ }
237
+
238
+ if (this.state === "csi") {
239
+ this.csi += ch;
240
+ if (/[A-Za-z~]$/.test(ch)) {
241
+ this._handleCsi(this.csi);
242
+ this.csi = "";
243
+ this.state = "text";
244
+ }
245
+ continue;
246
+ }
247
+
248
+ if (this.state === "osc") {
249
+ if (this.oscEsc) {
250
+ if (ch === "\\") {
251
+ this.state = "text";
252
+ this.oscEsc = false;
253
+ continue;
254
+ }
255
+ this.oscEsc = false;
256
+ }
257
+ if (ch === "\u0007") {
258
+ this.state = "text";
259
+ continue;
260
+ }
261
+ if (ch === "\u001b") {
262
+ this.oscEsc = true;
263
+ continue;
264
+ }
265
+ this.osc += ch;
266
+ }
267
+ }
268
+ }
269
+
270
+ getLines({ includeScrollback = true } = {}) {
271
+ const lines = [];
272
+ if (includeScrollback && this.scrollback.length) {
273
+ lines.push(...this.scrollback);
274
+ }
275
+ for (const row of this.buffer) {
276
+ lines.push(this._rtrim(row.join("")));
277
+ }
278
+ return lines;
279
+ }
280
+
281
+ getRow(row) {
282
+ const line = this.buffer[row];
283
+ if (!line) return "";
284
+ return this._rtrim(line.join(""));
285
+ }
286
+
287
+ consumeDirtyLines() {
288
+ if (!this.dirtyRows.size) return [];
289
+ const rows = Array.from(this.dirtyRows).sort((a, b) => a - b);
290
+ this.dirtyRows.clear();
291
+ const out = [];
292
+ for (const r of rows) {
293
+ const line = this.buffer[r];
294
+ if (line) out.push({ row: r, text: this._rtrim(line.join("")) });
295
+ }
296
+ return out;
297
+ }
298
+ }
299
+
300
+ module.exports = {
301
+ TerminalBuffer,
302
+ };
package/lib/ws.js ADDED
@@ -0,0 +1,256 @@
1
+ const WebSocket = require("ws");
2
+ const { normalizeText } = require("./pty");
3
+ const {
4
+ recordFailure,
5
+ clearFailures,
6
+ isBlocked,
7
+ } = require("./auth-rate-limit");
8
+
9
+ function parseSessionId(request) {
10
+ const url = request.url || "";
11
+ const idx = url.indexOf("?");
12
+ if (idx === -1) return null;
13
+ const params = new URLSearchParams(url.slice(idx + 1));
14
+ return params.get("sessionId");
15
+ }
16
+
17
+ function decodeAuthProtocol(protocol) {
18
+ if (!protocol || !protocol.startsWith("auth.")) return null;
19
+ const enc = protocol.slice("auth.".length);
20
+ try {
21
+ return Buffer.from(enc, "base64url").toString("utf8");
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ function extractProtocols(header) {
28
+ if (!header) return [];
29
+ return header
30
+ .split(",")
31
+ .map((p) => p.trim())
32
+ .filter(Boolean);
33
+ }
34
+
35
+ function firstForwardedIp(value) {
36
+ if (typeof value !== "string" || value.length === 0) return "";
37
+ const first = value.split(",")[0].trim();
38
+ return first || "";
39
+ }
40
+
41
+ function getClientIp(request, useXForwardedFor = false) {
42
+ if (useXForwardedFor) {
43
+ const forwarded = firstForwardedIp(request.headers?.["x-forwarded-for"]);
44
+ if (forwarded) return forwarded;
45
+ }
46
+ return request.socket?.remoteAddress || "";
47
+ }
48
+
49
+ function attachWsServer({
50
+ server,
51
+ sessionManager,
52
+ debugWs,
53
+ debugStream,
54
+ authToken,
55
+ useXForwardedFor = false,
56
+ accessLog,
57
+ accessLogMode = "auth",
58
+ }) {
59
+ function shouldLogWs(event) {
60
+ if (accessLogMode === "off") return false;
61
+ if (accessLogMode === "all") return true;
62
+ if (event.type === "ws_upgrade") {
63
+ return event.status === "blocked" || event.status === "rejected";
64
+ }
65
+ return false;
66
+ }
67
+
68
+ function writeWsAccess(event) {
69
+ if (!accessLog) return;
70
+ if (!shouldLogWs(event)) return;
71
+ accessLog(event);
72
+ }
73
+
74
+ const wss = new WebSocket.Server({
75
+ noServer: true,
76
+ handleProtocols: authToken
77
+ ? (protocols) => {
78
+ for (const protocol of protocols) {
79
+ const token = decodeAuthProtocol(protocol);
80
+ if (token && token === authToken) return protocol;
81
+ }
82
+ return false;
83
+ }
84
+ : undefined,
85
+ });
86
+
87
+ server.on("upgrade", (request, socket, head) => {
88
+ if (request.url && request.url.startsWith("/ws")) {
89
+ const ip = getClientIp(request, useXForwardedFor);
90
+ if (authToken) {
91
+ if (isBlocked(ip)) {
92
+ writeWsAccess({
93
+ type: "ws_upgrade",
94
+ ip,
95
+ path: request.url,
96
+ status: "blocked",
97
+ auth: "blocked",
98
+ });
99
+ socket.destroy();
100
+ return;
101
+ }
102
+ const offered = extractProtocols(request.headers["sec-websocket-protocol"]);
103
+ const ok = offered.some((p) => decodeAuthProtocol(p) === authToken);
104
+ if (!ok) {
105
+ recordFailure(ip);
106
+ writeWsAccess({
107
+ type: "ws_upgrade",
108
+ ip,
109
+ path: request.url,
110
+ status: "rejected",
111
+ auth: "failed",
112
+ });
113
+ socket.destroy();
114
+ return;
115
+ }
116
+ clearFailures(ip);
117
+ }
118
+ writeWsAccess({
119
+ type: "ws_upgrade",
120
+ ip,
121
+ path: request.url,
122
+ status: "accepted",
123
+ auth: authToken ? "ok" : "disabled",
124
+ });
125
+ wss.handleUpgrade(request, socket, head, (ws) => {
126
+ wss.emit("connection", ws, request);
127
+ });
128
+ } else {
129
+ socket.destroy();
130
+ }
131
+ });
132
+
133
+ wss.on("connection", (ws, request) => {
134
+ const ip = getClientIp(request, useXForwardedFor);
135
+ const sessionId = parseSessionId(request);
136
+ const session =
137
+ (sessionId && sessionManager.getSession(sessionId)) ||
138
+ sessionManager.getDefaultSession();
139
+ if (!session || !session.term) {
140
+ writeWsAccess({
141
+ type: "ws_connection",
142
+ ip,
143
+ path: request.url || "/ws",
144
+ status: "closed",
145
+ reason: "session_not_found",
146
+ });
147
+ ws.close();
148
+ return;
149
+ }
150
+ const { clients, history } = session;
151
+ clients.add(ws);
152
+ writeWsAccess({
153
+ type: "ws_connection",
154
+ ip,
155
+ path: request.url || "/ws",
156
+ status: "open",
157
+ sessionId: session.id,
158
+ });
159
+ if (history.length > 0) {
160
+ ws.send(history.join(""));
161
+ }
162
+ ws.on("message", (message, isBinary) => {
163
+ if (!session.term) return;
164
+ if (isBinary) {
165
+ const buf = Buffer.from(message);
166
+ const type = buf[0];
167
+ const payload = buf.subarray(1);
168
+ const data = payload.toString();
169
+ if (type === 1) {
170
+ if (debugWs) {
171
+ debugStream.write(
172
+ `[WS] keys(bin) ${data.length} chars hex=${payload.toString("hex")}\n`
173
+ );
174
+ }
175
+ if (session.writeInput) session.writeInput(data);
176
+ else session.term.write(data);
177
+ return;
178
+ }
179
+ if (type === 2) {
180
+ if (!data.trim()) return;
181
+ const normalized = normalizeText(data).replace(/\n/g, "\r") + "\r";
182
+ if (debugWs) {
183
+ debugStream.write(
184
+ `[WS] text(bin) ${data.length} chars hex=${payload.toString("hex")}\n`
185
+ );
186
+ }
187
+ if (session.writeInput) session.writeInput(normalized);
188
+ else session.term.write(normalized);
189
+ return;
190
+ }
191
+ }
192
+
193
+ const raw = message.toString();
194
+ let handled = false;
195
+ try {
196
+ const payload = JSON.parse(raw);
197
+ if (payload && payload.type === "resize") {
198
+ const cols = Number(payload.cols);
199
+ const rows = Number(payload.rows);
200
+ if (Number.isFinite(cols) && Number.isFinite(rows)) {
201
+ session.term.resize(cols, rows);
202
+ if (session.screen) session.screen.resize(cols, rows);
203
+ }
204
+ handled = true;
205
+ }
206
+ if (payload && payload.type === "keys" && typeof payload.data === "string") {
207
+ if (debugWs) {
208
+ debugStream.write(`[WS] keys ${payload.data.length} chars\n`);
209
+ }
210
+ if (session.writeInput) session.writeInput(payload.data);
211
+ else session.term.write(payload.data);
212
+ handled = true;
213
+ }
214
+ if (payload && payload.type === "text" && typeof payload.data === "string") {
215
+ const text = payload.data;
216
+ if (!text.trim()) return;
217
+ const normalized = normalizeText(text).replace(/\n/g, "\r") + "\r";
218
+ if (debugWs) {
219
+ debugStream.write(`[WS] text ${text.length} chars\n`);
220
+ }
221
+ if (session.writeInput) session.writeInput(normalized);
222
+ else session.term.write(normalized);
223
+ handled = true;
224
+ }
225
+ } catch {
226
+ // fall back to plain text
227
+ }
228
+ if (!handled) {
229
+ const text = raw;
230
+ if (!text.trim()) return;
231
+ const normalized = normalizeText(text).replace(/\n/g, "\r") + "\r";
232
+ if (debugWs) {
233
+ debugStream.write(`[WS] plain ${text.length} chars\n`);
234
+ }
235
+ if (session.writeInput) session.writeInput(normalized);
236
+ else session.term.write(normalized);
237
+ }
238
+ });
239
+ ws.on("close", () => {
240
+ clients.delete(ws);
241
+ writeWsAccess({
242
+ type: "ws_connection",
243
+ ip,
244
+ path: request.url || "/ws",
245
+ status: "close",
246
+ sessionId: session.id,
247
+ });
248
+ });
249
+ });
250
+
251
+ return wss;
252
+ }
253
+
254
+ module.exports = {
255
+ attachWsServer,
256
+ };
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "copilot-proxy-web",
3
+ "version": "1.0.0",
4
+ "description": "Proxy GitHub Copilot CLI through a PTY with a Web UI, WebSocket streaming, and an HTTP API.",
5
+ "main": "copilot-proxy.js",
6
+ "bin": {
7
+ "copilot-proxy-web": "bin/run-web.js"
8
+ },
9
+ "scripts": {
10
+ "copilot-proxy-web": "node bin/run-web.js",
11
+ "clean:appledouble": "find . -name '._*' -type f -not -path './.git/*' -delete",
12
+ "test": "node --test test/*.test.js",
13
+ "test:ui": "npm run clean:appledouble && playwright test",
14
+ "ws": "node bin/wss-client.js"
15
+ },
16
+ "files": [
17
+ "bin/",
18
+ "lib/",
19
+ "public/",
20
+ "copilot-proxy.js",
21
+ "README.md",
22
+ "README.arch.md",
23
+ "CHANGELOG.md",
24
+ "LICENSE"
25
+ ],
26
+ "keywords": [
27
+ "copilot",
28
+ "proxy",
29
+ "pty",
30
+ "websocket",
31
+ "cli",
32
+ "web"
33
+ ],
34
+ "author": "changyy",
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "engines": {
39
+ "node": ">=18"
40
+ },
41
+ "license": "MIT",
42
+ "dependencies": {
43
+ "express": "^5.2.1",
44
+ "https-proxy-agent": "^7.0.6",
45
+ "node-pty": "^1.1.0",
46
+ "ws": "^8.19.0"
47
+ },
48
+ "devDependencies": {
49
+ "@playwright/test": "^1.50.0"
50
+ }
51
+ }