@gemus/mcp-proxy 0.1.9 → 0.1.10
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 +41 -21
- package/package.json +3 -2
- package/src/proxy.mjs +29 -427
- package/src/relay.mjs +440 -0
- package/src/setup/cli.mjs +130 -0
- package/src/setup/codex.mjs +110 -0
- package/src/setup/config.mjs +530 -0
- package/src/setup/configFile.mjs +155 -0
- package/src/setup/environment.mjs +79 -0
- package/src/setup/secret.mjs +91 -0
- package/src/__tests__/backfill.test.ts +0 -394
- package/src/__tests__/failFast.test.ts +0 -356
- package/src/__tests__/fixtures/proxy-runtime-loader.mjs +0 -216
- package/src/__tests__/mcpHeaders.test.ts +0 -32
- package/src/__tests__/proxyMessages.test.ts +0 -81
- package/src/__tests__/route.integration.test.ts +0 -68
- package/src/__tests__/startup.test.ts +0 -752
- package/src/__tests__/upstreamHttp.real.test.ts +0 -37
- package/src/__tests__/upstreamHttp.test.ts +0 -151
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { open, readFile, rename, rm, lstat, access, mkdir } from 'node:fs/promises'
|
|
3
|
+
import { constants } from 'node:fs'
|
|
4
|
+
import { homedir as systemHomeDirectory } from 'node:os'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
|
|
7
|
+
export class CodexConfigChangedError extends Error {
|
|
8
|
+
constructor(currentSource) {
|
|
9
|
+
super('Codex config changed while setup was running; re-plan from the latest config.')
|
|
10
|
+
this.code = 'CODEX_CONFIG_CHANGED'
|
|
11
|
+
this.currentSource = currentSource
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function configPathFromEnvironment(env, homeDirectory) {
|
|
16
|
+
const configuredHome = typeof env.CODEX_HOME === 'string' && env.CODEX_HOME.trim() !== ''
|
|
17
|
+
? env.CODEX_HOME
|
|
18
|
+
: undefined
|
|
19
|
+
const codexHome = configuredHome ?? path.join(homeDirectory(), '.codex')
|
|
20
|
+
if (!path.isAbsolute(codexHome)) throw new Error('CODEX_HOME must be an absolute path')
|
|
21
|
+
return path.join(codexHome, 'config.toml')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function readSource(configPath, fileOps) {
|
|
25
|
+
try {
|
|
26
|
+
return await fileOps.readFile(configPath, 'utf8')
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if (error?.code === 'ENOENT') return ''
|
|
29
|
+
throw error
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function validateConfigBoundary(configPath, fileOps) {
|
|
34
|
+
const root = path.parse(configPath).root
|
|
35
|
+
const parts = path.relative(root, configPath).split(path.sep).filter(Boolean)
|
|
36
|
+
let current = root
|
|
37
|
+
for (let index = 0; index < parts.length; index += 1) {
|
|
38
|
+
current = path.join(current, parts[index])
|
|
39
|
+
try {
|
|
40
|
+
const stat = await fileOps.lstat(current)
|
|
41
|
+
if (stat.isSymbolicLink()) throw new Error('refusing to replace a symlink Codex config path')
|
|
42
|
+
if (index < parts.length - 1 && !stat.isDirectory()) {
|
|
43
|
+
throw new Error('Codex config home path is not a directory')
|
|
44
|
+
}
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (error?.code === 'ENOENT') return
|
|
47
|
+
throw error
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function preflightCodexConfig({
|
|
53
|
+
env = process.env,
|
|
54
|
+
fileOps: overrides,
|
|
55
|
+
homeDirectory = systemHomeDirectory,
|
|
56
|
+
} = {}) {
|
|
57
|
+
const fileOps = { readFile, lstat, access, ...overrides }
|
|
58
|
+
const configPath = configPathFromEnvironment(env, homeDirectory)
|
|
59
|
+
await validateConfigBoundary(configPath, fileOps)
|
|
60
|
+
let ancestor = path.dirname(configPath)
|
|
61
|
+
while (true) {
|
|
62
|
+
try { await fileOps.lstat(ancestor); break } catch (error) {
|
|
63
|
+
if (error?.code !== 'ENOENT') throw error
|
|
64
|
+
const parent = path.dirname(ancestor)
|
|
65
|
+
if (parent === ancestor) throw error
|
|
66
|
+
ancestor = parent
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
await fileOps.access(ancestor, constants.W_OK)
|
|
70
|
+
return { configPath, source: await readSource(configPath, fileOps) }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function openExclusive(prefix, fileOps) {
|
|
74
|
+
for (let attempt = 0; attempt < 32; attempt += 1) {
|
|
75
|
+
const candidate = `${prefix}${randomUUID()}`
|
|
76
|
+
try {
|
|
77
|
+
return { path: candidate, handle: await fileOps.open(candidate, 'wx', 0o600) }
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (error?.code !== 'EEXIST') throw error
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
throw new Error('could not reserve a unique Codex config transaction file')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function createConfigHomeSafely(configPath, fileOps) {
|
|
86
|
+
await validateConfigBoundary(configPath, fileOps)
|
|
87
|
+
const root = path.parse(configPath).root
|
|
88
|
+
const directories = path.relative(root, path.dirname(configPath)).split(path.sep).filter(Boolean)
|
|
89
|
+
let current = root
|
|
90
|
+
for (const segment of directories) {
|
|
91
|
+
current = path.join(current, segment)
|
|
92
|
+
try {
|
|
93
|
+
const stat = await fileOps.lstat(current)
|
|
94
|
+
if (stat.isSymbolicLink()) throw new Error('refusing to replace a symlink Codex config path')
|
|
95
|
+
if (!stat.isDirectory()) throw new Error('Codex config home path is not a directory')
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if (error?.code !== 'ENOENT') throw error
|
|
98
|
+
await fileOps.mkdir(current, { mode: 0o700 })
|
|
99
|
+
const created = await fileOps.lstat(current)
|
|
100
|
+
if (created.isSymbolicLink()) throw new Error('refusing to replace a symlink Codex config path')
|
|
101
|
+
if (!created.isDirectory()) throw new Error('Codex config home path is not a directory')
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
await validateConfigBoundary(configPath, fileOps)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function commitCodexConfig({
|
|
108
|
+
expectedSource,
|
|
109
|
+
nextText,
|
|
110
|
+
env = process.env,
|
|
111
|
+
fileOps: overrides,
|
|
112
|
+
homeDirectory = systemHomeDirectory,
|
|
113
|
+
} = {}) {
|
|
114
|
+
if (typeof expectedSource !== 'string' || typeof nextText !== 'string') {
|
|
115
|
+
throw new TypeError('expectedSource and nextText must be strings')
|
|
116
|
+
}
|
|
117
|
+
const fileOps = { open, readFile, rename, rm, lstat, mkdir, ...overrides }
|
|
118
|
+
const configPath = configPathFromEnvironment(env, homeDirectory)
|
|
119
|
+
await createConfigHomeSafely(configPath, fileOps)
|
|
120
|
+
const currentSource = await readSource(configPath, fileOps)
|
|
121
|
+
if (currentSource !== expectedSource) throw new CodexConfigChangedError(currentSource)
|
|
122
|
+
if (currentSource === nextText) return { changed: false, backupPath: undefined }
|
|
123
|
+
|
|
124
|
+
let backupPath
|
|
125
|
+
let tempPath
|
|
126
|
+
try {
|
|
127
|
+
if (currentSource !== '') {
|
|
128
|
+
const backup = await openExclusive(`${configPath}.gemus-backup-`, fileOps)
|
|
129
|
+
backupPath = backup.path
|
|
130
|
+
try {
|
|
131
|
+
await backup.handle.writeFile(currentSource, 'utf8')
|
|
132
|
+
} finally {
|
|
133
|
+
await backup.handle.close()
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const temp = await openExclusive(`${path.join(path.dirname(configPath), `.${path.basename(configPath)}.gemus-tmp-`)}`, fileOps)
|
|
138
|
+
tempPath = temp.path
|
|
139
|
+
try {
|
|
140
|
+
await temp.handle.writeFile(nextText, 'utf8')
|
|
141
|
+
} finally {
|
|
142
|
+
await temp.handle.close()
|
|
143
|
+
}
|
|
144
|
+
await validateConfigBoundary(configPath, fileOps)
|
|
145
|
+
const finalSource = await readSource(configPath, fileOps)
|
|
146
|
+
if (finalSource !== expectedSource) throw new CodexConfigChangedError(finalSource)
|
|
147
|
+
await fileOps.rename(tempPath, configPath)
|
|
148
|
+
tempPath = undefined
|
|
149
|
+
} catch (error) {
|
|
150
|
+
if (tempPath) await fileOps.rm(tempPath, { force: true })
|
|
151
|
+
throw error
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return { changed: true, backupPath }
|
|
155
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
const WINDOWS_SCRIPT = [
|
|
4
|
+
"$ErrorActionPreference = 'Stop'",
|
|
5
|
+
'try {',
|
|
6
|
+
" if ($env:GEMUS_URL) { [Environment]::SetEnvironmentVariable('GEMUS_URL', $env:GEMUS_URL, 'User') }",
|
|
7
|
+
" [Environment]::SetEnvironmentVariable('GEMUS_KEY', $env:GEMUS_KEY, 'User')",
|
|
8
|
+
"$signature = @'",
|
|
9
|
+
'using System;',
|
|
10
|
+
'using System.Runtime.InteropServices;',
|
|
11
|
+
'public static class GemusEnvironmentBroadcast {',
|
|
12
|
+
' [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]',
|
|
13
|
+
' public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint msg, UIntPtr wParam, string lParam, uint flags, uint timeout, out UIntPtr result);',
|
|
14
|
+
'}',
|
|
15
|
+
"'@",
|
|
16
|
+
'Add-Type -TypeDefinition $signature',
|
|
17
|
+
'$result = [UIntPtr]::Zero',
|
|
18
|
+
'[GemusEnvironmentBroadcast]::SendMessageTimeout([IntPtr]0xffff, 0x001A, [UIntPtr]::Zero, "Environment", 2, 5000, [ref]$result) | Out-Null # WM_SETTINGCHANGE',
|
|
19
|
+
'} finally {',
|
|
20
|
+
' Remove-Item Env:GEMUS_KEY -ErrorAction SilentlyContinue',
|
|
21
|
+
' Remove-Item Env:GEMUS_URL -ErrorAction SilentlyContinue',
|
|
22
|
+
'}',
|
|
23
|
+
].join('\n')
|
|
24
|
+
|
|
25
|
+
const MACOS_SCRIPT = [
|
|
26
|
+
'set -e',
|
|
27
|
+
"trap 'unset GEMUS_KEY GEMUS_URL' EXIT",
|
|
28
|
+
'if [ -n "${GEMUS_URL:-}" ]; then /bin/launchctl setenv GEMUS_URL "$GEMUS_URL"; fi',
|
|
29
|
+
'/bin/launchctl setenv GEMUS_KEY "$GEMUS_KEY"',
|
|
30
|
+
].join('\n')
|
|
31
|
+
|
|
32
|
+
function childEnvironment(key, url, parentEnv) {
|
|
33
|
+
const environment = Object.fromEntries(Object.entries(parentEnv).filter(([name]) => (
|
|
34
|
+
name.toUpperCase() !== 'GEMUS_KEY' && name.toUpperCase() !== 'GEMUS_URL'
|
|
35
|
+
)))
|
|
36
|
+
environment.GEMUS_KEY = key
|
|
37
|
+
if (url !== undefined) environment.GEMUS_URL = url
|
|
38
|
+
return environment
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function runChild(command, args, { env }) {
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
const child = spawn(command, args, { env, stdio: 'ignore' })
|
|
44
|
+
child.once('error', reject)
|
|
45
|
+
child.once('exit', (code) => {
|
|
46
|
+
if (code === 0) resolve()
|
|
47
|
+
else reject(new Error('environment installer exited unsuccessfully'))
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function installGemusEnvironment({
|
|
53
|
+
key,
|
|
54
|
+
url,
|
|
55
|
+
runner = runChild,
|
|
56
|
+
platform = process.platform,
|
|
57
|
+
stdout = process.stdout,
|
|
58
|
+
parentEnv = process.env,
|
|
59
|
+
} = {}) {
|
|
60
|
+
if (platform !== 'win32' && platform !== 'darwin') {
|
|
61
|
+
throw new Error('Gemus Companion setup supports only Windows and macOS.')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const env = childEnvironment(key, url, parentEnv)
|
|
65
|
+
try {
|
|
66
|
+
if (platform === 'win32') {
|
|
67
|
+
await runner('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', WINDOWS_SCRIPT], { env })
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
await runner('/bin/sh', ['-c', MACOS_SCRIPT], { env })
|
|
71
|
+
stdout?.write(
|
|
72
|
+
'Gemus environment values last only for the current macOS login session. '
|
|
73
|
+
+ 'After logout, reboot, or a new login, rerun setup. '
|
|
74
|
+
+ 'Fully quit and restart Codex Desktop to use Gemus Companion.\n',
|
|
75
|
+
)
|
|
76
|
+
} catch {
|
|
77
|
+
throw new Error('Could not install the Gemus Companion environment.')
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
function present(value) {
|
|
2
|
+
return value !== undefined && value !== ''
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function validateKey(key) {
|
|
6
|
+
if (typeof key !== 'string' || key.length === 0 || /\s/.test(key)) {
|
|
7
|
+
throw new Error('invalid GEMUS_KEY')
|
|
8
|
+
}
|
|
9
|
+
return key
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function validateUrl(url) {
|
|
13
|
+
if (typeof url !== 'string' || url.length === 0 || /\s/.test(url)) {
|
|
14
|
+
throw new Error('invalid GEMUS_URL')
|
|
15
|
+
}
|
|
16
|
+
let parsed
|
|
17
|
+
try {
|
|
18
|
+
parsed = new URL(url)
|
|
19
|
+
} catch {
|
|
20
|
+
throw new Error('invalid GEMUS_URL')
|
|
21
|
+
}
|
|
22
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
|
23
|
+
throw new Error('invalid GEMUS_URL')
|
|
24
|
+
}
|
|
25
|
+
return url
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function hiddenKey({ stdin, stdout }) {
|
|
29
|
+
stdout?.write('Enter Gemus API key: ')
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
let key = ''
|
|
32
|
+
const finish = (callback, value) => {
|
|
33
|
+
stdin.off('data', onData)
|
|
34
|
+
stdin.off('end', onEnd)
|
|
35
|
+
stdin.off('error', onError)
|
|
36
|
+
callback(value)
|
|
37
|
+
}
|
|
38
|
+
const onData = (chunk) => {
|
|
39
|
+
for (const character of String(chunk)) {
|
|
40
|
+
if (character === '\u0003') {
|
|
41
|
+
finish(reject, new Error('Gemus setup cancelled.'))
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
if (character === '\r' || character === '\n') {
|
|
45
|
+
if (key) finish(resolve, key)
|
|
46
|
+
else finish(reject, new Error('No Gemus API key was provided.'))
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
if (character === '\b' || character === '\u007f') {
|
|
50
|
+
key = key.slice(0, -1)
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
53
|
+
key += character
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const onEnd = () => finish(reject, new Error('No Gemus API key was provided.'))
|
|
57
|
+
const onError = () => finish(reject, new Error('Could not read the Gemus API key.'))
|
|
58
|
+
|
|
59
|
+
stdin.on('data', onData)
|
|
60
|
+
stdin.once('end', onEnd)
|
|
61
|
+
stdin.once('error', onError)
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function promptForKey({ stdin, stdout }) {
|
|
66
|
+
if (!stdin?.isTTY || typeof stdin.setRawMode !== 'function') {
|
|
67
|
+
throw new Error('GEMUS_KEY is required; set GEMUS_KEY before running setup.')
|
|
68
|
+
}
|
|
69
|
+
let rawModeEnabled = false
|
|
70
|
+
try {
|
|
71
|
+
stdin.setRawMode(true)
|
|
72
|
+
rawModeEnabled = true
|
|
73
|
+
return validateKey(await hiddenKey({ stdin, stdout }))
|
|
74
|
+
} finally {
|
|
75
|
+
if (rawModeEnabled) stdin.setRawMode(false)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function acquireGemusSecret({
|
|
80
|
+
env = process.env,
|
|
81
|
+
legacyKey,
|
|
82
|
+
legacyUrl,
|
|
83
|
+
stdin = process.stdin,
|
|
84
|
+
stdout = process.stdout,
|
|
85
|
+
} = {}) {
|
|
86
|
+
const configuredUrl = present(env.GEMUS_URL) ? env.GEMUS_URL : legacyUrl
|
|
87
|
+
const url = configuredUrl === undefined ? undefined : validateUrl(configuredUrl)
|
|
88
|
+
const configuredKey = present(env.GEMUS_KEY) ? env.GEMUS_KEY : legacyKey
|
|
89
|
+
const key = configuredKey === undefined ? await promptForKey({ stdin, stdout }) : validateKey(configuredKey)
|
|
90
|
+
return { key, url }
|
|
91
|
+
}
|