Add credential proxy server runtime
Create the host-side proxy runtime so containers can forward HTTPS Git credential requests to the host helper. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
98
helper.mjs
Normal file
98
helper.mjs
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import http from 'node:http';
|
||||||
|
import https from 'node:https';
|
||||||
|
import path from 'node:path';
|
||||||
|
import process from 'node:process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
const operation = process.argv[2] ?? '';
|
||||||
|
const supportedOperations = new Set(['get', 'store', 'erase']);
|
||||||
|
|
||||||
|
if (!supportedOperations.has(operation)) {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxyUrl = process.env.GIT_CRED_PROXY_URL ?? 'http://host.docker.internal:18765';
|
||||||
|
const tokenFile =
|
||||||
|
process.env.GIT_CRED_PROXY_TOKEN_FILE ?? path.join(__dirname, 'state', 'token');
|
||||||
|
|
||||||
|
function readStdin() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks = [];
|
||||||
|
|
||||||
|
process.stdin.on('data', (chunk) => {
|
||||||
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||||
|
});
|
||||||
|
|
||||||
|
process.stdin.on('end', () => {
|
||||||
|
resolve(Buffer.concat(chunks).toString('utf8'));
|
||||||
|
});
|
||||||
|
|
||||||
|
process.stdin.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function request(url, body, token) {
|
||||||
|
const client = url.protocol === 'https:' ? https : http;
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = client.request(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'text/plain; charset=utf-8',
|
||||||
|
'Content-Length': Buffer.byteLength(body),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
const chunks = [];
|
||||||
|
|
||||||
|
res.on('data', (chunk) => {
|
||||||
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||||
|
});
|
||||||
|
|
||||||
|
res.on('end', () => {
|
||||||
|
resolve({
|
||||||
|
statusCode: res.statusCode ?? 0,
|
||||||
|
body: Buffer.concat(chunks).toString('utf8'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
req.on('error', reject);
|
||||||
|
req.end(body);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const token = (process.env.GIT_CRED_PROXY_TOKEN ?? (await fs.readFile(tokenFile, 'utf8'))).trim();
|
||||||
|
const body = await readStdin();
|
||||||
|
const url = new URL(`/${operation}`, proxyUrl);
|
||||||
|
const response = await request(url, body, token);
|
||||||
|
|
||||||
|
if (response.statusCode === 200) {
|
||||||
|
process.stdout.write(response.body);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.body) {
|
||||||
|
process.stderr.write(response.body.trimEnd() + '\n');
|
||||||
|
} else {
|
||||||
|
process.stderr.write(`Proxy request failed with status ${response.statusCode}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
205
server.mjs
Normal file
205
server.mjs
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import http from 'node:http';
|
||||||
|
import process from 'node:process';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
|
||||||
|
const host = process.env.GIT_CRED_PROXY_HOST ?? '127.0.0.1';
|
||||||
|
const port = Number(process.env.GIT_CRED_PROXY_PORT ?? '18765');
|
||||||
|
const token = process.env.GIT_CRED_PROXY_TOKEN ?? '';
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
process.stderr.write('Missing GIT_CRED_PROXY_TOKEN\n');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedProtocols = new Set(
|
||||||
|
(process.env.GIT_CRED_PROXY_PROTOCOLS ?? 'https')
|
||||||
|
.split(',')
|
||||||
|
.map((value) => value.trim().toLowerCase())
|
||||||
|
.filter(Boolean),
|
||||||
|
);
|
||||||
|
|
||||||
|
const allowedHosts = new Set(
|
||||||
|
(process.env.GIT_CRED_PROXY_ALLOWED_HOSTS ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
);
|
||||||
|
|
||||||
|
const actionMap = new Map([
|
||||||
|
['get', 'fill'],
|
||||||
|
['store', 'approve'],
|
||||||
|
['erase', 'reject'],
|
||||||
|
['fill', 'fill'],
|
||||||
|
['approve', 'approve'],
|
||||||
|
['reject', 'reject'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
function parseCredentialBody(body) {
|
||||||
|
const result = {};
|
||||||
|
|
||||||
|
for (const line of body.split('\n')) {
|
||||||
|
if (!line || !line.includes('=')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = line.indexOf('=');
|
||||||
|
const key = line.slice(0, index);
|
||||||
|
const value = line.slice(index + 1);
|
||||||
|
result[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBody(req) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks = [];
|
||||||
|
let size = 0;
|
||||||
|
|
||||||
|
req.on('data', (chunk) => {
|
||||||
|
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||||
|
size += buffer.length;
|
||||||
|
|
||||||
|
if (size > 64 * 1024) {
|
||||||
|
reject(new Error('Request body too large'));
|
||||||
|
req.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks.push(buffer);
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('end', () => {
|
||||||
|
resolve(Buffer.concat(chunks).toString('utf8'));
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runGitCredential(action, input) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn('git', ['credential', action], {
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
GIT_TERMINAL_PROMPT: '0',
|
||||||
|
},
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
|
||||||
|
child.stdout.on('data', (chunk) => {
|
||||||
|
stdout += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stderr.on('data', (chunk) => {
|
||||||
|
stderr += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('error', reject);
|
||||||
|
child.on('close', (code) => {
|
||||||
|
resolve({ code: code ?? 1, stdout, stderr });
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stdin.end(input);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendText(res, statusCode, body) {
|
||||||
|
res.writeHead(statusCode, {
|
||||||
|
'Content-Type': 'text/plain; charset=utf-8',
|
||||||
|
});
|
||||||
|
res.end(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMissingCredentialError(stderr) {
|
||||||
|
const normalized = stderr.toLowerCase();
|
||||||
|
return (
|
||||||
|
normalized.includes('terminal prompts disabled') ||
|
||||||
|
normalized.includes('could not read username') ||
|
||||||
|
normalized.includes('could not read password')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
try {
|
||||||
|
const url = new URL(req.url ?? '/', 'http://localhost');
|
||||||
|
|
||||||
|
if (req.method === 'GET' && url.pathname === '/healthz') {
|
||||||
|
sendText(res, 200, 'ok\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method !== 'POST') {
|
||||||
|
sendText(res, 405, 'Method Not Allowed\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authHeader = req.headers.authorization ?? '';
|
||||||
|
if (authHeader !== `Bearer ${token}`) {
|
||||||
|
sendText(res, 401, 'Unauthorized\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const action = actionMap.get(url.pathname.replace(/^\//, ''));
|
||||||
|
if (!action) {
|
||||||
|
sendText(res, 404, 'Not Found\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await readBody(req);
|
||||||
|
const attrs = parseCredentialBody(body);
|
||||||
|
const protocol = attrs.protocol?.toLowerCase();
|
||||||
|
const requestHost = attrs.host;
|
||||||
|
|
||||||
|
if (allowedProtocols.size > 0) {
|
||||||
|
if (!protocol) {
|
||||||
|
sendText(res, 400, 'Missing protocol\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allowedProtocols.has(protocol)) {
|
||||||
|
sendText(res, 403, 'Protocol not allowed\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowedHosts.size > 0 && requestHost && !allowedHosts.has(requestHost)) {
|
||||||
|
sendText(res, 403, 'Host not allowed\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await runGitCredential(action, body);
|
||||||
|
|
||||||
|
if (result.code === 0) {
|
||||||
|
sendText(res, 200, result.stdout);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'fill' && isMissingCredentialError(result.stderr)) {
|
||||||
|
sendText(res, 200, '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendText(res, 502, result.stderr || `git credential ${action} failed\n`);
|
||||||
|
} catch (error) {
|
||||||
|
sendText(res, 500, `${error instanceof Error ? error.message : String(error)}\n`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(port, host, () => {
|
||||||
|
process.stdout.write(`Git credential proxy listening on http://${host}:${port}\n`);
|
||||||
|
});
|
||||||
|
|
||||||
|
function shutdown() {
|
||||||
|
server.close(() => {
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('SIGINT', shutdown);
|
||||||
|
process.on('SIGTERM', shutdown);
|
||||||
Reference in New Issue
Block a user