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,110 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline';
|
|
2
|
+
import { spawnProcess, clientEnvironment } from "../process.js";
|
|
3
|
+
import { ConnectorError, requireValue, TOKEN, tools } from "../core.js";
|
|
4
|
+
// Adapted from the existing Codex pilot app-server check. No model turn, code
|
|
5
|
+
// upload, synthetic project assumptions, finding submission or direct DB access.
|
|
6
|
+
export async function verifyCodex(executable, codexHome, setup, token, signal, spawnFn = spawnProcess, timeoutMs = 30_000) {
|
|
7
|
+
requireValue(TOKEN.test(token), 'token_invalid');
|
|
8
|
+
const env = { ...clientEnvironment(codexHome), [setup.envVar]: token };
|
|
9
|
+
token = '';
|
|
10
|
+
const table = `mcp_servers={${setup.serverName}={url="${setup.endpoint}",bearer_token_env_var="${setup.envVar}",startup_timeout_sec=15,tool_timeout_sec=10,enabled_tools=[${tools.map(name => `"${name}"`).join(',')}]}}`;
|
|
11
|
+
const child = spawnFn(executable, ['app-server', '--stdio', '-c', table, '-c', 'plugins={}', '-c', 'analytics.enabled=false'], { env, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
12
|
+
delete env[setup.envVar];
|
|
13
|
+
let sequence = 0, bytes = 0, stage = 'initialization', stopped = false, authorization = null;
|
|
14
|
+
const pending = new Map();
|
|
15
|
+
const fail = () => {
|
|
16
|
+
if (stopped)
|
|
17
|
+
return;
|
|
18
|
+
stopped = true;
|
|
19
|
+
for (const promise of pending.values())
|
|
20
|
+
promise.reject(new ConnectorError('verification_failed'));
|
|
21
|
+
pending.clear();
|
|
22
|
+
child.kill();
|
|
23
|
+
};
|
|
24
|
+
child.on('error', fail);
|
|
25
|
+
child.on('exit', fail);
|
|
26
|
+
for (const stream of [child.stdin, child.stdout, child.stderr])
|
|
27
|
+
stream?.on('error', fail);
|
|
28
|
+
for (const stream of [child.stdout, child.stderr])
|
|
29
|
+
stream?.on('data', (chunk) => { bytes += chunk.length; if (bytes > 1_048_576)
|
|
30
|
+
fail(); });
|
|
31
|
+
const lines = createInterface({ input: child.stdout });
|
|
32
|
+
lines.on('error', fail);
|
|
33
|
+
let startupError = false;
|
|
34
|
+
lines.on('line', (line) => {
|
|
35
|
+
if (stopped)
|
|
36
|
+
return;
|
|
37
|
+
try {
|
|
38
|
+
const message = JSON.parse(line);
|
|
39
|
+
if (message.method === 'mcpServer/startupStatus/updated' && message.params?.name === setup.serverName && message.params.error)
|
|
40
|
+
startupError = true;
|
|
41
|
+
const promise = pending.get(message.id);
|
|
42
|
+
if (!promise)
|
|
43
|
+
return;
|
|
44
|
+
pending.delete(message.id);
|
|
45
|
+
if (message.error)
|
|
46
|
+
promise.reject(new ConnectorError('verification_failed'));
|
|
47
|
+
else
|
|
48
|
+
promise.resolve(message.result);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
fail();
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
const timer = setTimeout(fail, timeoutMs);
|
|
55
|
+
signal?.addEventListener('abort', fail, { once: true });
|
|
56
|
+
const rpc = (method, params = {}) => new Promise((resolve, reject) => {
|
|
57
|
+
if (stopped || signal?.aborted) {
|
|
58
|
+
reject(new ConnectorError('interrupted'));
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const id = ++sequence;
|
|
62
|
+
pending.set(id, { resolve, reject });
|
|
63
|
+
child.stdin.write(JSON.stringify({ id, method, params }) + '\n');
|
|
64
|
+
});
|
|
65
|
+
try {
|
|
66
|
+
await rpc('initialize', { clientInfo: { name: 'trackqa_connect', version: '0.1.0' } });
|
|
67
|
+
child.stdin.write(JSON.stringify({ method: 'initialized', params: {} }) + '\n');
|
|
68
|
+
const config = await rpc('config/read');
|
|
69
|
+
const servers = config?.config?.mcp_servers;
|
|
70
|
+
requireValue(servers && Object.keys(servers).length === 1 && servers[setup.serverName]?.url === setup.endpoint, 'verification_failed');
|
|
71
|
+
const thread = await rpc('thread/start', { cwd: process.cwd(), ephemeral: true, approvalPolicy: 'never', sandbox: 'read-only' });
|
|
72
|
+
requireValue(typeof thread?.thread?.id === 'string', 'verification_failed');
|
|
73
|
+
const threadId = thread.thread.id;
|
|
74
|
+
stage = 'discovery';
|
|
75
|
+
const result = await rpc('mcpServerStatus/list', { threadId, serverName: setup.serverName, detail: 'toolsAndAuthOnly', limit: 1 });
|
|
76
|
+
const server = result?.data?.find((item) => item.name === setup.serverName);
|
|
77
|
+
requireValue(server && !server.toolsError && !startupError, 'verification_failed');
|
|
78
|
+
const names = Object.values(server.tools ?? {}).map((tool) => tool.name);
|
|
79
|
+
requireValue(names.length === tools.length && tools.every(name => names.includes(name)), 'verification_failed');
|
|
80
|
+
const call = async (tool) => {
|
|
81
|
+
const result = await rpc('mcpServer/tool/call', { threadId, server: setup.serverName, tool, arguments: {} });
|
|
82
|
+
requireValue(!result?.isError, 'verification_failed');
|
|
83
|
+
return result.structuredContent ?? JSON.parse(result.content?.find((item) => item.type === 'text')?.text ?? 'null');
|
|
84
|
+
};
|
|
85
|
+
stage = 'connection_status';
|
|
86
|
+
const status = await call('trackqa_connection_status');
|
|
87
|
+
requireValue(status?.project_id === setup.projectId && status.database_connected === true && status.finding_submission === true && status.issue_writes_allowed === false, 'verification_failed');
|
|
88
|
+
authorization = true;
|
|
89
|
+
stage = 'project_scope';
|
|
90
|
+
const projects = await call('trackqa_list_projects');
|
|
91
|
+
requireValue(projects?.projects?.length === 1 && projects.projects[0].id === setup.projectId, 'verification_failed');
|
|
92
|
+
return { authorized: true, verified: true, code: 'verified' };
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return { authorized: authorization, verified: false, code: signal?.aborted ? 'interrupted' : `verification_${stage}_failed` };
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
clearTimeout(timer);
|
|
99
|
+
signal?.removeEventListener('abort', fail);
|
|
100
|
+
lines.close();
|
|
101
|
+
child.stdin?.end();
|
|
102
|
+
if (child.exitCode === null) {
|
|
103
|
+
await new Promise(resolve => {
|
|
104
|
+
const force = setTimeout(() => { child.kill('SIGKILL'); resolve(); }, 1500);
|
|
105
|
+
child.once('exit', () => { clearTimeout(force); resolve(); });
|
|
106
|
+
child.kill();
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { realpath, stat, readFile, readdir } from 'node:fs/promises';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { capture, clientEnvironment, foreground } from "../process.js";
|
|
6
|
+
import { ConnectorError, requireValue } from "../core.js";
|
|
7
|
+
import { verifyCodex } from "./codex-verify.js";
|
|
8
|
+
export function trustedLocation(file, home = os.homedir(), platform = process.platform, cwd = process.cwd()) {
|
|
9
|
+
const paths = platform === 'win32' ? path.win32 : path.posix;
|
|
10
|
+
const normalized = paths.resolve(file).toLowerCase();
|
|
11
|
+
const within = (root) => normalized.startsWith(paths.resolve(root).toLowerCase() + paths.sep);
|
|
12
|
+
if (!paths.isAbsolute(file) || within(cwd) || normalized === paths.resolve(cwd).toLowerCase())
|
|
13
|
+
return false;
|
|
14
|
+
if (platform === 'win32') {
|
|
15
|
+
if (paths.basename(file).toLowerCase() !== 'codex.exe')
|
|
16
|
+
return false;
|
|
17
|
+
const programFiles = process.env.ProgramFiles ?? 'C:\\Program Files';
|
|
18
|
+
return within(programFiles) || within(paths.join(home, 'AppData', 'Local', 'Programs'))
|
|
19
|
+
|| within(paths.join(home, '.codex', 'bin'))
|
|
20
|
+
|| ['.vscode', '.vscode-insiders', '.antigravity-ide'].some(editor => {
|
|
21
|
+
const base = paths.join(home, editor, 'extensions');
|
|
22
|
+
return within(base) && /^openai\.chatgpt-[^\\/]+[\\/]bin[\\/]/i.test(paths.relative(base, file));
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
return ['/usr/bin', '/usr/local/bin', '/opt', paths.join(home, '.local', 'bin'), paths.join(home, '.codex', 'bin')].some(within);
|
|
26
|
+
}
|
|
27
|
+
export function editorBundled(file) {
|
|
28
|
+
return /\/\.(?:vscode(?:-insiders)?|antigravity-ide)\/extensions\/openai\.chatgpt-[^/]+\/bin\//i.test(file.replaceAll('\\', '/'));
|
|
29
|
+
}
|
|
30
|
+
const sha = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
31
|
+
export async function checkExecutable(file) {
|
|
32
|
+
requireValue(path.isAbsolute(file) && trustedLocation(file), 'executable_untrusted');
|
|
33
|
+
const canonical = await realpath(file);
|
|
34
|
+
requireValue(trustedLocation(canonical) && (process.platform !== 'win32' || path.resolve(canonical).toLowerCase() === path.resolve(file).toLowerCase()), 'executable_untrusted');
|
|
35
|
+
const info = await stat(canonical);
|
|
36
|
+
requireValue(info.isFile() && (process.platform === 'win32' || !(info.mode & 0o022)), 'executable_untrusted');
|
|
37
|
+
return { path: canonical, sha256: sha(await readFile(canonical)) };
|
|
38
|
+
}
|
|
39
|
+
export async function detectCodex(codexHome, explicit, env = process.env, run = capture, inspect = checkExecutable) {
|
|
40
|
+
const candidates = explicit ? [explicit] : (env.PATH ?? '').split(path.delimiter).filter(folder => path.isAbsolute(folder))
|
|
41
|
+
.map(folder => path.join(folder, process.platform === 'win32' ? 'codex.exe' : 'codex'));
|
|
42
|
+
if (!explicit && env.PATH && process.platform === 'win32') {
|
|
43
|
+
candidates.push(path.join(os.homedir(), '.codex', 'bin', 'codex.exe'));
|
|
44
|
+
// Known publisher paths only: do not recursively scan arbitrary workspaces.
|
|
45
|
+
for (const editor of ['.vscode', '.vscode-insiders', '.antigravity-ide']) {
|
|
46
|
+
const folder = path.join(os.homedir(), editor, 'extensions');
|
|
47
|
+
const extensions = await readdir(folder).catch(() => []);
|
|
48
|
+
for (const extension of extensions.filter(name => /^openai\.chatgpt-[A-Za-z0-9.-]+$/.test(name)).sort().reverse()) {
|
|
49
|
+
candidates.push(path.join(folder, extension, 'bin', `windows-${process.arch === 'arm64' ? 'aarch64' : 'x86_64'}`, 'codex.exe'));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const childEnv = clientEnvironment(codexHome, env);
|
|
54
|
+
// Prefer an installed native CLI over an editor's partial package. Explicit
|
|
55
|
+
// selections are respected; never silently replace the executable requested.
|
|
56
|
+
const ordered = explicit ? candidates : [...new Map(candidates.map(file => [process.platform === 'win32' ? file.toLowerCase() : file, file])).values()]
|
|
57
|
+
.sort((a, b) => Number(editorBundled(a)) - Number(editorBundled(b)));
|
|
58
|
+
let fallback;
|
|
59
|
+
let unsupported;
|
|
60
|
+
for (const candidate of ordered) {
|
|
61
|
+
let executable;
|
|
62
|
+
try {
|
|
63
|
+
executable = await inspect(candidate);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
if (explicit)
|
|
67
|
+
throw new ConnectorError('executable_untrusted');
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const version = /^codex-cli (\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?)\s*$/.exec((await run(executable.path, ['--version'], childEnv)).trim())?.[1];
|
|
72
|
+
requireValue(version, 'client_unsupported');
|
|
73
|
+
// Capabilities, rather than an invented version threshold, determine support.
|
|
74
|
+
for (const [args, expected] of [
|
|
75
|
+
[['mcp', 'add', '--help'], ['--url', '--bearer-token-env-var']],
|
|
76
|
+
[['mcp', 'get', '--help'], ['--json']], [['mcp', 'list', '--help'], ['--json']],
|
|
77
|
+
[['mcp', 'remove', '--help'], ['NAME']],
|
|
78
|
+
]) {
|
|
79
|
+
const help = await run(executable.path, args, childEnv);
|
|
80
|
+
requireValue(expected.every(flag => help.includes(flag)), 'client_unsupported');
|
|
81
|
+
}
|
|
82
|
+
const help = await run(executable.path, ['--help'], childEnv);
|
|
83
|
+
let foregroundMode = editorBundled(executable.path) ? 'unsupported' : 'direct';
|
|
84
|
+
if (/--no-daemon\b/.test(help)) {
|
|
85
|
+
// Help must both advertise the option and accept it. Do not optimistically
|
|
86
|
+
// retry a failed interactive session with a second credentialed process.
|
|
87
|
+
foregroundMode = 'unsupported';
|
|
88
|
+
try {
|
|
89
|
+
await run(executable.path, ['--no-daemon', '--help'], childEnv);
|
|
90
|
+
foregroundMode = 'no-daemon';
|
|
91
|
+
}
|
|
92
|
+
catch { /* Clear pre-auth diagnostic below. */ }
|
|
93
|
+
}
|
|
94
|
+
let appServer = false;
|
|
95
|
+
try {
|
|
96
|
+
appServer = /app.server/.test(await run(executable.path, ['app-server', '--stdio', '--help'], childEnv));
|
|
97
|
+
}
|
|
98
|
+
catch { /* Manual verification remains available. */ }
|
|
99
|
+
requireValue(sha(await readFile(executable.path)) === executable.sha256, 'executable_changed');
|
|
100
|
+
const detected = { ...executable, version, appServer, foregroundMode };
|
|
101
|
+
if (foregroundMode !== 'unsupported' || explicit)
|
|
102
|
+
return detected;
|
|
103
|
+
fallback ??= detected; // Status/disconnect must remain usable for recovery.
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
if (explicit || (error instanceof ConnectorError && error.code === 'executable_changed'))
|
|
107
|
+
throw error;
|
|
108
|
+
unsupported = error;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (fallback)
|
|
112
|
+
return fallback;
|
|
113
|
+
if (unsupported)
|
|
114
|
+
throw unsupported;
|
|
115
|
+
throw new ConnectorError('client_not_found');
|
|
116
|
+
}
|
|
117
|
+
export function fingerprint(value) {
|
|
118
|
+
const stable = (item) => Array.isArray(item) ? item.map(stable) : item && typeof item === 'object'
|
|
119
|
+
? Object.fromEntries(Object.entries(item).sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => [key, stable(value)])) : item;
|
|
120
|
+
return sha(Buffer.from(JSON.stringify(stable(value))));
|
|
121
|
+
}
|
|
122
|
+
export function matchesEntry(value, setup) {
|
|
123
|
+
if (!value || typeof value !== 'object')
|
|
124
|
+
return false;
|
|
125
|
+
const entry = value;
|
|
126
|
+
const transport = entry.transport;
|
|
127
|
+
return entry.name === setup.serverName && entry.enabled === true && transport?.type === 'streamable_http'
|
|
128
|
+
&& transport.url === setup.endpoint && transport.bearer_token_env_var === setup.envVar
|
|
129
|
+
&& !Object.keys(transport.http_headers ?? {}).length && !Object.keys(transport.env_http_headers ?? {}).length;
|
|
130
|
+
}
|
|
131
|
+
export function codexAdapter(executable, codexHome, run = capture, environment = process.env, launchForeground = foreground) {
|
|
132
|
+
const env = clientEnvironment(codexHome, environment);
|
|
133
|
+
async function assertIntegrity() { requireValue(sha(await readFile(executable.path)) === executable.sha256, 'executable_changed'); }
|
|
134
|
+
async function command(args) { await assertIntegrity(); return run(executable.path, args, env); }
|
|
135
|
+
async function entry(setup) {
|
|
136
|
+
let rows;
|
|
137
|
+
try {
|
|
138
|
+
rows = JSON.parse(await command(['mcp', 'list', '--json']));
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
throw new ConnectorError('configuration_unreadable');
|
|
142
|
+
}
|
|
143
|
+
requireValue(Array.isArray(rows) && rows.every(row => typeof row.name === 'string'), 'configuration_unreadable');
|
|
144
|
+
if (!rows.some(row => row.name === setup.serverName))
|
|
145
|
+
return null;
|
|
146
|
+
try {
|
|
147
|
+
return JSON.parse(await command(['mcp', 'get', setup.serverName, '--json']));
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
throw new ConnectorError('configuration_unreadable');
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
executable, codexHome, entry,
|
|
155
|
+
async install(setup) {
|
|
156
|
+
requireValue(await entry(setup) === null, 'configuration_conflict');
|
|
157
|
+
await command(['mcp', 'add', setup.serverName, '--url', setup.endpoint, '--bearer-token-env-var', setup.envVar]);
|
|
158
|
+
const installed = await entry(setup);
|
|
159
|
+
requireValue(matchesEntry(installed, setup), 'installation_unconfirmed');
|
|
160
|
+
return fingerprint(installed);
|
|
161
|
+
},
|
|
162
|
+
async remove(setup, hash) {
|
|
163
|
+
const installed = await entry(setup);
|
|
164
|
+
if (installed === null)
|
|
165
|
+
return;
|
|
166
|
+
requireValue(hash && matchesEntry(installed, setup) && fingerprint(installed) === hash, 'configuration_changed');
|
|
167
|
+
await command(['mcp', 'remove', setup.serverName]);
|
|
168
|
+
requireValue(await entry(setup) === null, 'removal_unconfirmed');
|
|
169
|
+
},
|
|
170
|
+
async launch(setup, token, signal) {
|
|
171
|
+
requireValue(executable.foregroundMode === 'no-daemon' || executable.foregroundMode === 'direct', 'client_foreground_unsupported');
|
|
172
|
+
await assertIntegrity();
|
|
173
|
+
const childEnv = { ...env, [setup.envVar]: token };
|
|
174
|
+
token = '';
|
|
175
|
+
try {
|
|
176
|
+
const running = launchForeground(executable.path, childEnv, signal, undefined, executable.foregroundMode === 'no-daemon' ? ['--no-daemon'] : []);
|
|
177
|
+
delete childEnv[setup.envVar]; // spawn has copied the environment into the child.
|
|
178
|
+
await running;
|
|
179
|
+
}
|
|
180
|
+
finally {
|
|
181
|
+
delete childEnv[setup.envVar];
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
async verify(setup, token, signal) {
|
|
185
|
+
await assertIntegrity();
|
|
186
|
+
if (!executable.appServer)
|
|
187
|
+
return { authorized: null, verified: false, code: 'manual_verification_required' };
|
|
188
|
+
return verifyCodex(executable.path, codexHome, setup, token, signal);
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { lstat, mkdir, open, realpath } from 'node:fs/promises';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { ConnectorError, requireValue } from "../core.js";
|
|
5
|
+
import { fingerprint } from "./codex.js";
|
|
6
|
+
import { commitWindowsSettings } from "./gemini-windows.js";
|
|
7
|
+
const object = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
|
|
8
|
+
const sha = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
9
|
+
export function parseGeminiSettings(bytes) {
|
|
10
|
+
requireValue(bytes.length <= 1_048_576, 'configuration_unreadable');
|
|
11
|
+
try {
|
|
12
|
+
const text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
13
|
+
const value = JSON.parse(text);
|
|
14
|
+
requireValue(object(value) && (value.mcpServers === undefined || (object(value.mcpServers) && Object.values(value.mcpServers).every(object))), 'configuration_unreadable');
|
|
15
|
+
// JSON.parse accepts duplicate keys and unrepresentable numbers. Reject
|
|
16
|
+
// these rather than silently losing unrelated data during serialization.
|
|
17
|
+
const tokens = text.match(/"(?:[^"\\]|\\.)*"|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null|[{}[\]:,]/g) ?? [];
|
|
18
|
+
let index = 0;
|
|
19
|
+
const visit = (depth) => {
|
|
20
|
+
requireValue(depth <= 64, 'configuration_unreadable');
|
|
21
|
+
const token = tokens[index++];
|
|
22
|
+
if (token === '{') {
|
|
23
|
+
const keys = new Set();
|
|
24
|
+
while (tokens[index] !== '}') {
|
|
25
|
+
const key = JSON.parse(tokens[index++]);
|
|
26
|
+
requireValue(!keys.has(key), 'configuration_unreadable');
|
|
27
|
+
keys.add(key);
|
|
28
|
+
index++;
|
|
29
|
+
visit(depth + 1);
|
|
30
|
+
if (tokens[index] !== ',')
|
|
31
|
+
break;
|
|
32
|
+
index++;
|
|
33
|
+
}
|
|
34
|
+
index++;
|
|
35
|
+
}
|
|
36
|
+
else if (token === '[') {
|
|
37
|
+
while (tokens[index] !== ']') {
|
|
38
|
+
visit(depth + 1);
|
|
39
|
+
if (tokens[index] !== ',')
|
|
40
|
+
break;
|
|
41
|
+
index++;
|
|
42
|
+
}
|
|
43
|
+
index++;
|
|
44
|
+
}
|
|
45
|
+
else if (/^-?\d/.test(token)) {
|
|
46
|
+
const number = Number(token);
|
|
47
|
+
requireValue(Number.isFinite(number) && (!Number.isInteger(number) || Number.isSafeInteger(number)), 'configuration_unreadable');
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
visit(0);
|
|
51
|
+
return value;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
throw new ConnectorError('configuration_unreadable');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async function directory(home, create = false) {
|
|
58
|
+
requireValue(path.isAbsolute(home), 'configuration_location_untrusted');
|
|
59
|
+
const dir = path.join(home, '.gemini');
|
|
60
|
+
try {
|
|
61
|
+
const info = await lstat(dir);
|
|
62
|
+
requireValue(info.isDirectory() && !info.isSymbolicLink() && (await realpath(dir)).toLowerCase() === path.resolve(dir).toLowerCase(), 'configuration_location_untrusted');
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (error.code !== 'ENOENT')
|
|
66
|
+
throw error;
|
|
67
|
+
if (create) {
|
|
68
|
+
await mkdir(dir, { mode: 0o700 });
|
|
69
|
+
return directory(home);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return dir;
|
|
73
|
+
}
|
|
74
|
+
async function snapshot(file) {
|
|
75
|
+
let info;
|
|
76
|
+
try {
|
|
77
|
+
info = await lstat(file, { bigint: true });
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
if (error.code === 'ENOENT')
|
|
81
|
+
return { exists: false, hash: null, identity: '', bytes: null, value: {} };
|
|
82
|
+
throw new ConnectorError('configuration_unreadable');
|
|
83
|
+
}
|
|
84
|
+
requireValue(info.isFile() && !info.isSymbolicLink() && info.nlink === 1n && info.size <= 1048576n, 'configuration_unreadable');
|
|
85
|
+
const identity = (stat) => [stat.dev, stat.ino, stat.mtimeNs, stat.ctimeNs, stat.size, stat.mode].join(':');
|
|
86
|
+
const handle = await open(file, 'r');
|
|
87
|
+
try {
|
|
88
|
+
requireValue(identity(await handle.stat({ bigint: true })) === identity(info), 'configuration_changed');
|
|
89
|
+
const bytes = await handle.readFile();
|
|
90
|
+
requireValue(identity(await handle.stat({ bigint: true })) === identity(info), 'configuration_changed');
|
|
91
|
+
return { exists: true, hash: sha(bytes), identity: identity(info), bytes, value: parseGeminiSettings(bytes) };
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
await handle.close();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
export async function readGeminiSettings(home) {
|
|
98
|
+
return (await snapshot(path.join(await directory(home), 'settings.json'))).value;
|
|
99
|
+
}
|
|
100
|
+
export const geminiHeader = (setup) => `Bearer \${${setup.envVar}}`;
|
|
101
|
+
export function geminiEntry(setup) {
|
|
102
|
+
requireValue(/^trackqa_[A-Za-z0-9_]+$/.test(setup.serverName) && /^TRACKQA_[A-Z0-9_]+$/.test(setup.envVar), 'configuration_conflict');
|
|
103
|
+
return { url: setup.endpoint, type: 'http', headers: { Authorization: geminiHeader(setup) } };
|
|
104
|
+
}
|
|
105
|
+
export function matchesGeminiEntry(value, setup) {
|
|
106
|
+
if (!object(value) || !object(value.headers))
|
|
107
|
+
return false;
|
|
108
|
+
const legacy = value.httpUrl === setup.endpoint && value.url === undefined && (value.type === undefined || value.type === 'http');
|
|
109
|
+
const current = value.url === setup.endpoint && value.type === 'http' && value.httpUrl === undefined;
|
|
110
|
+
return (legacy || current) && value.command === undefined && value.trust !== true
|
|
111
|
+
&& Object.keys(value.headers).length === 1 && value.headers.Authorization === geminiHeader(setup)
|
|
112
|
+
&& Object.keys(value).every(key => ['httpUrl', 'url', 'type', 'headers', 'timeout', 'description', 'trust'].includes(key));
|
|
113
|
+
}
|
|
114
|
+
// Optimistic checks + an advisory lock for cooperating writers, NOT a filesystem
|
|
115
|
+
// compare-and-swap. Non-cooperating editors must be closed during setup/cleanup.
|
|
116
|
+
export async function editGeminiSettings(home, setup, operation, hash = null, commit = commitWindowsSettings) {
|
|
117
|
+
const ownedEntry = geminiEntry(setup);
|
|
118
|
+
const dir = await directory(home), file = path.join(dir, 'settings.json');
|
|
119
|
+
const before = await snapshot(file), existing = (before.value.mcpServers ?? {});
|
|
120
|
+
if (operation === 'remove' && !Object.hasOwn(existing, setup.serverName))
|
|
121
|
+
return null;
|
|
122
|
+
if (operation === 'install')
|
|
123
|
+
requireValue(!Object.hasOwn(existing, setup.serverName), 'configuration_conflict');
|
|
124
|
+
else
|
|
125
|
+
requireValue(hash && matchesGeminiEntry(existing[setup.serverName], setup) && fingerprint(existing[setup.serverName]) === hash, 'configuration_changed');
|
|
126
|
+
const entries = { ...existing };
|
|
127
|
+
if (operation === 'install')
|
|
128
|
+
entries[setup.serverName] = ownedEntry;
|
|
129
|
+
else
|
|
130
|
+
delete entries[setup.serverName];
|
|
131
|
+
const updated = { ...before.value, mcpServers: entries };
|
|
132
|
+
const bytes = Buffer.from(JSON.stringify(updated, null, 2) + '\n');
|
|
133
|
+
parseGeminiSettings(bytes);
|
|
134
|
+
await directory(home, true);
|
|
135
|
+
const current = await snapshot(file);
|
|
136
|
+
requireValue(current.exists === before.exists && current.identity === before.identity && current.hash === before.hash, 'configuration_changed');
|
|
137
|
+
await commit(file, { exists: before.exists, hash: before.hash }, bytes);
|
|
138
|
+
const after = await snapshot(file);
|
|
139
|
+
requireValue(after.hash === sha(bytes), 'configuration_write_uncertain');
|
|
140
|
+
return operation === 'install' ? fingerprint(entries[setup.serverName]) : null;
|
|
141
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { tools } from "../core.js";
|
|
2
|
+
// A native `mcp list` result is NOT tool discovery or a successful read call.
|
|
3
|
+
// Gemini has no verified equivalent of Codex's model-free app-server tool API.
|
|
4
|
+
export function geminiVerification() {
|
|
5
|
+
return { authorized: null, verified: false, code: 'manual_verification_required' };
|
|
6
|
+
}
|
|
7
|
+
export function geminiInstructions(setup) {
|
|
8
|
+
return `Gemini CLI availability and token-free installation passed. Client-side discovery: not verified. Authenticated reads: not verified. Gemini account sign-in is separate from TrackQA. In Gemini use /mcp to check this server and exactly these five tools: ${tools.join(', ')}. Call only trackqa_connection_status and trackqa_list_projects; confirm only project ${setup.projectId}. Do not submit a finding. Exit with /quit for cleanup. These manual checks are not machine-verified; the connector will keep its result unverified.`;
|
|
9
|
+
}
|
|
10
|
+
export function assessGeminiDiscovery(names) {
|
|
11
|
+
const discovered = tools.filter(name => names.includes(name));
|
|
12
|
+
return { discovered, complete: names.length === tools.length && new Set(names).size === tools.length && discovered.length === tools.length,
|
|
13
|
+
authenticatedReadsVerified: false };
|
|
14
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { ConnectorError } from "../core.js";
|
|
4
|
+
// Static script only: configuration bytes go over stdin, never command args.
|
|
5
|
+
// Windows PowerShell 5.1/.NET File.Replace preserves the destination DACL.
|
|
6
|
+
export const windowsSettingsScript = String.raw `
|
|
7
|
+
$ErrorActionPreference = 'Stop'
|
|
8
|
+
Set-StrictMode -Version Latest
|
|
9
|
+
$code = 'configuration_write_failed'
|
|
10
|
+
$lock = $null; $stream = $null; $temp = $null; $backup = $null; $ownsLock = $false
|
|
11
|
+
function Hash-Bytes([byte[]]$bytes) {
|
|
12
|
+
$sha = [Security.Cryptography.SHA256]::Create()
|
|
13
|
+
try { return ([BitConverter]::ToString($sha.ComputeHash($bytes))).Replace('-', '').ToLowerInvariant() }
|
|
14
|
+
finally { $sha.Dispose() }
|
|
15
|
+
}
|
|
16
|
+
function ACL-Key($acl) {
|
|
17
|
+
$raw = [Security.AccessControl.RawSecurityDescriptor]::new($acl.GetSecurityDescriptorBinaryForm(), 0)
|
|
18
|
+
# ReplaceFile may add the informational AUTO_INHERITED flag even to a
|
|
19
|
+
# protected DACL. Compare every ACE, owner, group and protection flag; ignore
|
|
20
|
+
# only that bit, which does not add permissions or enable inheritance.
|
|
21
|
+
$flags = [int]$raw.ControlFlags -band (-bnot [int][Security.AccessControl.ControlFlags]::DiscretionaryAclAutoInherited)
|
|
22
|
+
$raw.SetFlags([Security.AccessControl.ControlFlags]$flags)
|
|
23
|
+
return $raw.GetSddlForm([Security.AccessControl.AccessControlSections]::All)
|
|
24
|
+
}
|
|
25
|
+
function Private-File([string]$file) {
|
|
26
|
+
$sid = [Security.Principal.WindowsIdentity]::GetCurrent().User
|
|
27
|
+
$acl = [Security.AccessControl.FileSecurity]::new()
|
|
28
|
+
$acl.SetOwner($sid); $acl.SetAccessRuleProtection($true, $false)
|
|
29
|
+
foreach ($identity in @($sid, [Security.Principal.SecurityIdentifier]::new('S-1-5-18'), [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544'))) {
|
|
30
|
+
$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new($identity, [Security.AccessControl.FileSystemRights]::FullControl, [Security.AccessControl.AccessControlType]::Allow))
|
|
31
|
+
}
|
|
32
|
+
return [IO.FileStream]::new($file, [IO.FileMode]::CreateNew, [Security.AccessControl.FileSystemRights]::FullControl, [IO.FileShare]::None, 4096, [IO.FileOptions]::WriteThrough, $acl)
|
|
33
|
+
}
|
|
34
|
+
function Check-Original {
|
|
35
|
+
if ([IO.File]::Exists($file) -ne $request.exists) { throw 'conflict' }
|
|
36
|
+
if ($request.exists) {
|
|
37
|
+
$info = [IO.FileInfo]::new($file)
|
|
38
|
+
if (($info.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'conflict' }
|
|
39
|
+
if ((Hash-Bytes ([IO.File]::ReadAllBytes($file))) -cne $request.hash) { throw 'conflict' }
|
|
40
|
+
if ($null -ne $originalAcl -and (ACL-Key ([IO.File]::GetAccessControl($file))) -cne (ACL-Key $originalAcl)) { throw 'conflict' }
|
|
41
|
+
if ($null -ne $stamp -and ($info.CreationTimeUtc.Ticks.ToString() + ':' + $info.LastWriteTimeUtc.Ticks.ToString()) -cne $stamp) { throw 'conflict' }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
$request = [Console]::In.ReadToEnd() | ConvertFrom-Json
|
|
46
|
+
$file = [IO.Path]::GetFullPath([string]$request.file)
|
|
47
|
+
$dir = [IO.Path]::GetDirectoryName($file)
|
|
48
|
+
if ([IO.Path]::GetFileName($file) -cne 'settings.json' -or ([IO.File]::GetAttributes($dir) -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'conflict' }
|
|
49
|
+
$lockPath = [IO.Path]::Combine($dir, '.trackqa-settings.lock')
|
|
50
|
+
try { $lock = Private-File $lockPath; $ownsLock = $true } catch { $code = 'configuration_busy'; throw }
|
|
51
|
+
$pidBytes = [Text.Encoding]::UTF8.GetBytes($PID.ToString()); $lock.Write($pidBytes, 0, $pidBytes.Length); $lock.Flush($true)
|
|
52
|
+
$originalAcl = $null; $stamp = $null
|
|
53
|
+
Check-Original
|
|
54
|
+
if ($request.exists) {
|
|
55
|
+
$originalAcl = [IO.File]::GetAccessControl($file)
|
|
56
|
+
$info = [IO.FileInfo]::new($file)
|
|
57
|
+
$stamp = $info.CreationTimeUtc.Ticks.ToString() + ':' + $info.LastWriteTimeUtc.Ticks.ToString()
|
|
58
|
+
}
|
|
59
|
+
$suffix = [Guid]::NewGuid().ToString('N')
|
|
60
|
+
$temp = [IO.Path]::Combine($dir, '.trackqa-settings-' + $suffix + '.tmp')
|
|
61
|
+
$backup = [IO.Path]::Combine($dir, '.trackqa-settings-recovery-' + $suffix + '.bak')
|
|
62
|
+
$bytes = [Convert]::FromBase64String($request.bytes)
|
|
63
|
+
$stream = Private-File $temp
|
|
64
|
+
try { $stream.Write($bytes, 0, $bytes.Length); $stream.Flush($true) } finally { $stream.Dispose(); $stream = $null }
|
|
65
|
+
Check-Original
|
|
66
|
+
# Commit point: no delete/truncate fallback and no automatic retry.
|
|
67
|
+
if ($request.exists) { [IO.File]::Replace($temp, $file, $backup, $false) }
|
|
68
|
+
else { [IO.File]::Move($temp, $file) }
|
|
69
|
+
if ((Hash-Bytes ([IO.File]::ReadAllBytes($file))) -cne (Hash-Bytes $bytes)) { $code = 'configuration_write_uncertain'; throw 'postcheck' }
|
|
70
|
+
if ($request.exists) {
|
|
71
|
+
if ((Hash-Bytes ([IO.File]::ReadAllBytes($backup))) -cne $request.hash -or (ACL-Key ([IO.File]::GetAccessControl($file))) -cne (ACL-Key $originalAcl)) {
|
|
72
|
+
$code = 'configuration_write_uncertain'; throw 'postcheck'
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if ([IO.File]::Exists($backup)) { [IO.File]::Delete($backup) }
|
|
76
|
+
$code = 'ok'
|
|
77
|
+
} catch {
|
|
78
|
+
if ($_.Exception.Message -eq 'conflict') { $code = 'configuration_changed' }
|
|
79
|
+
if ($null -ne $backup -and [IO.File]::Exists($backup)) {
|
|
80
|
+
# Restore only to an absent destination using a no-clobber move. Never
|
|
81
|
+
# overwrite a file that another writer may have created or edited.
|
|
82
|
+
if (-not [IO.File]::Exists($file)) {
|
|
83
|
+
try { [IO.File]::Move($backup, $file) } catch { $code = 'configuration_write_uncertain' }
|
|
84
|
+
} else { $code = 'configuration_write_uncertain' }
|
|
85
|
+
}
|
|
86
|
+
} finally {
|
|
87
|
+
if ($null -ne $stream) { $stream.Dispose() }
|
|
88
|
+
if ($null -ne $temp -and [IO.File]::Exists($temp)) {
|
|
89
|
+
try { [IO.File]::Delete($temp) } catch { $code = 'configuration_write_uncertain' }
|
|
90
|
+
}
|
|
91
|
+
if ($null -ne $lock) { $lock.Dispose() }
|
|
92
|
+
if ($ownsLock) { try { [IO.File]::Delete($lockPath) } catch { $code = 'configuration_write_uncertain' } }
|
|
93
|
+
}
|
|
94
|
+
[Console]::Out.Write($code)
|
|
95
|
+
`;
|
|
96
|
+
export async function commitWindowsSettings(file, expected, bytes, spawnFn = spawn, timeoutMs = 15_000) {
|
|
97
|
+
if (process.platform !== 'win32')
|
|
98
|
+
throw new ConnectorError('client_unsupported');
|
|
99
|
+
const shell = path.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
|
|
100
|
+
await new Promise((resolve, reject) => {
|
|
101
|
+
const env = {};
|
|
102
|
+
for (const key of ['SystemRoot', 'WINDIR', 'TEMP', 'TMP'])
|
|
103
|
+
if (process.env[key])
|
|
104
|
+
env[key] = process.env[key];
|
|
105
|
+
const child = spawnFn(shell, ['-NoProfile', '-NonInteractive', '-Command', windowsSettingsScript], { env, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
106
|
+
let output = '', size = 0, settled = false;
|
|
107
|
+
const finish = (code) => {
|
|
108
|
+
if (settled)
|
|
109
|
+
return;
|
|
110
|
+
settled = true;
|
|
111
|
+
clearTimeout(timer);
|
|
112
|
+
if (code === 'ok')
|
|
113
|
+
resolve();
|
|
114
|
+
else {
|
|
115
|
+
child.kill();
|
|
116
|
+
reject(new ConnectorError(['configuration_busy', 'configuration_changed', 'configuration_write_failed'].includes(code) ? code : 'configuration_write_uncertain'));
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
const timer = setTimeout(() => finish('configuration_write_uncertain'), timeoutMs);
|
|
120
|
+
child.on('error', () => finish('configuration_write_uncertain'));
|
|
121
|
+
for (const stream of [child.stdin, child.stdout, child.stderr])
|
|
122
|
+
stream?.on('error', () => finish('configuration_write_uncertain'));
|
|
123
|
+
for (const stream of [child.stdout, child.stderr])
|
|
124
|
+
stream?.on('data', (chunk) => {
|
|
125
|
+
size += chunk.length;
|
|
126
|
+
if (size > 4096)
|
|
127
|
+
finish('configuration_write_uncertain');
|
|
128
|
+
else if (stream === child.stdout)
|
|
129
|
+
output += chunk.toString('utf8');
|
|
130
|
+
});
|
|
131
|
+
child.on('close', code => finish(code === 0 ? output.trim() : 'configuration_write_uncertain'));
|
|
132
|
+
child.stdin.end(JSON.stringify({ file, ...expected, bytes: bytes.toString('base64') }));
|
|
133
|
+
});
|
|
134
|
+
}
|