trackqa 0.0.0-stage → 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/README.md +161 -2
- package/dist/src/lib/mcpOnboarding.js +143 -0
- package/dist/tools/mcp-pilot/google-pkce.mjs +244 -0
- package/dist/tools/trackqa-connect/src/auth.js +153 -0
- package/dist/tools/trackqa-connect/src/cli.js +246 -0
- package/dist/tools/trackqa-connect/src/clients/adapter.js +3 -0
- package/dist/tools/trackqa-connect/src/clients/claude-session.js +78 -0
- package/dist/tools/trackqa-connect/src/clients/claude.js +113 -0
- package/dist/tools/trackqa-connect/src/clients/codex-verify.js +110 -0
- package/dist/tools/trackqa-connect/src/clients/codex.js +191 -0
- package/dist/tools/trackqa-connect/src/clients/gemini-config.js +141 -0
- package/dist/tools/trackqa-connect/src/clients/gemini-verify.js +14 -0
- package/dist/tools/trackqa-connect/src/clients/gemini-windows.js +134 -0
- package/dist/tools/trackqa-connect/src/clients/gemini.js +249 -0
- package/dist/tools/trackqa-connect/src/clients/vscode-copilot-lifecycle.js +97 -0
- package/dist/tools/trackqa-connect/src/clients/vscode-copilot-session.js +115 -0
- package/dist/tools/trackqa-connect/src/clients/vscode-copilot.js +59 -0
- package/dist/tools/trackqa-connect/src/clients/vscode-process.js +123 -0
- package/dist/tools/trackqa-connect/src/clients/vscode-session.js +145 -0
- package/dist/tools/trackqa-connect/src/clients/vscode.js +123 -0
- package/dist/tools/trackqa-connect/src/core.js +87 -0
- package/dist/tools/trackqa-connect/src/process.js +76 -0
- package/dist/tools/trackqa-connect/src/state.js +80 -0
- package/dist/tools/trackqa-connect/src/testing/copilot-fixture-scope.js +103 -0
- package/dist/tools/trackqa-connect/src/workflow.js +241 -0
- package/package.json +43 -4
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { readFile, realpath, lstat, mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import { capture, foreground } from "../process.js";
|
|
6
|
+
import { ConnectorError, requireValue, TOKEN } from "../core.js";
|
|
7
|
+
import { fingerprint } from "./codex.js";
|
|
8
|
+
import { geminiInstructions, geminiVerification } from "./gemini-verify.js";
|
|
9
|
+
import { editGeminiSettings, readGeminiSettings, parseGeminiSettings, geminiHeader, matchesGeminiEntry } from "./gemini-config.js";
|
|
10
|
+
export { geminiHeader, matchesGeminiEntry } from "./gemini-config.js";
|
|
11
|
+
const sha = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
12
|
+
const object = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
|
|
13
|
+
export function trustedGeminiLocation(file, home = os.homedir(), platform = process.platform, cwd = process.cwd()) {
|
|
14
|
+
const paths = platform === 'win32' ? path.win32 : path.posix;
|
|
15
|
+
if (!paths.isAbsolute(file))
|
|
16
|
+
return false;
|
|
17
|
+
const normalized = paths.resolve(file).toLowerCase();
|
|
18
|
+
const inside = (root) => normalized.startsWith(paths.resolve(root).toLowerCase() + paths.sep);
|
|
19
|
+
if (inside(cwd) || normalized === paths.resolve(cwd).toLowerCase())
|
|
20
|
+
return false;
|
|
21
|
+
const roots = platform === 'win32' ? [paths.join(home, 'AppData', 'Roaming', 'npm', 'node_modules', '@google', 'gemini-cli'),
|
|
22
|
+
paths.join(process.env.ProgramFiles ?? 'C:\\Program Files', 'nodejs', 'node_modules', '@google', 'gemini-cli')]
|
|
23
|
+
: ['/usr/local/lib/node_modules/@google/gemini-cli', '/usr/lib/node_modules/@google/gemini-cli'];
|
|
24
|
+
return roots.some(root => inside(root)) && paths.basename(file).toLowerCase().endsWith('.js');
|
|
25
|
+
}
|
|
26
|
+
export async function checkGeminiInstallation(file) {
|
|
27
|
+
requireValue(trustedGeminiLocation(file), 'executable_untrusted');
|
|
28
|
+
const canonical = await realpath(file);
|
|
29
|
+
requireValue(path.resolve(canonical).toLowerCase() === path.resolve(file).toLowerCase() && trustedGeminiLocation(canonical), 'executable_untrusted');
|
|
30
|
+
let root = path.dirname(canonical), packageFile = '';
|
|
31
|
+
// The package metadata must identify this exact executable as its gemini bin.
|
|
32
|
+
for (let i = 0; i < 4; i++, root = path.dirname(root)) {
|
|
33
|
+
const candidate = path.join(root, 'package.json');
|
|
34
|
+
try {
|
|
35
|
+
if (JSON.parse(await readFile(candidate, 'utf8')).name === '@google/gemini-cli') {
|
|
36
|
+
packageFile = candidate;
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch { /* Keep looking within the allowed root. */ }
|
|
41
|
+
}
|
|
42
|
+
requireValue(packageFile, 'client_unsupported');
|
|
43
|
+
const bytes = await readFile(packageFile), metadata = JSON.parse(bytes.toString('utf8'));
|
|
44
|
+
const bin = typeof metadata.bin === 'string' ? metadata.bin : metadata.bin?.gemini;
|
|
45
|
+
requireValue(typeof bin === 'string' && path.resolve(path.dirname(packageFile), bin).toLowerCase() === canonical.toLowerCase()
|
|
46
|
+
&& /^\d+\.\d+\.\d+(?:[-+][\w.-]+)?$/.test(metadata.version), 'client_unsupported');
|
|
47
|
+
const nodePath = await realpath(process.execPath);
|
|
48
|
+
return { path: canonical, sha256: sha(await readFile(canonical)), version: metadata.version, nodePath,
|
|
49
|
+
nodeHash: sha(await readFile(nodePath)), packageFile, packageHash: sha(bytes), appServer: false, foregroundMode: 'direct' };
|
|
50
|
+
}
|
|
51
|
+
const networkVariables = ['HTTP_PROXY', 'http_proxy', 'HTTPS_PROXY', 'https_proxy', 'ALL_PROXY', 'all_proxy', 'NO_PROXY', 'no_proxy', 'NODE_USE_SYSTEM_CA'];
|
|
52
|
+
export function inheritGeminiNetworkEnvironment(child, source) {
|
|
53
|
+
const env = { ...child };
|
|
54
|
+
for (const key of networkVariables) {
|
|
55
|
+
const configured = Object.keys(env).some(name => process.platform === 'win32' ? name.toLowerCase() === key.toLowerCase() : name === key);
|
|
56
|
+
if (!configured && Object.hasOwn(source, key) && source[key] !== undefined)
|
|
57
|
+
env[key] = source[key];
|
|
58
|
+
}
|
|
59
|
+
return env;
|
|
60
|
+
}
|
|
61
|
+
export function geminiEnvironment(home, source = process.env, interactive = false) {
|
|
62
|
+
const env = { GEMINI_CLI_HOME: home, NO_COLOR: '1', GEMINI_TELEMETRY_ENABLED: 'false', GEMINI_TELEMETRY_TRACES_ENABLED: 'false' };
|
|
63
|
+
for (const key of ['PATH', 'PATHEXT', 'SystemRoot', 'WINDIR', 'COMSPEC', 'USERPROFILE', 'HOME', 'APPDATA', 'LOCALAPPDATA', 'TEMP', 'TMP', 'TERM', 'COLORTERM', 'LANG', 'LC_ALL'])
|
|
64
|
+
if (source[key])
|
|
65
|
+
env[key] = source[key];
|
|
66
|
+
// Preserve administrator policy overrides, but never ambient MCP tokens,
|
|
67
|
+
// NODE_OPTIONS/preloads, Supabase credentials or editor attachment variables.
|
|
68
|
+
for (const key of ['GEMINI_CLI_SYSTEM_SETTINGS_PATH', 'GEMINI_CLI_SYSTEM_DEFAULTS_PATH'])
|
|
69
|
+
if (source[key])
|
|
70
|
+
env[key] = source[key];
|
|
71
|
+
if (interactive)
|
|
72
|
+
for (const key of ['GEMINI_API_KEY', 'GOOGLE_API_KEY', 'GOOGLE_CLOUD_PROJECT', 'GOOGLE_CLOUD_LOCATION', 'GOOGLE_APPLICATION_CREDENTIALS'])
|
|
73
|
+
if (source[key])
|
|
74
|
+
env[key] = source[key];
|
|
75
|
+
// Proxy URLs may contain credentials. Keep them only in the child environment;
|
|
76
|
+
// never log/serialize them or relax TLS verification to accommodate a proxy.
|
|
77
|
+
return inheritGeminiNetworkEnvironment(env, source);
|
|
78
|
+
}
|
|
79
|
+
async function assertIntegrity(exe) {
|
|
80
|
+
for (const [file, hash] of [[exe.path, exe.sha256], [exe.nodePath, exe.nodeHash], [exe.packageFile, exe.packageHash]]) {
|
|
81
|
+
requireValue(await realpath(file) === file && sha(await readFile(file)) === hash, 'executable_changed');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async function invoke(exe, args, env, run, cwd) {
|
|
85
|
+
await assertIntegrity(exe);
|
|
86
|
+
// Execute the real npm package bin with the existing Node runtime. Never run
|
|
87
|
+
// a .cmd/.ps1 wrapper through a shell or confuse agy.exe with Gemini CLI.
|
|
88
|
+
return run(exe.nodePath, [exe.path, ...args], env, undefined, cwd);
|
|
89
|
+
}
|
|
90
|
+
async function settings(home) {
|
|
91
|
+
return readGeminiSettings(home);
|
|
92
|
+
}
|
|
93
|
+
async function readSettingsFile(file) {
|
|
94
|
+
try {
|
|
95
|
+
const info = await lstat(file);
|
|
96
|
+
requireValue(info.isFile() && !info.isSymbolicLink() && info.size <= 1_048_576, 'configuration_unreadable');
|
|
97
|
+
const value = parseGeminiSettings(await readFile(file));
|
|
98
|
+
requireValue(object(value) && (value.mcpServers === undefined || object(value.mcpServers)), 'configuration_unreadable');
|
|
99
|
+
return value;
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
if (error.code === 'ENOENT')
|
|
103
|
+
return {};
|
|
104
|
+
throw new ConnectorError('configuration_unreadable');
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// Gemini documents auto-update as enabled by default. Do not let launching the
|
|
108
|
+
// user's client implicitly upgrade it. No preferences are changed here. Be
|
|
109
|
+
// conservative about workspace/system overrides rather than guessing precedence.
|
|
110
|
+
export async function assertNoAutoUpdate(home, source = process.env, cwd = process.cwd()) {
|
|
111
|
+
const user = await settings(home);
|
|
112
|
+
requireValue(object(user.general) && user.general.enableAutoUpdate === false, 'gemini_auto_update_enabled');
|
|
113
|
+
const system = await readSettingsFile(source.GEMINI_CLI_SYSTEM_SETTINGS_PATH ?? 'C:\\ProgramData\\gemini-cli\\settings.json');
|
|
114
|
+
const layers = [system];
|
|
115
|
+
for (let dir = path.resolve(cwd);; dir = path.dirname(dir)) {
|
|
116
|
+
layers.push(await settings(dir));
|
|
117
|
+
if (path.dirname(dir) === dir)
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
for (const layer of layers)
|
|
121
|
+
if (object(layer.general) && layer.general.enableAutoUpdate !== undefined) {
|
|
122
|
+
requireValue(layer.general.enableAutoUpdate === false, 'gemini_auto_update_enabled');
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const servers = (value) => (value.mcpServers ?? {});
|
|
126
|
+
export function geminiAddArgs(setup) {
|
|
127
|
+
return ['mcp', 'add', '--scope', 'user', '--transport', 'http', '--header', `Authorization: ${geminiHeader(setup)}`, setup.serverName, setup.endpoint];
|
|
128
|
+
}
|
|
129
|
+
export const geminiRemoveArgs = (name) => ['mcp', 'remove', '--scope', 'user', name];
|
|
130
|
+
function withoutEntry(value, name) {
|
|
131
|
+
const rest = { ...servers(value) };
|
|
132
|
+
delete rest[name];
|
|
133
|
+
const base = { ...value };
|
|
134
|
+
delete base.mcpServers;
|
|
135
|
+
return { ...base, ...(Object.keys(rest).length ? { mcpServers: rest } : {}) };
|
|
136
|
+
}
|
|
137
|
+
// Native behavior and raw-editor safety are measured independently. Native
|
|
138
|
+
// mutation remains confined to this disposable home even if it appears safe.
|
|
139
|
+
export async function probeGemini(exe, run = capture) {
|
|
140
|
+
const root = await mkdtemp(path.join(os.tmpdir(), 'trackqa-gemini-probe-'));
|
|
141
|
+
const workspace = path.join(root, 'workspace');
|
|
142
|
+
try {
|
|
143
|
+
await mkdir(path.join(root, '.gemini'));
|
|
144
|
+
await mkdir(workspace);
|
|
145
|
+
const original = { general: { enableAutoUpdate: false }, ui: { theme: 'Default' }, mcpServers: { unrelated: { httpUrl: 'https://example.invalid/mcp', headers: { Authorization: 'Bearer ${UNRELATED_TOKEN}' } } } };
|
|
146
|
+
await writeFile(path.join(root, '.gemini', 'settings.json'), JSON.stringify(original));
|
|
147
|
+
// Nonexistent overrides avoid loading real system settings without trying
|
|
148
|
+
// to impersonate administrator-owned policy files in a user-writable temp.
|
|
149
|
+
// The temporary user settings above explicitly disable auto-update.
|
|
150
|
+
const empty = path.join(root, 'absent-system.json');
|
|
151
|
+
const env = { ...geminiEnvironment(root), HOME: root, USERPROFILE: root, UNRELATED_TOKEN: 'compatibility-sentinel-not-a-credential', GEMINI_CLI_SYSTEM_SETTINGS_PATH: empty, GEMINI_CLI_SYSTEM_DEFAULTS_PATH: empty };
|
|
152
|
+
const command = (args) => invoke(exe, args, env, run, workspace);
|
|
153
|
+
const version = (await command(['--version'])).trim();
|
|
154
|
+
requireValue(version === exe.version, 'client_unsupported');
|
|
155
|
+
for (const [args, flags] of [[['mcp', 'add', '--help'], ['--scope', '--transport', '--header']], [['mcp', 'remove', '--help'], ['--scope']], [['mcp', 'list', '--help'], ['list']]]) {
|
|
156
|
+
const help = await command(args);
|
|
157
|
+
requireValue(flags.every(flag => help.includes(flag)), 'client_unsupported');
|
|
158
|
+
}
|
|
159
|
+
const setup = { projectId: '11111111-1111-4111-8111-111111111111', serverName: 'trackqa_compatibility_probe', envVar: 'TRACKQA_PROBE_TOKEN', endpoint: 'https://example.invalid/mcp' };
|
|
160
|
+
await command(geminiAddArgs(setup));
|
|
161
|
+
const installed = await settings(root);
|
|
162
|
+
requireValue(matchesGeminiEntry(servers(installed)[setup.serverName], setup), 'client_unsupported');
|
|
163
|
+
const nativeAddPreservesUnrelated = fingerprint(withoutEntry(installed, setup.serverName)) === fingerprint(original);
|
|
164
|
+
await command(geminiRemoveArgs(setup.serverName));
|
|
165
|
+
const removed = await settings(root);
|
|
166
|
+
requireValue(!Object.hasOwn(servers(removed), setup.serverName), 'client_unsupported');
|
|
167
|
+
const nativeRemovePreservesUnrelated = fingerprint(removed) === fingerprint(withoutEntry(installed, setup.serverName));
|
|
168
|
+
const rawHome = path.join(root, 'raw-editor');
|
|
169
|
+
await mkdir(path.join(rawHome, '.gemini'), { recursive: true });
|
|
170
|
+
await writeFile(path.join(rawHome, '.gemini', 'settings.json'), JSON.stringify(original));
|
|
171
|
+
const hash = await editGeminiSettings(rawHome, setup, 'install');
|
|
172
|
+
const rawInstalled = await settings(rawHome);
|
|
173
|
+
requireValue(matchesGeminiEntry(servers(rawInstalled)[setup.serverName], setup)
|
|
174
|
+
&& fingerprint(withoutEntry(rawInstalled, setup.serverName)) === fingerprint(original), 'installation_unconfirmed');
|
|
175
|
+
await editGeminiSettings(rawHome, setup, 'remove', hash);
|
|
176
|
+
requireValue(fingerprint(await settings(rawHome)) === fingerprint(original), 'removal_unconfirmed');
|
|
177
|
+
return { nativeAddPreservesUnrelated, nativeRemovePreservesUnrelated, generatedHttpEntry: true, literalHeader: true, rawEditorPreservesUnrelated: true, rawEditorRemoval: true };
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
if (error instanceof ConnectorError && error.code === 'executable_changed')
|
|
181
|
+
throw error;
|
|
182
|
+
throw new ConnectorError('gemini_compatibility_unverified');
|
|
183
|
+
}
|
|
184
|
+
finally {
|
|
185
|
+
await rm(root, { recursive: true, force: true });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
export async function detectGemini(explicit, source = process.env, run = capture, inspect = checkGeminiInstallation) {
|
|
189
|
+
const prefixes = [...new Set([...(source.PATH ?? '').split(path.delimiter), source.APPDATA ? path.join(source.APPDATA, 'npm') : '', path.join(source.ProgramFiles ?? 'C:\\Program Files', 'nodejs')])].filter(folder => path.isAbsolute(folder));
|
|
190
|
+
const candidates = explicit ? [explicit] : prefixes.map(folder => path.join(folder, 'node_modules', '@google', 'gemini-cli', 'dist', 'index.js'));
|
|
191
|
+
let incompatible = false;
|
|
192
|
+
for (const file of candidates) {
|
|
193
|
+
let exe;
|
|
194
|
+
try {
|
|
195
|
+
exe = await inspect(file);
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
if (explicit)
|
|
199
|
+
throw error instanceof ConnectorError ? error : new ConnectorError('executable_untrusted');
|
|
200
|
+
if (error instanceof ConnectorError && error.code === 'client_unsupported')
|
|
201
|
+
incompatible = true;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
try {
|
|
205
|
+
return { ...exe, capabilities: await probeGemini(exe, run) };
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
if (explicit || (error instanceof ConnectorError && error.code === 'executable_changed'))
|
|
209
|
+
throw error;
|
|
210
|
+
incompatible = true;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
throw new ConnectorError(incompatible ? 'gemini_compatibility_unverified' : 'client_not_found');
|
|
214
|
+
}
|
|
215
|
+
export function geminiAdapter(exe, home, run = capture, source = process.env, launchForeground = foreground) {
|
|
216
|
+
// Retain the injected runner for the detection/probe API, never user writes.
|
|
217
|
+
void run;
|
|
218
|
+
const entry = async (setup) => servers(await settings(home))[setup.serverName] ?? null;
|
|
219
|
+
return {
|
|
220
|
+
client: 'gemini', label: 'Gemini', configurationHome: home, executable: exe, entry, matches: matchesGeminiEntry,
|
|
221
|
+
installPreview: () => 'Scoped user settings JSON editor; private flushed temporary file and Windows replacement; no native add/remove on real settings',
|
|
222
|
+
manualInstructions: geminiInstructions,
|
|
223
|
+
async prepare() { await assertNoAutoUpdate(home, source); },
|
|
224
|
+
async install(setup) {
|
|
225
|
+
await assertIntegrity(exe);
|
|
226
|
+
return (await editGeminiSettings(home, setup, 'install'));
|
|
227
|
+
},
|
|
228
|
+
async remove(setup, hash) {
|
|
229
|
+
await editGeminiSettings(home, setup, 'remove', hash);
|
|
230
|
+
},
|
|
231
|
+
async verify() { return geminiVerification(); },
|
|
232
|
+
async launch(setup, token, signal) {
|
|
233
|
+
requireValue(TOKEN.test(token), 'token_invalid');
|
|
234
|
+
await assertNoAutoUpdate(home, source);
|
|
235
|
+
requireValue(matchesGeminiEntry(await entry(setup), setup), 'configuration_changed');
|
|
236
|
+
await assertIntegrity(exe);
|
|
237
|
+
const childEnv = { ...geminiEnvironment(home, source, true), [setup.envVar]: token };
|
|
238
|
+
token = '';
|
|
239
|
+
try {
|
|
240
|
+
const running = launchForeground(exe.nodePath, childEnv, signal, undefined, [exe.path]);
|
|
241
|
+
delete childEnv[setup.envVar];
|
|
242
|
+
await running;
|
|
243
|
+
}
|
|
244
|
+
finally {
|
|
245
|
+
delete childEnv[setup.envVar];
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { lstat, readFile, readdir, realpath } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { ConnectorError, requireValue, TOKEN } from "../core.js";
|
|
6
|
+
import { copilotEnvironment, copilotLaunchArgs } from "./vscode-copilot.js";
|
|
7
|
+
import { copilotPaths, copilotPayloads, readCopilotSession } from "./vscode-copilot-session.js";
|
|
8
|
+
import { readVSCodeFile, safeVSCodeDirectory, writeVSCodeFile } from "./vscode-session.js";
|
|
9
|
+
async function verify(exe) {
|
|
10
|
+
requireValue(!await lstat(path.join(exe.installation, 'data')).catch(e => { if (e.code === 'ENOENT')
|
|
11
|
+
return null; throw e; }), 'executable_untrusted');
|
|
12
|
+
for (const input of [{ path: exe.path, sha256: exe.sha256 }, ...exe.inputs]) {
|
|
13
|
+
requireValue(input.path.toLowerCase().startsWith(path.resolve(exe.installation).toLowerCase() + path.sep), 'executable_untrusted');
|
|
14
|
+
await safeVSCodeDirectory(path.dirname(input.path));
|
|
15
|
+
const stat = await lstat(input.path);
|
|
16
|
+
requireValue(stat.isFile() && !stat.isSymbolicLink() && (await realpath(input.path)).toLowerCase() === input.path.toLowerCase()
|
|
17
|
+
&& createHash('sha256').update(await readFile(input.path)).digest('hex') === input.sha256, 'executable_changed');
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export const copilotRuntime = { spawn, verify };
|
|
21
|
+
export async function startCopilotSession(exe, home, setup, options = {}) {
|
|
22
|
+
const files = copilotPaths(home, setup);
|
|
23
|
+
const mode = options.credential === undefined ? 'token-free' : 'session';
|
|
24
|
+
requireValue(!options.signal?.aborted, 'interrupted');
|
|
25
|
+
requireValue(options.credential === undefined || TOKEN.test(options.credential), 'token_invalid');
|
|
26
|
+
requireValue(await readCopilotSession(home, setup, mode), 'configuration_changed');
|
|
27
|
+
return startPreparedCopilotSession(exe, {
|
|
28
|
+
files, settings: copilotPayloads(setup, mode).settings, args: copilotLaunchArgs(home, setup),
|
|
29
|
+
env: copilotEnvironment(home, setup, options.source), envVar: setup.envVar,
|
|
30
|
+
}, options);
|
|
31
|
+
}
|
|
32
|
+
// Shared process primitive, called only after the production or separate local
|
|
33
|
+
// test validator has verified configuration ownership and expected bytes.
|
|
34
|
+
export async function startPreparedCopilotSession(exe, profile, options = {}) {
|
|
35
|
+
const runtime = options.runtime ?? copilotRuntime, { files } = profile;
|
|
36
|
+
requireValue(!options.signal?.aborted, 'interrupted');
|
|
37
|
+
requireValue((await readVSCodeFile(files.settings))?.equals(profile.settings)
|
|
38
|
+
&& !await readVSCodeFile(files.process), 'configuration_changed');
|
|
39
|
+
requireValue((await readdir(files.extensions)).every(name => ['extensions.json', '.obsolete'].includes(name)), 'configuration_changed');
|
|
40
|
+
for (const [name, expected] of [['extensions.json', '[]'], ['.obsolete', '{}']]) {
|
|
41
|
+
const content = await readVSCodeFile(path.join(files.extensions, name));
|
|
42
|
+
try {
|
|
43
|
+
requireValue(!content || JSON.stringify(JSON.parse(content.toString('utf8'))) === expected, 'configuration_changed');
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
throw new ConnectorError('configuration_changed');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
await runtime.verify(exe);
|
|
50
|
+
// Exclusive single-use receipt: an old GUI may survive Disconnect. Never reuse
|
|
51
|
+
// its environment/profile or remove its runtime data based on launcher exit.
|
|
52
|
+
await writeVSCodeFile(files.launched, Buffer.from('{"owner":"TrackQA Copilot single-use launch v1"}\n'));
|
|
53
|
+
const env = { ...profile.env };
|
|
54
|
+
if (options.credential)
|
|
55
|
+
env[profile.envVar] = options.credential;
|
|
56
|
+
let child;
|
|
57
|
+
try {
|
|
58
|
+
child = runtime.spawn(exe.path, profile.args, { env, cwd: files.root, windowsHide: false, stdio: 'ignore' });
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
throw new ConnectorError('client_launch_failed');
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
delete env[profile.envVar];
|
|
65
|
+
options.credential = undefined;
|
|
66
|
+
}
|
|
67
|
+
let exited = false, failure;
|
|
68
|
+
let complete;
|
|
69
|
+
const stopped = new Promise(resolve => { complete = resolve; });
|
|
70
|
+
child.once('error', () => { failure = new ConnectorError('client_launch_failed'); complete(); });
|
|
71
|
+
child.once('exit', code => { exited = true; if (code !== 0)
|
|
72
|
+
failure = new ConnectorError('client_exit_failed'); complete(); });
|
|
73
|
+
return {
|
|
74
|
+
launcherStatus: () => exited ? 'exit-observed' : 'unconfirmed',
|
|
75
|
+
async waitForDisconnect(request, signal) {
|
|
76
|
+
const prompt = new AbortController();
|
|
77
|
+
let interrupted;
|
|
78
|
+
const interruption = new Promise(resolve => { interrupted = resolve; });
|
|
79
|
+
const abort = () => { interrupted(); };
|
|
80
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
81
|
+
if (signal?.aborted)
|
|
82
|
+
abort();
|
|
83
|
+
try {
|
|
84
|
+
await Promise.race([stopped, interruption, Promise.resolve().then(() => request(prompt.signal))]);
|
|
85
|
+
requireValue(!signal?.aborted, 'interrupted');
|
|
86
|
+
if (failure)
|
|
87
|
+
throw failure;
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
prompt.abort();
|
|
91
|
+
signal?.removeEventListener('abort', abort);
|
|
92
|
+
// No descendant discovery, ownership adoption, shutdown RPC or kill.
|
|
93
|
+
child.unref();
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { lstat, mkdir, unlink } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { requireValue, setupFor, UUID } from "../core.js";
|
|
5
|
+
import { fingerprint } from "./codex.js";
|
|
6
|
+
import { readVSCodeFile, safeVSCodeDirectory, writeVSCodeFile } from "./vscode-session.js";
|
|
7
|
+
export const freshCopilotHome = (base) => path.join(base, 'runs', randomUUID());
|
|
8
|
+
export function copilotRecoveryHome(base, home) {
|
|
9
|
+
const relative = path.relative(base, home).split(path.sep);
|
|
10
|
+
requireValue(path.isAbsolute(home) && (path.resolve(home) === path.resolve(base)
|
|
11
|
+
|| relative.length === 2 && relative[0] === 'runs' && UUID.test(relative[1])), 'configuration_home_mismatch');
|
|
12
|
+
return home;
|
|
13
|
+
}
|
|
14
|
+
export function copilotPaths(home, setup) {
|
|
15
|
+
requireValue(path.isAbsolute(home) && UUID.test(setup.projectId)
|
|
16
|
+
&& fingerprint(setup) === fingerprint(setupFor(setup.projectId, 'vscode-copilot')), 'configuration_location_untrusted');
|
|
17
|
+
const root = path.join(home, setup.projectId), user = path.join(root, 'user-data'), isolatedHome = path.join(root, 'home');
|
|
18
|
+
return { root, user, home: isolatedHome, extensions: path.join(root, 'extensions'), temp: path.join(isolatedHome, 'Temp'),
|
|
19
|
+
copilot: path.join(root, 'copilot'), shared: path.join(root, 'shared-data'), owner: path.join(root, 'owner.json'),
|
|
20
|
+
process: path.join(root, 'process.json'), launched: path.join(root, 'launch.json'), settings: path.join(user, 'User', 'settings.json'),
|
|
21
|
+
localMcp: path.join(user, 'User', 'mcp.json'), mcp: path.join(root, 'copilot', 'mcp-config.json') };
|
|
22
|
+
}
|
|
23
|
+
export const copilotEntry = (setup) => ({ type: 'http', url: setup.endpoint,
|
|
24
|
+
headers: { Authorization: `Bearer \${${setup.envVar}}` } });
|
|
25
|
+
const bytes = (value) => Buffer.from(JSON.stringify(value, null, 2) + '\n');
|
|
26
|
+
export function copilotPayloads(setup, mode = 'session') {
|
|
27
|
+
requireValue(['session', 'token-free'].includes(mode), 'configuration_conflict');
|
|
28
|
+
return {
|
|
29
|
+
owner: bytes({ owner: 'TrackQA Connect VS Code Copilot client-managed v1', client: 'vscode-copilot', projectId: setup.projectId, mode }),
|
|
30
|
+
mcp: bytes({ mcpServers: mode === 'token-free' ? {} : { [setup.serverName]: copilotEntry(setup) } }),
|
|
31
|
+
settings: bytes({ 'update.mode': 'none', 'extensions.autoUpdate': 'off', 'extensions.autoCheckUpdates': false,
|
|
32
|
+
'telemetry.telemetryLevel': 'off', 'workbench.startupEditor': 'none', 'window.restoreWindows': 'none',
|
|
33
|
+
'window.title': 'TrackQA Copilot - ${activeEditorShort}' }),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
async function owned(home, setup, mode) {
|
|
37
|
+
const files = copilotPaths(home, setup);
|
|
38
|
+
try {
|
|
39
|
+
await safeVSCodeDirectory(home);
|
|
40
|
+
await safeVSCodeDirectory(files.root);
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
if (error.code === 'ENOENT')
|
|
44
|
+
return null;
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
requireValue((await readVSCodeFile(files.owner))?.equals(copilotPayloads(setup, mode).owner), 'configuration_conflict');
|
|
48
|
+
return files;
|
|
49
|
+
}
|
|
50
|
+
async function configuration(home, setup, mode) {
|
|
51
|
+
const files = await owned(home, setup, mode);
|
|
52
|
+
if (!files)
|
|
53
|
+
return null;
|
|
54
|
+
for (const dir of [files.user, path.dirname(files.settings), files.extensions, files.home, files.temp, files.copilot, files.shared])
|
|
55
|
+
await safeVSCodeDirectory(dir);
|
|
56
|
+
// A second registration source is unexpected, including a link or directory.
|
|
57
|
+
requireValue(!await lstat(files.localMcp).catch(error => { if (error.code === 'ENOENT')
|
|
58
|
+
return null; throw error; }), 'configuration_conflict');
|
|
59
|
+
return files;
|
|
60
|
+
}
|
|
61
|
+
export async function readCopilotSession(home, setup, mode = 'session') {
|
|
62
|
+
const files = await configuration(home, setup, mode);
|
|
63
|
+
if (!files)
|
|
64
|
+
return null;
|
|
65
|
+
const content = await readVSCodeFile(files.mcp);
|
|
66
|
+
if (!content)
|
|
67
|
+
return null;
|
|
68
|
+
requireValue(content.equals(copilotPayloads(setup, mode).mcp), 'configuration_changed');
|
|
69
|
+
return mode === 'token-free' ? { mcpServers: {} } : copilotEntry(setup);
|
|
70
|
+
}
|
|
71
|
+
export async function installCopilotSession(home, setup, mode = 'session') {
|
|
72
|
+
const files = copilotPaths(home, setup), payload = copilotPayloads(setup, mode);
|
|
73
|
+
await safeVSCodeDirectory(home, true);
|
|
74
|
+
try {
|
|
75
|
+
await mkdir(files.root, { mode: 0o700 });
|
|
76
|
+
await writeVSCodeFile(files.owner, payload.owner);
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
if (error.code !== 'EEXIST')
|
|
80
|
+
throw error;
|
|
81
|
+
await owned(home, setup, mode);
|
|
82
|
+
}
|
|
83
|
+
await safeVSCodeDirectory(files.root);
|
|
84
|
+
requireValue(!await readVSCodeFile(files.process), 'copilot_process_unconfirmed');
|
|
85
|
+
requireValue(!await readVSCodeFile(files.launched), 'configuration_conflict');
|
|
86
|
+
for (const file of [files.settings, files.mcp, files.localMcp]) {
|
|
87
|
+
requireValue(!await lstat(file).catch(error => { if (error.code === 'ENOENT')
|
|
88
|
+
return null; throw error; }), 'configuration_conflict');
|
|
89
|
+
}
|
|
90
|
+
for (const dir of [files.user, path.dirname(files.settings), files.extensions, files.home, files.temp, files.copilot, files.shared,
|
|
91
|
+
path.join(files.home, 'AppData', 'Roaming'), path.join(files.home, 'AppData', 'Local')])
|
|
92
|
+
await safeVSCodeDirectory(dir, true);
|
|
93
|
+
await writeVSCodeFile(files.settings, payload.settings);
|
|
94
|
+
await writeVSCodeFile(files.mcp, payload.mcp);
|
|
95
|
+
return fingerprint(mode === 'token-free' ? { mcpServers: {} } : copilotEntry(setup));
|
|
96
|
+
}
|
|
97
|
+
export async function removeCopilotSession(home, setup, hash, mode = 'session') {
|
|
98
|
+
const files = await configuration(home, setup, mode);
|
|
99
|
+
if (!files)
|
|
100
|
+
return;
|
|
101
|
+
// Offline foundation cannot establish that a recorded runtime has stopped.
|
|
102
|
+
// Never retire its evidence based on editor exit or an empty PID list alone.
|
|
103
|
+
requireValue(!await readVSCodeFile(files.process), 'copilot_process_unconfirmed');
|
|
104
|
+
requireValue(hash === null || hash === fingerprint(mode === 'token-free' ? { mcpServers: {} } : copilotEntry(setup)), 'configuration_changed');
|
|
105
|
+
await removeOwnedCopilotConfiguration(files.mcp, copilotPayloads(setup, mode).mcp);
|
|
106
|
+
// Preserve the owner marker, runtime data and any unexpected files. No tree deletion.
|
|
107
|
+
}
|
|
108
|
+
// Both validators establish ownership first. Keep removal identical for hosted
|
|
109
|
+
// sessions and the loopback test fixture, without accepting a different schema.
|
|
110
|
+
export async function removeOwnedCopilotConfiguration(file, expected) {
|
|
111
|
+
const content = await readVSCodeFile(file);
|
|
112
|
+
requireValue(!content || content.equals(expected), 'configuration_changed');
|
|
113
|
+
if (content)
|
|
114
|
+
await unlink(file);
|
|
115
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { ConnectorError } from "../core.js";
|
|
3
|
+
import { fingerprint } from "./codex.js";
|
|
4
|
+
import { copilotEntry, copilotPaths, installCopilotSession, readCopilotSession, removeCopilotSession } from "./vscode-copilot-session.js";
|
|
5
|
+
import { startCopilotSession } from "./vscode-copilot-lifecycle.js";
|
|
6
|
+
export function copilotEnvironment(home, setup, source = process.env) {
|
|
7
|
+
const files = copilotPaths(home, setup), clean = {};
|
|
8
|
+
for (const key of ['PATH', 'PATHEXT', 'SystemRoot', 'SystemDrive', 'WINDIR', 'COMSPEC', 'TERM', 'COLORTERM', 'LANG', 'LC_ALL',
|
|
9
|
+
'HTTP_PROXY', 'http_proxy', 'HTTPS_PROXY', 'https_proxy', 'ALL_PROXY', 'all_proxy', 'NO_PROXY', 'no_proxy', 'NODE_USE_SYSTEM_CA', 'NODE_EXTRA_CA_CERTS']) {
|
|
10
|
+
if (source[key] !== undefined)
|
|
11
|
+
clean[key] = source[key];
|
|
12
|
+
}
|
|
13
|
+
return { ...clean, HOME: files.home, USERPROFILE: files.home, APPDATA: path.join(files.home, 'AppData', 'Roaming'),
|
|
14
|
+
LOCALAPPDATA: path.join(files.home, 'AppData', 'Local'), TEMP: files.temp, TMP: files.temp,
|
|
15
|
+
COPILOT_HOME: files.copilot, VSCODE_DISABLE_TELEMETRY: '1' };
|
|
16
|
+
}
|
|
17
|
+
export function copilotLaunchArgs(home, setup) {
|
|
18
|
+
const files = copilotPaths(home, setup);
|
|
19
|
+
return ['--new-window', '--user-data-dir', files.user, '--extensions-dir', files.extensions, '--shared-data-dir', files.shared,
|
|
20
|
+
'--skip-welcome', '--skip-release-notes', '--disable-updates', '--disable-telemetry', '--sync', 'off',
|
|
21
|
+
'--use-inmemory-secretstorage', '--log', 'warning'];
|
|
22
|
+
}
|
|
23
|
+
const unavailable = () => { throw new ConnectorError('copilot_runtime_unverified'); };
|
|
24
|
+
export function vscodeCopilotAdapter(exe, home, controls) {
|
|
25
|
+
let launcherStatus = 'not observed';
|
|
26
|
+
return {
|
|
27
|
+
client: 'vscode-copilot', label: 'VS Code Copilot', configurationHome: home, executable: exe,
|
|
28
|
+
entry: setup => readCopilotSession(home, setup), matches: (value, setup) => fingerprint(value) === fingerprint(copilotEntry(setup)),
|
|
29
|
+
installPreview: setup => `Private portable MCP configuration: ${copilotPaths(home, setup).mcp}. Session-only; native acceptance pending.`,
|
|
30
|
+
prepare: async () => unavailable(),
|
|
31
|
+
install: setup => installCopilotSession(home, setup),
|
|
32
|
+
remove: async (setup, hash) => {
|
|
33
|
+
try {
|
|
34
|
+
await removeCopilotSession(home, setup, hash);
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
controls?.report(`Editor launcher: ${launcherStatus}. Copilot runtime termination: UNVERIFIED. Use File > Exit in only TrackQA Copilot to exit normally. Runtime profile data is preserved.`);
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
manualInstructions: setup => `In only TrackQA Copilot, use native Copilot MCP controls to start ${setup.serverName} and inspect its tools. GitHub/model sign-in is separate. Stop on unexpected permissions. No automatic tool approval. Keep the CLI open; type disconnect here to revoke access. Configuration and editor startup do not prove connectivity.`,
|
|
41
|
+
verify: async () => {
|
|
42
|
+
unavailable();
|
|
43
|
+
return { authorized: null, verified: false, code: 'manual_verification_required' };
|
|
44
|
+
},
|
|
45
|
+
launch: async (setup, token, signal) => {
|
|
46
|
+
unavailable(); // Remains blocked until separately approved synthetic acceptance.
|
|
47
|
+
if (!controls)
|
|
48
|
+
throw new ConnectorError('usage');
|
|
49
|
+
const session = await startCopilotSession(exe, home, setup, { credential: token, signal });
|
|
50
|
+
token = '';
|
|
51
|
+
try {
|
|
52
|
+
await session.waitForDisconnect(controls.requestDisconnect, signal);
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
launcherStatus = session.launcherStatus();
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { open, unlink } from 'node:fs/promises';
|
|
4
|
+
import { capture } from "../process.js";
|
|
5
|
+
import { ConnectorError, requireValue } from "../core.js";
|
|
6
|
+
import { readVSCodeFile, writeVSCodeFile } from "./vscode-session.js";
|
|
7
|
+
const shell = () => path.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
|
|
8
|
+
const quote = (value) => "'" + value.replaceAll("'", "''") + "'";
|
|
9
|
+
const helperEnvironment = () => Object.fromEntries(['SystemRoot', 'WINDIR', 'TEMP', 'TMP'].filter(k => process.env[k]).map(k => [k, process.env[k]]));
|
|
10
|
+
export async function codeProcesses(user) {
|
|
11
|
+
requireValue(process.platform === 'win32', 'client_unsupported');
|
|
12
|
+
const command = `$dir=${quote(user)}; @(Get-CimInstance Win32_Process -Filter "Name = 'Code.exe'" -ErrorAction Stop | ForEach-Object { [pscustomobject]@{pid=[int]$_.ProcessId;created=$_.CreationDate.ToUniversalTime().ToString('o');owned=[bool]($_.CommandLine -and $_.CommandLine.Contains($dir))} }) | ConvertTo-Json -Compress`;
|
|
13
|
+
try {
|
|
14
|
+
const raw = await capture(shell(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', command], helperEnvironment());
|
|
15
|
+
const rows = raw.trim() ? JSON.parse(raw) : [];
|
|
16
|
+
requireValue(Array.isArray(rows) && rows.length < 1000 && rows.every(row => Number.isSafeInteger(row.pid) && row.pid > 0 && typeof row.created === 'string' && typeof row.owned === 'boolean'), 'vscode_process_unconfirmed');
|
|
17
|
+
return rows;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
throw new ConnectorError('vscode_process_unconfirmed');
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
async function closeOwnedWindow(user, process) {
|
|
24
|
+
// No forced process/tree kill. Recheck identity and the private user-data path.
|
|
25
|
+
const command = `$p=Get-CimInstance Win32_Process -Filter ${quote('ProcessId = ' + process.pid)} -ErrorAction Stop; if ($p -and $p.Name -ceq 'Code.exe' -and $p.CreationDate.ToUniversalTime().ToString('o') -ceq ${quote(process.created)} -and $p.CommandLine.Contains(${quote(user)})) { [void](Get-Process -Id ${process.pid} -ErrorAction Stop).CloseMainWindow() }`;
|
|
26
|
+
await capture(shell(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', command], helperEnvironment());
|
|
27
|
+
}
|
|
28
|
+
export const vscodeRuntime = { snapshot: codeProcesses, close: closeOwnedWindow, spawn };
|
|
29
|
+
const identity = (row) => `${row.pid}:${row.created}`;
|
|
30
|
+
async function runRecord(file) {
|
|
31
|
+
const bytes = await readVSCodeFile(file);
|
|
32
|
+
if (!bytes)
|
|
33
|
+
return null;
|
|
34
|
+
let record;
|
|
35
|
+
try {
|
|
36
|
+
record = JSON.parse(bytes.toString('utf8'));
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
throw new ConnectorError('vscode_process_unconfirmed');
|
|
40
|
+
}
|
|
41
|
+
requireValue(record && Object.keys(record).sort().join(',') === 'baseline,phase,version' && record.version === 1
|
|
42
|
+
&& ['launching', 'running'].includes(record.phase) && Array.isArray(record.baseline)
|
|
43
|
+
&& record.baseline.every(row => Object.keys(row).sort().join(',') === 'created,owned,pid' && Number.isSafeInteger(row.pid)
|
|
44
|
+
&& row.pid > 0 && typeof row.created === 'string' && typeof row.owned === 'boolean'), 'vscode_process_unconfirmed');
|
|
45
|
+
return record;
|
|
46
|
+
}
|
|
47
|
+
export async function settleVSCodeRun(file, user, runtime = vscodeRuntime) {
|
|
48
|
+
const record = await runRecord(file);
|
|
49
|
+
if (!record)
|
|
50
|
+
return;
|
|
51
|
+
// A crash in the spawn/acknowledgement gap cannot be automatically settled.
|
|
52
|
+
requireValue(record.phase === 'running', 'vscode_process_unconfirmed');
|
|
53
|
+
const baseline = new Set(record.baseline.map(identity)), rows = await runtime.snapshot(user);
|
|
54
|
+
requireValue(rows.every(row => !row.owned && baseline.has(identity(row))), 'vscode_process_unconfirmed');
|
|
55
|
+
await unlink(file);
|
|
56
|
+
}
|
|
57
|
+
export async function launchVSCode(executable, args, env, user, file, signal, runtime = vscodeRuntime) {
|
|
58
|
+
requireValue(!signal?.aborted, 'interrupted');
|
|
59
|
+
const baseline = await runtime.snapshot(user);
|
|
60
|
+
requireValue(baseline.length < 100 && !baseline.some(row => row.owned), 'vscode_process_unconfirmed');
|
|
61
|
+
const record = { version: 1, phase: 'launching', baseline };
|
|
62
|
+
await writeVSCodeFile(file, Buffer.from(JSON.stringify(record)));
|
|
63
|
+
if (signal?.aborted) {
|
|
64
|
+
await unlink(file);
|
|
65
|
+
throw new ConnectorError('interrupted');
|
|
66
|
+
}
|
|
67
|
+
let child;
|
|
68
|
+
try {
|
|
69
|
+
child = runtime.spawn(executable, args, { env, cwd: path.dirname(user), windowsHide: false, stdio: 'ignore' });
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
await unlink(file);
|
|
73
|
+
throw new ConnectorError('client_launch_failed');
|
|
74
|
+
}
|
|
75
|
+
// spawn copies the environment synchronously. Never retain its token reference.
|
|
76
|
+
for (const key of Object.keys(env))
|
|
77
|
+
if (/^TRACKQA_MCP_/.test(key))
|
|
78
|
+
delete env[key];
|
|
79
|
+
let failure, stopping = false, exited = false;
|
|
80
|
+
let timer;
|
|
81
|
+
let completeCleanup = () => { };
|
|
82
|
+
const complete = new Promise(resolve => {
|
|
83
|
+
const stop = () => {
|
|
84
|
+
if (stopping || exited)
|
|
85
|
+
return;
|
|
86
|
+
stopping = true;
|
|
87
|
+
void runtime.snapshot(user).then(rows => Promise.all(rows.filter(row => row.pid === child.pid && row.owned && !baseline.some(old => identity(old) === identity(row))).map(row => runtime.close(user, row)))).catch(() => { });
|
|
88
|
+
timer = setTimeout(() => { failure = new ConnectorError('vscode_process_unconfirmed'); resolve(); }, runtime.graceMs ?? 5000);
|
|
89
|
+
};
|
|
90
|
+
signal?.addEventListener('abort', stop, { once: true });
|
|
91
|
+
child.once('error', () => { exited = true; failure = new ConnectorError('client_launch_failed'); resolve(); });
|
|
92
|
+
child.once('exit', code => { exited = true; if (code !== 0)
|
|
93
|
+
failure = new ConnectorError('client_exit_failed'); resolve(); });
|
|
94
|
+
if (signal?.aborted)
|
|
95
|
+
stop();
|
|
96
|
+
completeCleanup = () => { clearTimeout(timer); signal?.removeEventListener('abort', stop); };
|
|
97
|
+
});
|
|
98
|
+
try {
|
|
99
|
+
// A partial acknowledgement fails closed on recovery; no token is written.
|
|
100
|
+
const handle = await open(file, 'r+');
|
|
101
|
+
try {
|
|
102
|
+
await handle.truncate(0);
|
|
103
|
+
await handle.writeFile(JSON.stringify({ ...record, phase: 'running' }));
|
|
104
|
+
await handle.sync();
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
await handle.close();
|
|
108
|
+
}
|
|
109
|
+
await complete;
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
throw new ConnectorError('vscode_process_unconfirmed');
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
completeCleanup();
|
|
116
|
+
}
|
|
117
|
+
// Main-process exit alone is insufficient. Any newly observed Code process
|
|
118
|
+
// (even an unrelated new instance) makes cleanup uncertain; none is killed.
|
|
119
|
+
await settleVSCodeRun(file, user, runtime);
|
|
120
|
+
if (failure)
|
|
121
|
+
throw failure;
|
|
122
|
+
requireValue(!signal?.aborted, 'interrupted');
|
|
123
|
+
}
|