@seventysixty/codefacility-bridge 1.0.1
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 +59 -0
- package/index.js +281 -0
- package/package.json +22 -0
package/README.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# @codefacility/bridge
|
|
2
|
+
|
|
3
|
+
Local bridge that lets the CodeFacility web app verify AI CLI tools installed on your machine.
|
|
4
|
+
|
|
5
|
+
## Why it exists
|
|
6
|
+
|
|
7
|
+
Browsers cannot run shell commands. This bridge runs as a tiny local HTTP server on `127.0.0.1:7325` — reachable only from your machine — and checks whether CLIs like `claude`, `codex`, and `gemini` are installed and authenticated.
|
|
8
|
+
|
|
9
|
+
## Usage
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npx @seventysixty/codefacility-bridge
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Run this once in any terminal. Keep it running while you use CodeFacility. Stop it with `Ctrl+C`.
|
|
16
|
+
|
|
17
|
+
## Security
|
|
18
|
+
|
|
19
|
+
- **Loopback only** — binds to `127.0.0.1`, never `0.0.0.0`. External machines cannot connect.
|
|
20
|
+
- **Origin allowlist** — only accepts HTTP requests from `codefacility.vercel.app` and `localhost` dev servers.
|
|
21
|
+
- **No arbitrary execution** — the provider list and CLI commands are hardcoded. No user input is ever used to construct a shell command.
|
|
22
|
+
- **`execFile` not `exec`** — no shell is spawned; shell injection is not possible.
|
|
23
|
+
- **No dependencies** — uses only Node.js built-in modules.
|
|
24
|
+
- **No writes, no outbound calls** — reads credential files and environment variables only.
|
|
25
|
+
|
|
26
|
+
## API
|
|
27
|
+
|
|
28
|
+
### `GET /health`
|
|
29
|
+
```json
|
|
30
|
+
{ "status": "ok", "version": "1.0.0" }
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### `POST /cli-verify`
|
|
34
|
+
```json
|
|
35
|
+
// Request
|
|
36
|
+
{ "provider": "anthropic" }
|
|
37
|
+
|
|
38
|
+
// Response
|
|
39
|
+
{ "installed": true, "authenticated": true, "version": "1.0.5" }
|
|
40
|
+
{ "installed": true, "authenticated": false }
|
|
41
|
+
{ "installed": false, "authenticated": false }
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Supported provider IDs: `anthropic`, `openai`, `google`, `mistral`, `xai`
|
|
45
|
+
|
|
46
|
+
## How authentication is detected
|
|
47
|
+
|
|
48
|
+
The bridge checks two things (no API calls are made):
|
|
49
|
+
|
|
50
|
+
1. **Credential files** — known config/credential file locations for each CLI
|
|
51
|
+
2. **Environment variables** — e.g. `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`
|
|
52
|
+
|
|
53
|
+
| Provider | CLI | Credential paths checked |
|
|
54
|
+
|----------|-----|--------------------------|
|
|
55
|
+
| Anthropic | `claude` | `~/.claude/.credentials.json` |
|
|
56
|
+
| OpenAI | `codex` | `~/.codex/config.json`, `OPENAI_API_KEY` |
|
|
57
|
+
| Google | `gemini` | `~/.gemini/credentials.json`, `GOOGLE_API_KEY` |
|
|
58
|
+
| Mistral | `mistral` | `~/.mistral/credentials.json`, `MISTRAL_API_KEY` |
|
|
59
|
+
| xAI | `xai` | `~/.xai/credentials.json`, `XAI_API_KEY` |
|
package/index.js
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* CodeFacility Local Bridge
|
|
6
|
+
*
|
|
7
|
+
* Runs on http://127.0.0.1:7325 — loopback only, never reachable externally.
|
|
8
|
+
*
|
|
9
|
+
* Security model:
|
|
10
|
+
* - Binds to 127.0.0.1 only (OS enforces: no external connections possible)
|
|
11
|
+
* - Validates Origin header against an explicit allowlist
|
|
12
|
+
* - Only exposes two endpoints: /health and /cli-verify
|
|
13
|
+
* - /cli-verify only accepts a hardcoded list of known provider IDs
|
|
14
|
+
* - Uses execFile (not exec) so no shell injection is possible
|
|
15
|
+
* - Caps request body at 1 KB
|
|
16
|
+
* - All child processes time out after 5 seconds
|
|
17
|
+
* - No file writes, no outbound network calls, no eval
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const http = require('http')
|
|
21
|
+
const { execFile } = require('child_process')
|
|
22
|
+
const fs = require('fs')
|
|
23
|
+
const os = require('os')
|
|
24
|
+
const path = require('path')
|
|
25
|
+
|
|
26
|
+
const PORT = 7325
|
|
27
|
+
const HOST = '127.0.0.1'
|
|
28
|
+
|
|
29
|
+
// Only the CodeFacility web app may call this bridge.
|
|
30
|
+
// Requests from any other origin are rejected.
|
|
31
|
+
const ALLOWED_ORIGINS = new Set([
|
|
32
|
+
'https://codefacility.vercel.app',
|
|
33
|
+
'https://dev-agent-nu.vercel.app',
|
|
34
|
+
'http://localhost:5173', // Vite dev server
|
|
35
|
+
'http://localhost:4173', // Vite preview
|
|
36
|
+
'http://localhost:3000',
|
|
37
|
+
])
|
|
38
|
+
|
|
39
|
+
const HOME = os.homedir()
|
|
40
|
+
|
|
41
|
+
// ── Provider definitions ─────────────────────────────────────────────────────
|
|
42
|
+
// Each entry describes:
|
|
43
|
+
// command — the CLI binary name (no user-supplied input ever used here)
|
|
44
|
+
// credPaths — filesystem paths that indicate the user is authenticated
|
|
45
|
+
// envVars — environment variables that indicate an API key is configured
|
|
46
|
+
//
|
|
47
|
+
// Only providers listed here can be checked. Any unknown provider ID is rejected.
|
|
48
|
+
const PROVIDERS = {
|
|
49
|
+
anthropic: {
|
|
50
|
+
command: 'claude',
|
|
51
|
+
credPaths: [
|
|
52
|
+
path.join(HOME, '.claude', '.credentials.json'),
|
|
53
|
+
path.join(HOME, '.claude', 'credentials.json'),
|
|
54
|
+
path.join(HOME, '.config', 'claude', 'credentials.json'),
|
|
55
|
+
// Windows-specific
|
|
56
|
+
path.join(HOME, 'AppData', 'Roaming', 'claude', 'credentials.json'),
|
|
57
|
+
],
|
|
58
|
+
envVars: ['ANTHROPIC_API_KEY'],
|
|
59
|
+
},
|
|
60
|
+
openai: {
|
|
61
|
+
command: 'codex',
|
|
62
|
+
credPaths: [
|
|
63
|
+
path.join(HOME, '.codex', 'config.json'),
|
|
64
|
+
path.join(HOME, '.config', 'codex', 'config.json'),
|
|
65
|
+
path.join(HOME, 'AppData', 'Roaming', 'codex', 'config.json'),
|
|
66
|
+
],
|
|
67
|
+
envVars: ['OPENAI_API_KEY'],
|
|
68
|
+
},
|
|
69
|
+
google: {
|
|
70
|
+
command: 'gemini',
|
|
71
|
+
credPaths: [
|
|
72
|
+
path.join(HOME, '.gemini', 'credentials.json'),
|
|
73
|
+
path.join(HOME, '.config', 'gemini', 'credentials.json'),
|
|
74
|
+
path.join(HOME, 'AppData', 'Roaming', 'gemini', 'credentials.json'),
|
|
75
|
+
// gcloud application-default credentials also satisfy Gemini CLI
|
|
76
|
+
path.join(HOME, '.config', 'gcloud', 'application_default_credentials.json'),
|
|
77
|
+
],
|
|
78
|
+
envVars: ['GOOGLE_API_KEY', 'GEMINI_API_KEY'],
|
|
79
|
+
},
|
|
80
|
+
mistral: {
|
|
81
|
+
command: 'mistral',
|
|
82
|
+
credPaths: [
|
|
83
|
+
path.join(HOME, '.mistral', 'credentials.json'),
|
|
84
|
+
path.join(HOME, '.config', 'mistral', 'credentials.json'),
|
|
85
|
+
],
|
|
86
|
+
envVars: ['MISTRAL_API_KEY'],
|
|
87
|
+
},
|
|
88
|
+
xai: {
|
|
89
|
+
command: 'xai',
|
|
90
|
+
credPaths: [
|
|
91
|
+
path.join(HOME, '.xai', 'credentials.json'),
|
|
92
|
+
],
|
|
93
|
+
envVars: ['XAI_API_KEY'],
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── CLI helpers ──────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
function runVersion(command) {
|
|
100
|
+
return new Promise((resolve) => {
|
|
101
|
+
execFile(
|
|
102
|
+
command,
|
|
103
|
+
['--version'],
|
|
104
|
+
{
|
|
105
|
+
timeout: 5000,
|
|
106
|
+
windowsHide: true,
|
|
107
|
+
// On Windows, CLI tools installed via npm are .cmd wrappers
|
|
108
|
+
shell: process.platform === 'win32',
|
|
109
|
+
},
|
|
110
|
+
(err, stdout, stderr) => {
|
|
111
|
+
if (err) {
|
|
112
|
+
resolve({ ok: false })
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
// Some CLIs print version to stderr, some to stdout
|
|
116
|
+
const raw = (stdout || stderr || '').trim()
|
|
117
|
+
const version = raw.split('\n')[0].trim()
|
|
118
|
+
resolve({ ok: true, version })
|
|
119
|
+
}
|
|
120
|
+
)
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function credFileExists(credPaths) {
|
|
125
|
+
for (const credPath of credPaths) {
|
|
126
|
+
try {
|
|
127
|
+
const stat = fs.statSync(credPath)
|
|
128
|
+
// File must exist and have actual content (> 10 bytes)
|
|
129
|
+
if (stat.isFile() && stat.size > 10) {
|
|
130
|
+
// For JSON credential files, verify the content is parseable
|
|
131
|
+
// and contains at least one key — not just an empty object
|
|
132
|
+
try {
|
|
133
|
+
const content = fs.readFileSync(credPath, 'utf8')
|
|
134
|
+
const parsed = JSON.parse(content)
|
|
135
|
+
if (typeof parsed === 'object' && parsed !== null && Object.keys(parsed).length > 0) {
|
|
136
|
+
return true
|
|
137
|
+
}
|
|
138
|
+
} catch {
|
|
139
|
+
// Not JSON (e.g. TOML config) — size check is enough
|
|
140
|
+
return true
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
} catch {
|
|
144
|
+
// File doesn't exist or can't be read — try the next path
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return false
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function envVarSet(envVars) {
|
|
151
|
+
return envVars.some((v) => {
|
|
152
|
+
const val = process.env[v]
|
|
153
|
+
return typeof val === 'string' && val.trim().length > 8
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function checkProvider(providerId) {
|
|
158
|
+
const def = PROVIDERS[providerId]
|
|
159
|
+
if (!def) return { installed: false, authenticated: false }
|
|
160
|
+
|
|
161
|
+
const { ok, version } = await runVersion(def.command)
|
|
162
|
+
if (!ok) return { installed: false, authenticated: false }
|
|
163
|
+
|
|
164
|
+
const authenticated = credFileExists(def.credPaths) || envVarSet(def.envVars)
|
|
165
|
+
const result = { installed: true, authenticated }
|
|
166
|
+
if (version) result.version = version
|
|
167
|
+
return result
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── HTTP server ──────────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
function setCorsHeaders(res, origin) {
|
|
173
|
+
res.setHeader('Access-Control-Allow-Origin', origin)
|
|
174
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
|
175
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
|
|
176
|
+
res.setHeader('Access-Control-Max-Age', '86400')
|
|
177
|
+
// Prevent the bridge response from being cached
|
|
178
|
+
res.setHeader('Cache-Control', 'no-store')
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function send(res, status, body) {
|
|
182
|
+
res.writeHead(status, { 'Content-Type': 'application/json' })
|
|
183
|
+
res.end(JSON.stringify(body))
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const server = http.createServer((req, res) => {
|
|
187
|
+
const origin = req.headers.origin || ''
|
|
188
|
+
|
|
189
|
+
// ── Origin check ──────────────────────────────────────────────────────────
|
|
190
|
+
// Reject anything that isn't our web app. Because we bind to 127.0.0.1 the
|
|
191
|
+
// OS already prevents external machines from connecting; the Origin check is
|
|
192
|
+
// a defence-in-depth measure against CSRF from malicious websites.
|
|
193
|
+
if (!ALLOWED_ORIGINS.has(origin)) {
|
|
194
|
+
res.writeHead(403)
|
|
195
|
+
res.end()
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
setCorsHeaders(res, origin)
|
|
200
|
+
|
|
201
|
+
// Pre-flight
|
|
202
|
+
if (req.method === 'OPTIONS') {
|
|
203
|
+
res.writeHead(204)
|
|
204
|
+
res.end()
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const { pathname } = new URL(req.url, `http://${HOST}`)
|
|
209
|
+
|
|
210
|
+
// ── GET /health ───────────────────────────────────────────────────────────
|
|
211
|
+
if (req.method === 'GET' && pathname === '/health') {
|
|
212
|
+
send(res, 200, { status: 'ok', version: '1.0.0' })
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── POST /cli-verify ──────────────────────────────────────────────────────
|
|
217
|
+
if (req.method === 'POST' && pathname === '/cli-verify') {
|
|
218
|
+
let body = ''
|
|
219
|
+
|
|
220
|
+
req.on('data', (chunk) => {
|
|
221
|
+
body += chunk
|
|
222
|
+
// Hard cap — no request should be larger than 1 KB
|
|
223
|
+
if (body.length > 1024) {
|
|
224
|
+
req.destroy()
|
|
225
|
+
send(res, 413, { error: 'Request too large' })
|
|
226
|
+
}
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
req.on('end', async () => {
|
|
230
|
+
let providerId
|
|
231
|
+
try {
|
|
232
|
+
const parsed = JSON.parse(body)
|
|
233
|
+
providerId = parsed.provider
|
|
234
|
+
} catch {
|
|
235
|
+
send(res, 400, { error: 'Invalid JSON' })
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Only allow known, hardcoded provider IDs — never use providerId to
|
|
240
|
+
// construct a command string
|
|
241
|
+
if (typeof providerId !== 'string' || !Object.hasOwn(PROVIDERS, providerId)) {
|
|
242
|
+
send(res, 400, { error: 'Unknown provider' })
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
try {
|
|
247
|
+
const result = await checkProvider(providerId)
|
|
248
|
+
send(res, 200, result)
|
|
249
|
+
} catch (err) {
|
|
250
|
+
// Never expose internal error details
|
|
251
|
+
send(res, 500, { error: 'Verification failed' })
|
|
252
|
+
}
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
return
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// All other routes
|
|
259
|
+
res.writeHead(404)
|
|
260
|
+
res.end()
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
server.on('error', (err) => {
|
|
264
|
+
if (err.code === 'EADDRINUSE') {
|
|
265
|
+
console.error(`[codefacility-bridge] Port ${PORT} is already in use.`)
|
|
266
|
+
console.error('[codefacility-bridge] Is another instance of the bridge already running?')
|
|
267
|
+
} else {
|
|
268
|
+
console.error('[codefacility-bridge] Fatal error:', err.message)
|
|
269
|
+
}
|
|
270
|
+
process.exit(1)
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
server.listen(PORT, HOST, () => {
|
|
274
|
+
console.log(`[codefacility-bridge] Running on http://${HOST}:${PORT}`)
|
|
275
|
+
console.log('[codefacility-bridge] Only accepting connections from the CodeFacility web app.')
|
|
276
|
+
console.log('[codefacility-bridge] Press Ctrl+C to stop.')
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
// Graceful shutdown
|
|
280
|
+
process.on('SIGINT', () => { server.close(); process.exit(0) })
|
|
281
|
+
process.on('SIGTERM', () => { server.close(); process.exit(0) })
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@seventysixty/codefacility-bridge",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "CodeFacility local bridge — verifies AI CLI installations on the user's machine",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"codefacility-bridge": "index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node index.js"
|
|
11
|
+
},
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=18"
|
|
14
|
+
},
|
|
15
|
+
"keywords": ["codefacility", "devagent", "bridge", "cli"],
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/scorponmars/DevAgent.git",
|
|
20
|
+
"directory": "localclient"
|
|
21
|
+
}
|
|
22
|
+
}
|