-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Expand file tree
/
Copy pathCapiProxy.ts
More file actions
169 lines (152 loc) · 5.91 KB
/
Copy pathCapiProxy.ts
File metadata and controls
169 lines (152 loc) · 5.91 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
import { spawn } from "child_process";
import { resolve } from "path";
import { createInterface } from "readline";
import { expect } from "vitest";
import {
CopilotUserResponse,
ParsedHttpExchange,
} from "../../../../test/harness/replayingCapiProxy";
import { isCI } from "./sdkTestContext";
const HARNESS_SERVER_PATH = resolve(__dirname, "../../../../test/harness/server.ts");
const NO_PROXY = "127.0.0.1,localhost,::1";
interface ProxyStartupInfo {
capiProxyUrl: string;
connectProxyUrl?: string;
caFilePath?: string;
}
// Manages a child process that acts as a replaying proxy to the underlying AI endpoints
export class CapiProxy {
private proxyUrl: string | undefined;
private startupInfo: ProxyStartupInfo | undefined;
/**
* Returns the URL of the running proxy. Throws if the proxy has not been started.
*/
get url(): string {
if (!this.proxyUrl) {
throw new Error("CapiProxy has not been started; call start() first.");
}
return this.proxyUrl;
}
async start(): Promise<string> {
const serverProcess = spawn("npx", ["tsx", HARNESS_SERVER_PATH], {
stdio: ["ignore", "pipe", "inherit"],
shell: true,
});
this.startupInfo = await new Promise<ProxyStartupInfo>((resolve, reject) => {
const stdout = serverProcess.stdout!;
const lines: string[] = [];
const lineReader = createInterface({ input: stdout });
const cleanup = () => {
lineReader.off("line", onLine);
serverProcess.off("exit", onExit);
lineReader.close();
};
const onLine = (line: string) => {
lines.push(line);
try {
const info = tryParseStartupInfo(line);
if (!info) {
return;
}
cleanup();
resolve(info);
} catch (error) {
cleanup();
reject(error);
}
};
const onExit = (code: number | null) => {
cleanup();
reject(
new Error(`Proxy exited before startup with code ${code}: ${lines.join("\n")}`)
);
};
lineReader.on("line", onLine);
serverProcess.once("exit", onExit);
});
this.proxyUrl = this.startupInfo.capiProxyUrl;
return this.proxyUrl;
}
getProxyEnv(): Record<string, string> {
if (!this.startupInfo?.connectProxyUrl || !this.startupInfo.caFilePath) {
return {};
}
return {
HTTP_PROXY: this.startupInfo.connectProxyUrl,
HTTPS_PROXY: this.startupInfo.connectProxyUrl,
http_proxy: this.startupInfo.connectProxyUrl,
https_proxy: this.startupInfo.connectProxyUrl,
NO_PROXY,
no_proxy: NO_PROXY,
NODE_EXTRA_CA_CERTS: this.startupInfo.caFilePath,
SSL_CERT_FILE: this.startupInfo.caFilePath,
REQUESTS_CA_BUNDLE: this.startupInfo.caFilePath,
CURL_CA_BUNDLE: this.startupInfo.caFilePath,
GIT_SSL_CAINFO: this.startupInfo.caFilePath,
GH_TOKEN: "",
GH_ENTERPRISE_TOKEN: "",
GITHUB_ENTERPRISE_TOKEN: "",
// In CI we never want it to make real network requests, so there should be no need for auth
// But when running locally you have to be able to generate snapshots and that does require real auth,
// so you should set GH_TOKEN and we need to pass it through into the test app.
...(isCI ? { GITHUB_TOKEN: "" } : undefined),
};
}
async updateConfig(config: {
filePath: string;
workDir: string;
testInfo?: { file: string; line?: number };
}): Promise<void> {
const response = await fetch(`${this.proxyUrl}/config`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(config),
});
expect(response.ok).toBe(true);
}
async getExchanges(): Promise<ParsedHttpExchange[]> {
const response = await fetch(`${this.proxyUrl}/exchanges`, { method: "GET" });
return await response.json();
}
async stop(skipWritingCache?: boolean): Promise<void> {
const url = skipWritingCache
? `${this.proxyUrl}/stop?skipWritingCache=true`
: `${this.proxyUrl}/stop`;
const response = await fetch(url, { method: "POST" });
expect(response.ok).toBe(true);
}
/**
* Register a per-token response for the `/copilot_internal/user` endpoint.
* When a request with `Authorization: Bearer <token>` arrives at the proxy,
* the matching response is returned.
*/
async setCopilotUserByToken(token: string, response: CopilotUserResponse): Promise<void> {
const res = await fetch(`${this.proxyUrl}/copilot-user-config`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ token, response }),
});
expect(res.ok).toBe(true);
}
}
function tryParseStartupInfo(line: string): ProxyStartupInfo | undefined {
if (!line) {
return undefined;
}
const match = line.match(/Listening: (http:\/\/[^\s]+)\s+(\{.*\})$/);
if (!match) {
if (!line.includes("Listening: ")) {
return undefined;
}
throw new Error(`Unexpected proxy output: ${line}`);
}
const metadata = JSON.parse(match[2]) as Partial<ProxyStartupInfo>;
if (!metadata.connectProxyUrl || !metadata.caFilePath) {
throw new Error(`Proxy startup metadata missing CONNECT proxy details: ${line}`);
}
return {
capiProxyUrl: match[1],
connectProxyUrl: metadata.connectProxyUrl,
caFilePath: metadata.caFilePath,
};
}