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.
Files changed (26) hide show
  1. package/README.md +161 -2
  2. package/dist/src/lib/mcpOnboarding.js +143 -0
  3. package/dist/tools/mcp-pilot/google-pkce.mjs +244 -0
  4. package/dist/tools/trackqa-connect/src/auth.js +153 -0
  5. package/dist/tools/trackqa-connect/src/cli.js +246 -0
  6. package/dist/tools/trackqa-connect/src/clients/adapter.js +3 -0
  7. package/dist/tools/trackqa-connect/src/clients/claude-session.js +78 -0
  8. package/dist/tools/trackqa-connect/src/clients/claude.js +113 -0
  9. package/dist/tools/trackqa-connect/src/clients/codex-verify.js +110 -0
  10. package/dist/tools/trackqa-connect/src/clients/codex.js +191 -0
  11. package/dist/tools/trackqa-connect/src/clients/gemini-config.js +141 -0
  12. package/dist/tools/trackqa-connect/src/clients/gemini-verify.js +14 -0
  13. package/dist/tools/trackqa-connect/src/clients/gemini-windows.js +134 -0
  14. package/dist/tools/trackqa-connect/src/clients/gemini.js +249 -0
  15. package/dist/tools/trackqa-connect/src/clients/vscode-copilot-lifecycle.js +97 -0
  16. package/dist/tools/trackqa-connect/src/clients/vscode-copilot-session.js +115 -0
  17. package/dist/tools/trackqa-connect/src/clients/vscode-copilot.js +59 -0
  18. package/dist/tools/trackqa-connect/src/clients/vscode-process.js +123 -0
  19. package/dist/tools/trackqa-connect/src/clients/vscode-session.js +145 -0
  20. package/dist/tools/trackqa-connect/src/clients/vscode.js +123 -0
  21. package/dist/tools/trackqa-connect/src/core.js +87 -0
  22. package/dist/tools/trackqa-connect/src/process.js +76 -0
  23. package/dist/tools/trackqa-connect/src/state.js +80 -0
  24. package/dist/tools/trackqa-connect/src/testing/copilot-fixture-scope.js +103 -0
  25. package/dist/tools/trackqa-connect/src/workflow.js +241 -0
  26. package/package.json +43 -4
