@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.
Files changed (3) hide show
  1. package/README.md +59 -0
  2. package/index.js +281 -0
  3. 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
+ }