Repository navigation
Expand file tree
/
Copy pathextension_env_access.e2e.test.ts
More file actions
226 lines (203 loc) · 9.3 KB
/
Copy pathextension_env_access.e2e.test.ts
File metadata and controls
226 lines (203 loc) · 9.3 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
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------------------------------------------*/
import { execFileSync, spawn } from "node:child_process";
import { existsSync, mkdtempSync, readFileSync } from "node:fs";
import { copyFile, mkdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { expect, it } from "vitest";
import {
createMessageConnection,
StreamMessageReader,
StreamMessageWriter,
} from "vscode-jsonrpc/node.js";
import { approveAll, RuntimeConnection } from "../../src/index.js";
import { getSdkProtocolVersion } from "../../src/sdkProtocolVersion.js";
import { createSdkTestContext, getLegacyCliPathForTests } from "./harness/sdkTestContext.js";
import { retry, stopChildProcess } from "./harness/sdkTestHelper.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
const FIXTURE = join(__dirname, "fixtures", "env-access-extension.mjs");
const DIST_DIR = resolve(__dirname, "..", "..", "dist");
interface ExtensionRun {
resumeParams: Record<string, unknown>;
/** Sample taken before the join, so an inherited value is distinguishable from a granted one. */
prejoin: string;
postjoin: string;
result: string;
}
/**
* Run the fixture extension as a real child process against a stub host that
* speaks the extension side of the wire.
*
* The extension connection is plain JSON-RPC over the extension process's own
* stdio, so a stub host observes exactly what the CLI observes. That is the only
* way to cover this feature end to end today: the released CLI predates the host
* half (github/copilot-agent-runtime#15144), so it ignores the request and grants
* nothing. Once the pinned CLI release carries the host half, the
* real-CLI case below can assert the grant instead.
*/
async function runExtensionAgainstStubHost(options: {
requested: string[];
/** Pass an explicit empty list rather than omitting the option. */
requestEmptyList?: boolean;
/** Names the extension samples but never asks for. */
observe?: string[];
/** Values the host resolves for an approved request, or undefined to deny it. */
granted?: Record<string, string>;
}): Promise<ExtensionRun> {
if (!existsSync(join(DIST_DIR, "extension.js"))) {
throw new Error(`Built SDK not found at ${DIST_DIR}. Run \`npm run build\` first.`);
}
const dir = mkdtempSync(join(tmpdir(), "copilot-env-access-"));
const prejoinFile = join(dir, "prejoin");
const postjoinFile = join(dir, "postjoin");
const resultFile = join(dir, "result");
const child = spawn(process.execPath, [FIXTURE], {
stdio: ["pipe", "pipe", "pipe"],
env: {
...process.env,
SESSION_ID: "stub-host-session",
EXTENSION_SDK_MODULE: pathToFileURL(join(DIST_DIR, "extension.js")).href,
EXTENSION_ENV_REQUEST: options.requested.join(","),
EXTENSION_ENV_REQUEST_EMPTY: options.requestEmptyList ? "1" : "",
EXTENSION_OBSERVE_ENV_NAMES: (options.observe ?? []).join(","),
EXTENSION_PREJOIN_FILE: prejoinFile,
EXTENSION_POSTJOIN_FILE: postjoinFile,
EXTENSION_RESULT_FILE: resultFile,
},
});
const stderr: string[] = [];
child.stderr!.on("data", (chunk) => stderr.push(String(chunk)));
let capturedResumeParams: Record<string, unknown> = {};
const connection = createMessageConnection(
new StreamMessageReader(child.stdout!),
new StreamMessageWriter(child.stdin!)
);
connection.onRequest("connect", () => ({ protocolVersion: getSdkProtocolVersion() }));
connection.onRequest("session.resume", (params: Record<string, unknown>) => {
capturedResumeParams = params;
if (!options.granted) {
throw new Error(
'Extension "env-access" was denied access to sensitive environment variables'
);
}
return { sessionId: params.sessionId, grantedEnvironmentVariables: options.granted };
});
// Everything else the SDK issues while joining is irrelevant here, and an
// unanswered request would hang the join.
connection.onRequest(() => ({}));
connection.onNotification(() => {});
connection.listen();
try {
await retry(
"wait for the fixture extension to report its join result",
async () => {
expect(
existsSync(resultFile),
`extension never reported; stderr: ${stderr.join("")}`
).toBe(true);
},
300,
100
);
return {
resumeParams: capturedResumeParams,
prejoin: readFileSync(prejoinFile, "utf-8"),
postjoin: readFileSync(postjoinFile, "utf-8"),
result: readFileSync(resultFile, "utf-8"),
};
} finally {
connection.dispose();
try {
await stopChildProcess(child);
} finally {
// Windows keeps the directory locked until the child is gone.
await rm(dir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 });
}
}
}
it("puts an extension's environment request on the wire and applies the grant", async () => {
const run = await runExtensionAgainstStubHost({
requested: ["E2E_SDK_TOKEN", "E2E_SDK_OTHER"],
granted: { E2E_SDK_TOKEN: "granted-token", E2E_SDK_OTHER: "granted-other" },
});
expect(run.resumeParams.requestedEnvironmentVariables).toEqual([
"E2E_SDK_TOKEN",
"E2E_SDK_OTHER",
]);
// The values crossed the process boundary rather than being inherited.
expect(run.prejoin).toBe("E2E_SDK_TOKEN=\nE2E_SDK_OTHER=");
expect(run.postjoin).toBe("E2E_SDK_TOKEN=granted-token\nE2E_SDK_OTHER=granted-other");
expect(run.result).toBe("joined");
});
it("grants nothing to an extension whose request the host denies", async () => {
const run = await runExtensionAgainstStubHost({ requested: ["E2E_SDK_TOKEN"] });
expect(run.resumeParams.requestedEnvironmentVariables).toEqual(["E2E_SDK_TOKEN"]);
expect(run.result).toContain("denied access to sensitive environment variables");
expect(run.postjoin).toBe("E2E_SDK_TOKEN=");
});
it("leaves the wire payload alone when an extension asks for nothing", async () => {
const omitted = await runExtensionAgainstStubHost({ requested: [], granted: {} });
const emptyList = await runExtensionAgainstStubHost({
requested: [],
requestEmptyList: true,
granted: {},
});
expect(omitted.resumeParams).not.toHaveProperty("requestedEnvironmentVariables");
// An empty list is the other public way to ask for nothing.
expect(emptyList.resumeParams).not.toHaveProperty("requestedEnvironmentVariables");
expect(omitted.result).toBe("joined");
expect(emptyList.result).toBe("joined");
});
it("ignores a granted variable the extension never requested", async () => {
const run = await runExtensionAgainstStubHost({
requested: ["E2E_SDK_TOKEN"],
observe: ["E2E_SDK_SMUGGLED"],
granted: { E2E_SDK_TOKEN: "granted-token", E2E_SDK_SMUGGLED: "not-approved" },
});
// The user approved one name, so a host answering with a second one cannot
// widen the grant.
expect(run.postjoin).toBe("E2E_SDK_TOKEN=granted-token\nE2E_SDK_SMUGGLED=");
});
const cliObservations = mkdtempSync(join(tmpdir(), "copilot-env-access-cli-"));
const cliResultFile = join(cliObservations, "result");
const cliContext = await createSdkTestContext({
copilotClientOptions: {
connection: RuntimeConnection.forStdio({ path: await getLegacyCliPathForTests() }),
env: {
COPILOT_CLI_ENABLED_FEATURE_FLAGS: "EXTENSIONS",
EXTENSION_ENV_REQUEST: "E2E_SDK_TOKEN",
EXTENSION_RESULT_FILE: cliResultFile,
EXTENSION_PREJOIN_FILE: join(cliObservations, "prejoin"),
EXTENSION_POSTJOIN_FILE: join(cliObservations, "postjoin"),
},
},
});
// The released CLI ignores `requestedEnvironmentVariables`, so this covers the
// half a real CLI can prove today: asking for variables does not break the join.
// It becomes the grant test once the pinned CLI release carries the host half.
it("joins a real CLI that does not support environment requests", async () => {
const { workDir, copilotClient } = cliContext;
const extensionDir = join(workDir, ".github", "extensions", "env-access");
await rm(join(workDir, ".github"), { recursive: true, force: true });
await rm(cliResultFile, { force: true });
await mkdir(extensionDir, { recursive: true });
await copyFile(FIXTURE, join(extensionDir, "extension.mjs"));
execFileSync("git", ["init", "--quiet"], { cwd: workDir });
await using _session = await copilotClient.createSession({
requestExtensions: true,
extensionSdkPath: DIST_DIR,
onPermissionRequest: approveAll,
});
await retry(
"wait for the env-access extension to join the session",
async () => {
expect(existsSync(cliResultFile)).toBe(true);
},
300,
100
);
expect(readFileSync(cliResultFile, "utf-8")).toBe("joined");
});