@@ -0,0 +1,145 @@
1
+ import { lstat, mkdir, open, realpath, unlink } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { requireValue, setupFor, UUID } from "../core.js";
4
+ import { fingerprint } from "./codex.js";
5
+ export const vscodeEntry = (setup) => ({ type: 'http', url: setup.endpoint, headers: { Authorization: `Bearer \${env:${setup.envVar}}` } });
6
+ export const matchesVSCodeEntry = (value, setup) => fingerprint(value) === fingerprint(vscodeEntry(setup));
7
+ export function vscodePaths(home, setup) {
8
+ requireValue(path.isAbsolute(home) && UUID.test(setup.projectId) && fingerprint(setup) === fingerprint(setupFor(setup.projectId, 'vscode')), 'configuration_location_untrusted');
9
+ const root = path.join(home, setup.projectId), user = path.join(root, 'user-data');
10
+ return { root, user, extensions: path.join(root, 'extensions'), home: path.join(root, 'home'),
11
+ owner: path.join(root, 'owner.json'), process: path.join(root, 'process.json'),
12
+ mcp: path.join(user, 'User', 'mcp.json'), settings: path.join(user, 'User', 'settings.json') };
13
+ }
14
+ const bytes = (value) => Buffer.from(JSON.stringify(value, null, 2) + '\n');
15
+ export function vscodePayloads(setup) {
16
+ return {
17
+ owner: bytes({ owner: 'TrackQA Connect VS Code Local v1', projectId: setup.projectId, server: setup.serverName }),
18
+ mcp: bytes({ servers: { [setup.serverName]: vscodeEntry(setup) } }),
19
+ settings: bytes({ 'update.mode': 'none', 'extensions.autoUpdate': 'off', 'extensions.autoCheckUpdates': false,
20
+ 'telemetry.telemetryLevel': 'off', 'chat.editor.localAgent.enabled': true, 'chat.defaultToCopilotHarness': false,
21
+ 'chat.editor.preferCopilotHarness': false, 'workbench.startupEditor': 'none', 'window.restoreWindows': 'none',
22
+ 'window.title': 'TrackQA Local — ${activeEditorShort}' }),
23
+ };
24
+ }
25
+ export async function safeVSCodeDirectory(dir, create = false) {
26
+ const absolute = path.resolve(dir), root = path.parse(absolute).root;
27
+ let current = root;
28
+ for (const part of absolute.slice(root.length).split(path.sep).filter(Boolean)) {
29
+ current = path.join(current, part);
30
+ if (create) {
31
+ try {
32
+ await mkdir(current, { mode: 0o700 });
33
+ }
34
+ catch (error) {
35
+ if (error.code !== 'EEXIST')
36
+ throw error;
37
+ }
38
+ }
39
+ const info = await lstat(current);
40
+ requireValue(info.isDirectory() && !info.isSymbolicLink() && (await realpath(current)).toLowerCase() === current.toLowerCase(), 'configuration_location_untrusted');
41
+ }
42
+ }
43
+ export async function readVSCodeFile(file) {
44
+ try {
45
+ const info = await lstat(file);
46
+ requireValue(info.isFile() && !info.isSymbolicLink() && info.nlink === 1 && info.size <= 16384
47
+ && (await realpath(file)).toLowerCase() === path.resolve(file).toLowerCase(), 'configuration_changed');
48
+ const handle = await open(file, 'r');
49
+ try {
50
+ const stat = await handle.stat();
51
+ requireValue(stat.ino === info.ino && stat.dev === info.dev, 'configuration_changed');
52
+ const content = await handle.readFile();
53
+ requireValue(content.length <= 16384, 'configuration_changed');
54
+ return content;
55
+ }
56
+ finally {
57
+ await handle.close();
58
+ }
59
+ }
60
+ catch (error) {
61
+ if (error.code === 'ENOENT')
62
+ return null;
63
+ throw error;
64
+ }
65
+ }
66
+ export async function writeVSCodeFile(file, content) {
67
+ const handle = await open(file, 'wx', 0o600);
68
+ try {
69
+ await handle.writeFile(content);
70
+ await handle.sync();
71
+ }
72
+ finally {
73
+ await handle.close();
74
+ }
75
+ }
76
+ async function owned(home, setup) {
77
+ const files = vscodePaths(home, setup);
78
+ try {
79
+ await safeVSCodeDirectory(home);
80
+ await safeVSCodeDirectory(files.root);
81
+ }
82
+ catch (error) {
83
+ if (error.code === 'ENOENT')
84
+ return null;
85
+ throw error;
86
+ }
87
+ requireValue((await readVSCodeFile(files.owner))?.equals(vscodePayloads(setup).owner), 'configuration_conflict');
88
+ return files;
89
+ }
90
+ export async function readVSCodeSession(home, setup) {
91
+ const files = await owned(home, setup);
92
+ if (!files)
93
+ return null;
94
+ for (const dir of [files.user, path.dirname(files.mcp), files.extensions, files.home])
95
+ await safeVSCodeDirectory(dir);
96
+ const content = await readVSCodeFile(files.mcp);
97
+ if (!content)
98
+ return null;
99
+ requireValue(content.equals(vscodePayloads(setup).mcp), 'configuration_changed');
100
+ return vscodeEntry(setup);
101
+ }
102
+ export async function installVSCodeSession(home, setup) {
103
+ const files = vscodePaths(home, setup), payload = vscodePayloads(setup);
104
+ await safeVSCodeDirectory(home, true);
105
+ try {
106
+ await mkdir(files.root, { mode: 0o700 });
107
+ await writeVSCodeFile(files.owner, payload.owner);
108
+ }
109
+ catch (error) {
110
+ if (error.code !== 'EEXIST')
111
+ throw error;
112
+ await owned(home, setup);
113
+ }
114
+ await safeVSCodeDirectory(files.root);
115
+ requireValue(!await readVSCodeFile(files.process), 'vscode_process_unconfirmed');
116
+ for (const file of [files.mcp, files.settings])
117
+ requireValue(!await readVSCodeFile(file), 'configuration_conflict');
118
+ for (const dir of [files.user, path.dirname(files.mcp), files.extensions, files.home,
119
+ path.join(files.home, 'AppData', 'Roaming'), path.join(files.home, 'AppData', 'Local'), path.join(files.home, 'Temp')])
120
+ await safeVSCodeDirectory(dir, true);
121
+ await writeVSCodeFile(files.settings, payload.settings);
122
+ await writeVSCodeFile(files.mcp, payload.mcp);
123
+ return fingerprint(vscodeEntry(setup));
124
+ }
125
+ export async function removeVSCodeSession(home, setup, hash) {
126
+ const files = await owned(home, setup);
127
+ if (!files)
128
+ return;
129
+ for (const dir of [files.user, path.dirname(files.mcp)])
130
+ await safeVSCodeDirectory(dir);
131
+ requireValue(!await readVSCodeFile(files.process), 'vscode_process_unconfirmed');
132
+ requireValue(hash === null || hash === fingerprint(vscodeEntry(setup)), 'configuration_changed');
133
+ // Validate both before deleting either. Never recursively remove IDE state or user files.
134
+ const payload = vscodePayloads(setup);
135
+ for (const key of ['mcp', 'settings']) {
136
+ const content = await readVSCodeFile(files[key]);
137
+ requireValue(!content || content.equals(payload[key]), 'configuration_changed');
138
+ }
139
+ for (const key of ['mcp', 'settings']) {
140
+ if (await readVSCodeFile(files[key]))
141
+ await unlink(files[key]);
142
+ }
143
+ // Keep the ownership marker and runtime-generated profile data. They may include
144
+ // user-created files; removing a connection does not authorize deleting them.
145
+ }
@@ -0,0 +1,123 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { lstat, readFile, realpath, readdir } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ import { ConnectorError, requireValue, TOKEN, tools } from "../core.js";
6
+ import { installVSCodeSession, matchesVSCodeEntry, readVSCodeFile, readVSCodeSession, removeVSCodeSession, vscodePaths, vscodePayloads } from "./vscode-session.js";
7
+ import { launchVSCode, settleVSCodeRun, vscodeRuntime } from "./vscode-process.js";
8
+ const sha = (bytes) => createHash('sha256').update(bytes).digest('hex');
9
+ export function trustedVSCodeLocation(file, home = os.homedir(), platform = process.platform, cwd = process.cwd()) {
10
+ if (platform !== 'win32')
11
+ return false;
12
+ const paths = path.win32, resolved = paths.resolve(file).toLowerCase();
13
+ if (!paths.isAbsolute(file) || paths.basename(file).toLowerCase() !== 'code.exe' || resolved.startsWith(paths.resolve(cwd).toLowerCase() + paths.sep))
14
+ return false;
15
+ return [paths.join(home, 'AppData', 'Local', 'Programs', 'Microsoft VS Code', 'Code.exe'),
16
+ paths.join(process.env.ProgramFiles ?? 'C:\\Program Files', 'Microsoft VS Code', 'Code.exe')].some(candidate => paths.resolve(candidate).toLowerCase() === resolved);
17
+ }
18
+ async function regular(file) {
19
+ const stat = await lstat(file);
20
+ requireValue(stat.isFile() && !stat.isSymbolicLink() && (await realpath(file)).toLowerCase() === path.resolve(file).toLowerCase(), 'executable_untrusted');
21
+ return { path: file, sha256: sha(await readFile(file)) };
22
+ }
23
+ export async function checkVSCodeExecutable(file, trust = trustedVSCodeLocation) {
24
+ requireValue(trust(file), 'executable_untrusted');
25
+ const canonical = await realpath(file);
26
+ requireValue(trust(canonical), 'executable_untrusted');
27
+ const installation = path.dirname(canonical);
28
+ requireValue(!await lstat(path.join(installation, 'data')).catch(error => { if (error.code === 'ENOENT')
29
+ return null; throw error; }), 'client_unsupported');
30
+ const wrapper = path.join(installation, 'bin', 'code.cmd'), text = await readFile(wrapper, 'utf8');
31
+ const versionFolder = /%~dp0\.\.\\([0-9a-f]{10})\\resources\\app\\out\\cli\.js/.exec(text)?.[1];
32
+ requireValue(versionFolder || text.includes('%~dp0..\\resources\\app\\out\\cli.js'), 'client_unsupported');
33
+ const app = path.join(installation, ...(versionFolder ? [versionFolder] : []), 'resources', 'app');
34
+ const productFile = path.join(app, 'product.json'), packageFile = path.join(app, 'package.json'), cliFile = path.join(app, 'out', 'cli.js');
35
+ const product = JSON.parse(await readFile(productFile, 'utf8')), pkg = JSON.parse(await readFile(packageFile, 'utf8'));
36
+ requireValue(product.applicationName === 'code' && product.quality === 'stable' && product.version === pkg.version && /^1\.143\.\d+$/.test(pkg.version), 'client_unsupported');
37
+ const cli = await readFile(cliFile, 'utf8');
38
+ for (const flag of ['user-data-dir', 'extensions-dir', 'use-inmemory-secretstorage', 'disable-updates'])
39
+ requireValue(cli.includes(flag), 'client_unsupported');
40
+ const inputs = await Promise.all([canonical, wrapper, productFile, packageFile, cliFile].map(regular));
41
+ return { path: canonical, sha256: inputs[0].sha256, version: pkg.version, appServer: false, foregroundMode: 'direct', inputs, installation };
42
+ }
43
+ export async function detectVSCode(explicit, source = process.env, inspect = checkVSCodeExecutable) {
44
+ const candidates = explicit ? [explicit] : [path.join(os.homedir(), 'AppData', 'Local', 'Programs', 'Microsoft VS Code', 'Code.exe'),
45
+ path.join(source.ProgramFiles ?? 'C:\\Program Files', 'Microsoft VS Code', 'Code.exe')];
46
+ for (const candidate of candidates) {
47
+ try {
48
+ return await inspect(candidate);
49
+ }
50
+ catch (error) {
51
+ if (explicit || error instanceof ConnectorError && error.code === 'client_unsupported')
52
+ throw error instanceof ConnectorError ? error : new ConnectorError('executable_untrusted');
53
+ }
54
+ }
55
+ throw new ConnectorError('client_not_found');
56
+ }
57
+ export function vscodeEnvironment(home, setup, source = process.env) {
58
+ const files = vscodePaths(home, setup), clean = {};
59
+ for (const key of ['PATH', 'PATHEXT', 'SystemRoot', 'SystemDrive', 'WINDIR', 'COMSPEC', 'TERM', 'COLORTERM', 'LANG', 'LC_ALL',
60
+ 'HTTP_PROXY', 'http_proxy', 'HTTPS_PROXY', 'https_proxy', 'ALL_PROXY', 'all_proxy', 'NO_PROXY', 'no_proxy', 'NODE_USE_SYSTEM_CA', 'NODE_EXTRA_CA_CERTS']) {
61
+ if (source[key] !== undefined)
62
+ clean[key] = source[key];
63
+ }
64
+ return { ...clean, HOME: files.home, USERPROFILE: files.home, APPDATA: path.join(files.home, 'AppData', 'Roaming'),
65
+ LOCALAPPDATA: path.join(files.home, 'AppData', 'Local'), TEMP: path.join(files.home, 'Temp'), TMP: path.join(files.home, 'Temp'), VSCODE_DISABLE_TELEMETRY: '1' };
66
+ }
67
+ export function vscodeLaunchArgs(home, setup) {
68
+ const files = vscodePaths(home, setup);
69
+ return ['--new-window', '--user-data-dir', files.user, '--extensions-dir', files.extensions, '--skip-welcome', '--skip-release-notes',
70
+ '--disable-updates', '--disable-telemetry', '--sync', 'off', '--use-inmemory-secretstorage', '--log', 'warning'];
71
+ }
72
+ export const vscodeVerification = () => ({ authorized: null, verified: false, code: 'manual_verification_required' });
73
+ export function vscodeInstructions(setup) {
74
+ return `Experimental VS Code Local harness only. In the window titled TrackQA Local, use Ctrl+Shift+P > MCP: List Servers > ${setup.serverName} > Start Server. Trust only this server if asked; inspect its Show Output action. Startup alone is not verification. Confirm Running and these five tools: ${tools.join(', ')}. In a separately authorized Local agent session, call only trackqa_connection_status and trackqa_list_projects and confirm project ${setup.projectId}. Do not submit a finding during acceptance. Account/model access is separate; do not switch to Copilot or provider harnesses. Use File > Exit to close the entire isolated instance, then return here for revocation. Manual observations remain unverified by this CLI.`;
75
+ }
76
+ export function vscodeAdapter(exe, sessionHome, source = process.env, runtime = vscodeRuntime) {
77
+ async function integrity() {
78
+ requireValue(!await lstat(path.join(exe.installation, 'data')).catch(error => { if (error.code === 'ENOENT')
79
+ return null; throw error; }), 'executable_changed');
80
+ for (const input of exe.inputs)
81
+ requireValue((await realpath(input.path)).toLowerCase() === input.path.toLowerCase() && sha(await readFile(input.path)) === input.sha256, 'executable_changed');
82
+ }
83
+ return {
84
+ client: 'vscode', label: 'VS Code Local', configurationHome: sessionHome, executable: exe,
85
+ entry: setup => readVSCodeSession(sessionHome, setup), matches: matchesVSCodeEntry,
86
+ installPreview: setup => `Connector-owned VS Code Local profile only: ${vscodePaths(sessionHome, setup).root}. Normal profiles/extensions/workspaces are not edited. Native MCP startup is manual; connectivity stays unverified. Runtime-generated IDE files are preserved on disconnect.`,
87
+ manualInstructions: vscodeInstructions,
88
+ async install(setup) { await integrity(); return installVSCodeSession(sessionHome, setup); },
89
+ async remove(setup, hash) {
90
+ const files = vscodePaths(sessionHome, setup);
91
+ await readVSCodeSession(sessionHome, setup);
92
+ await settleVSCodeRun(files.process, files.user, runtime);
93
+ await removeVSCodeSession(sessionHome, setup, hash);
94
+ },
95
+ async verify() { return vscodeVerification(); },
96
+ async launch(setup, token, signal) {
97
+ requireValue(TOKEN.test(token), 'token_invalid');
98
+ const files = vscodePaths(sessionHome, setup);
99
+ requireValue(await readVSCodeSession(sessionHome, setup), 'configuration_changed');
100
+ requireValue((await readVSCodeFile(files.settings))?.equals(vscodePayloads(setup).settings), 'configuration_changed');
101
+ // An isolated extensions directory is not permission to launch newly installed extensions.
102
+ requireValue((await readdir(files.extensions)).every(name => ['extensions.json', '.obsolete'].includes(name)), 'configuration_changed');
103
+ for (const [name, expected] of [['extensions.json', '[]'], ['.obsolete', '{}']]) {
104
+ const content = await readVSCodeFile(path.join(files.extensions, name));
105
+ try {
106
+ requireValue(!content || JSON.stringify(JSON.parse(content.toString('utf8'))) === expected, 'configuration_changed');
107
+ }
108
+ catch {
109
+ throw new ConnectorError('configuration_changed');
110
+ }
111
+ }
112
+ await integrity();
113
+ const env = { ...vscodeEnvironment(sessionHome, setup, source), [setup.envVar]: token };
114
+ token = '';
115
+ try {
116
+ await launchVSCode(exe.path, vscodeLaunchArgs(sessionHome, setup), env, files.user, files.process, signal, runtime);
117
+ }
118
+ finally {
119
+ delete env[setup.envVar];
120
+ }
121
+ },
122
+ };
123
+ }
@@ -0,0 +1,87 @@
1
+ import { stripVTControlCharacters } from 'node:util';
2
+ import { mcpSetup, mcpTools } from "../../../src/lib/mcpOnboarding.js";
3
+ export const SUPABASE_URL = 'https://mzeuimqfrswrwoaqjdmp.supabase.co';
4
+ export const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
5
+ export const TOKEN = /^tqm_[0-9a-f]{64}$/;
6
+ export const tools = mcpTools.map(tool => tool.name);
7
+ export const setupFor = (id, client = 'codex') => {
8
+ const setup = mcpSetup(SUPABASE_URL, id);
9
+ return client === 'codex' ? setup : { ...setup, serverName: `${setup.serverName}_${client}`, envVar: `${setup.envVar}_${client.toUpperCase().replaceAll('-', '_')}` };
10
+ };
11
+ export class ConnectorError extends Error {
12
+ code;
13
+ constructor(code) { super('TrackQA connector operation did not complete.'); this.code = code; }
14
+ }
15
+ export function requireValue(condition, code) {
16
+ if (!condition)
17
+ throw new ConnectorError(code);
18
+ }
19
+ export function display(value) { return [...stripVTControlCharacters(value)].filter(char => char.charCodeAt(0) >= 32 && !(char.charCodeAt(0) >= 127 && char.charCodeAt(0) <= 159)).join('').slice(0, 160); }
20
+ export function parseArgs(args) {
21
+ if (!args.length || (args.length === 1 && ['help', '--help', '-h'].includes(args[0])))
22
+ return { command: 'help' };
23
+ requireValue(['connect', 'status', 'disconnect'].includes(args[0]), 'usage');
24
+ const result = { command: args[0] };
25
+ const seen = new Set();
26
+ for (let i = 1; i < args.length; i++) {
27
+ const flag = args[i];
28
+ requireValue(!seen.has(flag), 'usage');
29
+ seen.add(flag);
30
+ if (flag === '--claude-only') {
31
+ result.claudeOnly = true;
32
+ continue;
33
+ }
34
+ const value = args[++i];
35
+ requireValue(['--client', '--codex-path', '--gemini-path', '--claude-path', '--vscode-path', '--project'].includes(flag) && value && !value.startsWith('--'), 'usage');
36
+ if (flag === '--client') {
37
+ requireValue(['codex', 'gemini', 'claude', 'vscode', 'vscode-copilot'].includes(value), 'client_unsupported');
38
+ result.client = value;
39
+ }
40
+ if (['--codex-path', '--gemini-path', '--claude-path', '--vscode-path'].includes(flag))
41
+ result.executable = value;
42
+ if (flag === '--project') {
43
+ requireValue(UUID.test(value), 'usage');
44
+ result.projectId = value.toLowerCase();
45
+ }
46
+ }
47
+ requireValue(['--codex-path', '--gemini-path', '--claude-path', '--vscode-path'].filter(flag => seen.has(flag)).length <= 1
48
+ && (!seen.has('--gemini-path') || result.client === 'gemini')
49
+ && (!seen.has('--codex-path') || !result.client || result.client === 'codex')
50
+ && (!seen.has('--claude-path') || result.client === 'claude')
51
+ && (!seen.has('--vscode-path') || result.client === 'vscode' || result.client === 'vscode-copilot')
52
+ && (!result.claudeOnly || (result.client === 'claude' && result.command === 'connect')), 'usage');
53
+ return result;
54
+ }
55
+ export async function boundedJson(response, maxBytes = 1_048_576) {
56
+ requireValue(!response.redirected && response.ok, 'request_failed');
57
+ if (response.status === 204)
58
+ return null;
59
+ const reader = response.body?.getReader();
60
+ requireValue(reader, 'response_invalid');
61
+ let size = 0;
62
+ const chunks = [];
63
+ try {
64
+ for (;;) {
65
+ const { done, value } = await reader.read();
66
+ if (done)
67
+ break;
68
+ size += value.length;
69
+ requireValue(size <= maxBytes, 'response_limit');
70
+ chunks.push(value);
71
+ }
72
+ // PostgREST void RPCs may return an empty successful body. Callers of reads
73
+ // and issuance still require their explicit array/object response shapes.
74
+ if (size === 0)
75
+ return null;
76
+ try {
77
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
78
+ }
79
+ catch {
80
+ throw new ConnectorError('response_invalid');
81
+ }
82
+ }
83
+ finally {
84
+ await reader.cancel().catch(() => { });
85
+ reader.releaseLock();
86
+ }
87
+ }
@@ -0,0 +1,76 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { ConnectorError } from "./core.js";
3
+ export const spawnProcess = spawn;
4
+ export function clientEnvironment(codexHome, env = process.env) {
5
+ const clean = { CODEX_HOME: codexHome, RUST_LOG: 'off' };
6
+ for (const key of ['PATH', 'PATHEXT', 'SystemRoot', 'WINDIR', 'COMSPEC', 'USERPROFILE', 'HOME', 'APPDATA', 'LOCALAPPDATA', 'TEMP', 'TMP', 'TMPDIR', 'TERM', 'COLORTERM', 'LANG', 'LC_ALL', 'OPENAI_API_KEY']) {
7
+ if (env[key])
8
+ clean[key] = env[key];
9
+ }
10
+ return clean;
11
+ }
12
+ // stdout/stderr are bounded and never forwarded. Native client diagnostics may
13
+ // contain unrelated configuration secrets. Callers receive only sanitized errors.
14
+ export async function capture(executable, args, env, spawnFn = spawnProcess, cwd) {
15
+ return new Promise((resolve, reject) => {
16
+ let output = '', size = 0, settled = false;
17
+ const child = spawnFn(executable, args, { env, cwd, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
18
+ const finish = (error) => {
19
+ if (settled)
20
+ return;
21
+ settled = true;
22
+ clearTimeout(timer);
23
+ if (error) {
24
+ child.kill();
25
+ reject(error);
26
+ }
27
+ else
28
+ resolve(output);
29
+ };
30
+ const timer = setTimeout(() => finish(new ConnectorError('client_command_timeout')), 15_000);
31
+ child.on('error', () => finish(new ConnectorError('client_command_failed')));
32
+ for (const stream of [child.stdout, child.stderr]) {
33
+ stream?.on('error', () => finish(new ConnectorError('client_command_failed')));
34
+ stream?.on('data', (chunk) => {
35
+ size += chunk.length;
36
+ if (size > 1_048_576)
37
+ finish(new ConnectorError('client_response_limit'));
38
+ else if (stream === child.stdout)
39
+ output += chunk.toString('utf8');
40
+ });
41
+ }
42
+ child.on('close', code => finish(code === 0 ? undefined : new ConnectorError('client_command_failed')));
43
+ });
44
+ }
45
+ export async function foreground(executable, env, signal, spawnFn = spawnProcess, args = []) {
46
+ if (signal?.aborted)
47
+ throw new ConnectorError('interrupted');
48
+ await new Promise((resolve, reject) => {
49
+ const options = { env, stdio: 'inherit', windowsHide: false };
50
+ const child = spawnFn(executable, [...args], options);
51
+ let settled = false, stopping = false, timer;
52
+ const finish = (error) => {
53
+ if (settled)
54
+ return;
55
+ settled = true;
56
+ clearTimeout(timer);
57
+ signal?.removeEventListener('abort', abort);
58
+ if (error)
59
+ reject(error);
60
+ else
61
+ resolve();
62
+ };
63
+ const abort = () => {
64
+ if (stopping || settled)
65
+ return;
66
+ stopping = true;
67
+ child.kill('SIGTERM');
68
+ timer = setTimeout(() => { child.kill('SIGKILL'); finish(new ConnectorError('interrupted')); }, 3000);
69
+ };
70
+ signal?.addEventListener('abort', abort, { once: true });
71
+ child.on('error', () => finish(new ConnectorError('client_launch_failed')));
72
+ child.on('exit', code => finish(signal?.aborted ? new ConnectorError('interrupted') : code === 0 ? undefined : new ConnectorError('client_exit_failed')));
73
+ if (signal?.aborted)
74
+ abort();
75
+ });
76
+ }
@@ -0,0 +1,80 @@
1
+ import { mkdir, open, readFile, rename, unlink, lstat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { requireValue, UUID, setupFor } from "./core.js";
5
+ const fields = ['projectId', 'userId', 'client', 'executable', 'clientLabel', 'connectionId', 'phase', 'installed', 'lastVerifiedAt', 'configHash'];
6
+ export const recordHome = (record) => record.client === 'vscode-copilot' ? record.vscodeCopilotHome : record.client === 'vscode' ? record.vscodeHome : record.client === 'claude' ? record.claudeHome : record.client === 'gemini' ? record.geminiHome : record.codexHome;
7
+ export function validateRecord(record) {
8
+ const homeKey = record?.client === 'vscode-copilot' ? 'vscodeCopilotHome' : record?.client === 'vscode' ? 'vscodeHome' : record?.client === 'claude' ? 'claudeHome' : record?.client === 'gemini' ? 'geminiHome' : 'codexHome';
9
+ requireValue(record && Object.keys(record).length === fields.length + 1 && Object.keys(record).every(key => fields.includes(key) || key === homeKey)
10
+ && ['codex', 'gemini', 'claude', 'vscode', 'vscode-copilot'].includes(record.client) && UUID.test(record.projectId) && UUID.test(record.userId)
11
+ && (record.connectionId === null || UUID.test(record.connectionId))
12
+ && /^TrackQA Connect [0-9a-f-]{36}$/.test(record.clientLabel)
13
+ && ['planned', 'installed', 'issuing', 'active', 'revocation_pending', 'revoked'].includes(record.phase)
14
+ && typeof record.installed === 'boolean' && path.isAbsolute(record.executable) && typeof recordHome(record) === 'string' && path.isAbsolute(recordHome(record))
15
+ && (record.configHash === null || /^[0-9a-f]{64}$/.test(record.configHash))
16
+ && (record.lastVerifiedAt === null || Number.isFinite(Date.parse(record.lastVerifiedAt))), 'recovery_invalid');
17
+ setupFor(record.projectId, record.client);
18
+ }
19
+ export class Store {
20
+ root;
21
+ constructor(root) { requireValue(path.isAbsolute(root), 'recovery_unavailable'); this.root = root; }
22
+ async prepare() {
23
+ await mkdir(this.root, { recursive: true, mode: 0o700 });
24
+ requireValue(!(await lstat(this.root)).isSymbolicLink(), 'recovery_unavailable');
25
+ }
26
+ async read() {
27
+ try {
28
+ const file = path.join(this.root, 'connections.json');
29
+ const stat = await lstat(file);
30
+ requireValue(stat.isFile() && !stat.isSymbolicLink() && stat.size <= 131072, 'recovery_invalid');
31
+ const records = JSON.parse(await readFile(file, 'utf8'));
32
+ requireValue(Array.isArray(records) && records.length <= 200, 'recovery_invalid');
33
+ records.forEach(validateRecord);
34
+ requireValue(new Set(records.map(record => `${record.client}:${record.projectId}`)).size === records.length, 'recovery_invalid');
35
+ return records;
36
+ }
37
+ catch (error) {
38
+ if (error.code === 'ENOENT')
39
+ return [];
40
+ throw error;
41
+ }
42
+ }
43
+ async write(records) {
44
+ records.forEach(validateRecord);
45
+ await this.prepare();
46
+ const file = path.join(this.root, `state-${randomUUID()}.tmp`);
47
+ const handle = await open(file, 'wx', 0o600);
48
+ try {
49
+ await handle.writeFile(JSON.stringify(records, null, 2) + '\n');
50
+ await handle.sync();
51
+ }
52
+ finally {
53
+ await handle.close();
54
+ }
55
+ try {
56
+ await rename(file, path.join(this.root, 'connections.json'));
57
+ }
58
+ finally {
59
+ await unlink(file).catch(() => { });
60
+ }
61
+ }
62
+ async save(record) {
63
+ const records = await this.read();
64
+ await this.write([...records.filter(item => item.projectId !== record.projectId || item.client !== record.client), record]);
65
+ }
66
+ async remove(projectId, client = 'codex') { await this.write((await this.read()).filter(record => record.projectId !== projectId || record.client !== client)); }
67
+ async lock() {
68
+ await this.prepare();
69
+ const file = path.join(this.root, 'connector.lock');
70
+ let handle;
71
+ try {
72
+ handle = await open(file, 'wx', 0o600);
73
+ }
74
+ catch {
75
+ throw new Error('connector_busy_or_stale_lock');
76
+ }
77
+ await handle.writeFile(JSON.stringify({ pid: process.pid }));
78
+ return async () => { await handle.close(); await unlink(file); };
79
+ }
80
+ }
@@ -0,0 +1,103 @@
1
+ // Internal, fixed-fixture test capability. Normal connect never accepts this scope.
2
+ import { requireValue, UUID } from "../core.js";
3
+ import { adapterClient, adapterHome } from "../clients/adapter.js";
4
+ export const COPILOT_FIXTURE = Object.freeze({ id: '5a759e3f-50b4-4fdd-b8ad-43469a7100c0', name: 'MCP Synthetic Pilot', key: 'MCPPILOT' });
5
+ export const FIXTURE_POLICY = 'Allow native tool_search_tool capability discovery, incidental tool references and necessary embedding-service use. Allow only trackqa_connection_status, trackqa_list_projects, trackqa_list_issues and trackqa_get_issue as application tools, once each. Prohibit trackqa_submit_finding, unrelated application tools, background/delegated agents and automatic tool approval. Discovery references are metadata, not execution authorization.';
6
+ export const unrevokedRoute = `/rest/v1/mcp_connections?project_id=eq.${COPILOT_FIXTURE.id}&revoked_at=is.null&select=id,project_id,user_id,client_label,revoked_at&limit=2`;
7
+ export const inventoryRoute = (table, offset) => `/rest/v1/${table}?project_id=eq.${COPILOT_FIXTURE.id}&select=*&order=id.asc&limit=100&offset=${offset}`;
8
+ const scopes = new WeakMap();
9
+ const permits = new WeakMap();
10
+ export function createFixtureScope() {
11
+ const scope = Object.freeze({});
12
+ scopes.set(scope, { active: false, recovery: false, enabled: false, issued: false });
13
+ return scope;
14
+ }
15
+ function getState(scope) {
16
+ const state = scopes.get(scope);
17
+ requireValue(state, 'fixture_capability_invalid');
18
+ return state;
19
+ }
20
+ export const fixtureScopeState = (scope) => Object.freeze({ ...getState(scope) });
21
+ export function fixtureMembershipRoute(userId) {
22
+ return `/rest/v1/project_members?project_id=eq.${COPILOT_FIXTURE.id}&user_id=eq.${userId}&role=eq.OWNER&select=project_id,role,project:projects(id,name,key,mcp_enabled,archived)&limit=2`;
23
+ }
24
+ export function fixtureRequestAllowed(scope, userId, route, method = 'GET', body) {
25
+ const state = getState(scope), id = COPILOT_FIXTURE.id;
26
+ if (method === 'GET') {
27
+ if ([`/rest/v1/profiles?id=eq.${userId}&select=id&limit=2`, fixtureMembershipRoute(userId),
28
+ `/rest/v1/project_members?project_id=eq.${id}&user_id=eq.${userId}&select=user_id,role&limit=2`,
29
+ `/rest/v1/projects?id=eq.${id}&select=id,name,key,mcp_enabled,archived&limit=2`, unrevokedRoute].includes(route))
30
+ return true;
31
+ for (const table of ['issues', 'ai_findings'])
32
+ for (let offset = 0; offset <= 2000; offset += 100)
33
+ if (route === inventoryRoute(table, offset))
34
+ return true;
35
+ const filters = [state.connectionId ? `id=eq.${state.connectionId}` : null,
36
+ state.label ? `client_label=eq.${encodeURIComponent(state.label)}&user_id=eq.${userId}` : null].filter(Boolean);
37
+ return filters.some(filter => route === `/rest/v1/mcp_connections?project_id=eq.${id}&${filter}&select=id,project_id,user_id,client_label,revoked_at&limit=2`);
38
+ }
39
+ if (!state.active)
40
+ return false;
41
+ if (method === 'PATCH' && route === `/rest/v1/projects?id=eq.${id}&select=id,mcp_enabled`) {
42
+ if (JSON.stringify(body) === '{"mcp_enabled":false}')
43
+ return state.enabled || state.recovery;
44
+ if (JSON.stringify(body) === '{"mcp_enabled":true}' && !state.recovery && !state.enabled && !state.issued) {
45
+ state.enabled = true;
46
+ return true;
47
+ }
48
+ return false;
49
+ }
50
+ if (method === 'POST' && route === '/rest/v1/rpc/issue_mcp_connection_token' && !state.recovery && !state.issued) {
51
+ const value = body;
52
+ if (value && Object.keys(value).length === 3 && value.p_project_id === id && value.p_user_id === userId
53
+ && typeof value.p_client_label === 'string' && /^TrackQA Connect [0-9a-f-]{36}$/.test(value.p_client_label)) {
54
+ state.issued = true;
55
+ state.label = value.p_client_label;
56
+ return true;
57
+ }
58
+ }
59
+ return method === 'POST' && route === '/rest/v1/rpc/revoke_mcp_connection' && !!state.connectionId
60
+ && JSON.stringify(body) === JSON.stringify({ p_connection_id: state.connectionId });
61
+ }
62
+ export function observeFixtureResponse(scope, userId, route, value) {
63
+ const state = getState(scope);
64
+ if (!state.issued || !Array.isArray(value) || value.length !== 1)
65
+ return;
66
+ if (route === '/rest/v1/rpc/issue_mcp_connection_token' && UUID.test(value[0]?.connection_id))
67
+ state.connectionId = value[0].connection_id;
68
+ else if (route.startsWith('/rest/v1/mcp_connections?') && value[0]?.project_id === COPILOT_FIXTURE.id
69
+ && value[0]?.user_id === userId && value[0]?.client_label === state.label && UUID.test(value[0]?.id))
70
+ state.connectionId = value[0].id;
71
+ }
72
+ // Only the acceptance orchestrator calls this after two fresh preflights and
73
+ // explicit consent. Weak identity binds it to one Owner, adapter and Store.
74
+ export function grantFixturePermit(scope, owner, adapter, store) {
75
+ const state = getState(scope);
76
+ requireValue(!state.active && !state.recovery && !state.granted && adapterClient(adapter) === 'vscode-copilot', 'fixture_capability_invalid');
77
+ state.granted = true;
78
+ const permit = Object.freeze({});
79
+ permits.set(permit, { scope, owner, adapter, store, used: false, expires: Date.now() + 60_000 });
80
+ return permit;
81
+ }
82
+ export function consumeFixturePermit(permit, owner, adapter, store) {
83
+ const entry = permits.get(permit);
84
+ requireValue(entry && !entry.used && entry.expires >= Date.now() && entry.owner === owner && entry.adapter === adapter && entry.store === store
85
+ && !getState(entry.scope).active && adapterClient(adapter) === 'vscode-copilot' && adapterHome(adapter), 'fixture_capability_invalid');
86
+ entry.used = true;
87
+ getState(entry.scope).active = true;
88
+ }
89
+ export function requireFixtureActive(scope, projectId) {
90
+ const state = getState(scope);
91
+ requireValue(state.active && !state.recovery && projectId === COPILOT_FIXTURE.id, 'fixture_capability_invalid');
92
+ }
93
+ export function enableFixtureRecovery(scope, record) {
94
+ const state = getState(scope);
95
+ state.active = true;
96
+ state.recovery = true;
97
+ state.enabled = true;
98
+ if (record) {
99
+ state.issued = true;
100
+ state.label = record.clientLabel;
101
+ state.connectionId = record.connectionId ?? undefined;
102
+ }
103
+ }