lib/project-http.js
Rama main · réplica de lectura del repositorio original.
var fs = require("fs");
var path = require("path");
var os = require("os");
var crypto = require("crypto");
var { execFileSync, spawn } = require("child_process");
var defaultFsAsUser = require("./os-users").fsAsUser;
var defaultUsersModule = require("./users");
var gitCli = require("./git-cli");
var attachFileHTTP = require("./project-file-http").attachFileHTTP;
var claySkillStorage = require("./clay-skill-storage");
var sharedSkillDiscovery = require("./yoke/skill-discovery");
var MAX_UPLOAD_BYTES = 50 * 1024 * 1024; // 50 MB
function parseJsonBody(req) {
return new Promise(function (resolve, reject) {
var body = "";
req.on("data", function (chunk) { body += chunk; });
req.on("end", function () {
try { resolve(JSON.parse(body)); }
catch (e) { reject(e); }
});
});
}
/**
* Attach HTTP request handler to a project context.
*
* ctx fields:
* cwd, slug, project, sm, send, sendTo, imagesDir, osUsers, pushModule,
* requestAccess, getOsUserInfoForReq, sendExtensionCommandAny,
* saveGeneratedImageToProject, _extToken, _browserTabList
*/
function attachHTTP(ctx) {
var cwd = ctx.cwd;
var slug = ctx.slug;
var project = ctx.project;
var sm = ctx.sm;
var gitAttribution = ctx.gitAttribution;
var send = ctx.send;
var imagesDir = ctx.imagesDir;
var saveGeneratedImageToProject = ctx.saveGeneratedImageToProject;
var osUsers = ctx.osUsers;
var pushModule = ctx.pushModule;
var getOsUserInfoForReq = ctx.getOsUserInfoForReq;
var fsAsUser = ctx.fsAsUser || defaultFsAsUser;
var usersModule = ctx.usersModule || defaultUsersModule;
var sendExtensionCommandAny = ctx.sendExtensionCommandAny;
var _extToken = ctx._extToken;
var _browserTabList = ctx._browserTabList;
var fileHTTP = attachFileHTTP(Object.assign({}, ctx, {
usersModule: usersModule, fsAsUser: fsAsUser,
}));
function handleHTTP(req, res, urlPath) {
// Browser MCP extension bridge: forward commands to Chrome extension
if (req.method === "POST" && urlPath === "/ext-command") {
parseJsonBody(req).then(function (body) {
// Validate auth token
if (!body.token || body.token !== _extToken) {
res.writeHead(403, { "Content-Type": "application/json" });
res.end('{"error":"Invalid token"}');
return;
}
var command = body.command;
var args = body.args || {};
var timeout = Math.min(body.timeout || 5000, 30000); // max 30s
// Special command: list_tabs (no extension round-trip needed)
if (command === "list_tabs") {
var tabArr = [];
for (var tid in _browserTabList) {
tabArr.push(_browserTabList[tid]);
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ result: { tabs: tabArr } }));
return;
}
sendExtensionCommandAny(command, args, timeout).then(function (result) {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ result: result || {} }));
}).catch(function (err) {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: err.message || "Extension command failed" }));
});
}).catch(function () {
res.writeHead(400, { "Content-Type": "application/json" });
res.end('{"error":"Invalid JSON body"}');
});
return true;
}
// Serve chat images
if (req.method === "GET" && urlPath.indexOf("/images/") === 0) {
var imgName = path.basename(urlPath);
// Sanitize: only allow expected filename pattern
if (!/^\d+-[a-f0-9]+\.\w+$/.test(imgName)) {
res.writeHead(400);
res.end("Bad request");
return true;
}
var imgPath = path.join(imagesDir, imgName);
try {
var imgBuf = fs.readFileSync(imgPath);
var ext = path.extname(imgName).toLowerCase();
var mime = ext === ".png" ? "image/png" : ext === ".gif" ? "image/gif" : ext === ".webp" ? "image/webp" : "image/jpeg";
res.writeHead(200, { "Content-Type": mime, "Cache-Control": "public, max-age=86400" });
res.end(imgBuf);
} catch (e) {
res.writeHead(404);
res.end("Not found");
}
return true;
}
// Copy a generated image from Clay's session storage into the project.
if (req.method === "POST" && urlPath === "/api/generated-image/save") {
if (usersModule.isMultiUser()) {
var imageSaveUser = req._clayUser;
var imageSavePermissions = imageSaveUser ? usersModule.getEffectivePermissions(imageSaveUser, osUsers) : null;
if (!imageSavePermissions || !imageSavePermissions.fileBrowser) {
res.writeHead(403, { "Content-Type": "application/json" });
res.end('{"error":"File browser access is not permitted"}');
return true;
}
}
parseJsonBody(req).then(function (body) {
if (typeof saveGeneratedImageToProject !== "function") {
res.writeHead(501, { "Content-Type": "application/json" });
res.end('{"error":"Generated image saving is not available"}');
return;
}
var result = saveGeneratedImageToProject(
body.fileName,
body.path,
!!body.overwrite,
getOsUserInfoForReq(req)
);
res.writeHead(result.ok ? 200 : (result.status || 500), { "Content-Type": "application/json" });
res.end(JSON.stringify(result));
}).catch(function () {
res.writeHead(400, { "Content-Type": "application/json" });
res.end('{"error":"Invalid JSON body"}');
});
return true;
}
// File upload
if (req.method === "POST" && urlPath === "/api/upload") {
parseJsonBody(req).then(function (body) {
var fileName = body.name;
var fileData = body.data; // base64
if (!fileName || !fileData) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end('{"error":"missing name or data"}');
return;
}
// Sanitize filename — strip path separators
var safeName = path.basename(fileName).replace(/[\x00-\x1f\/\\:*?"<>|]/g, "_");
if (!safeName) safeName = "upload";
// Check size
var estimatedBytes = fileData.length * 0.75;
if (estimatedBytes > MAX_UPLOAD_BYTES) {
res.writeHead(413, { "Content-Type": "application/json" });
res.end('{"error":"file too large (max 50MB)"}');
return;
}
// Create tmp dir: os.tmpdir()/clay-{hash}/
var cwdHash = crypto.createHash("sha256").update(cwd).digest("hex").substring(0, 12);
var tmpDir = path.join(os.tmpdir(), "clay-" + cwdHash);
try { fs.mkdirSync(tmpDir, { recursive: true }); } catch (e) {}
// Add timestamp prefix to avoid collisions
var ts = Date.now();
var destName = ts + "-" + safeName;
var destPath = path.join(tmpDir, destName);
try {
var buf = Buffer.from(fileData, "base64");
fs.writeFileSync(destPath, buf);
// Make readable by all local users and chown to session owner
try { fs.chmodSync(destPath, 0o644); } catch (e2) {}
try { fs.chmodSync(tmpDir, 0o755); } catch (e2) {}
if (req._clayUser && req._clayUser.linuxUser) {
try {
var _osUM = require("./os-users");
var _uid = _osUM.getLinuxUserUid(req._clayUser.linuxUser);
if (_uid != null) {
execFileSync("chown", [String(_uid), destPath]);
execFileSync("chown", [String(_uid), tmpDir]);
}
} catch (e2) {}
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ path: destPath, name: safeName }));
} catch (e) {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "failed to save: " + (e.message || e) }));
}
}).catch(function () {
res.writeHead(400);
res.end("Bad request");
});
return true;
}
// Push subscribe
if (req.method === "POST" && urlPath === "/api/push-subscribe") {
parseJsonBody(req).then(function (body) {
var sub = body.subscription || body;
if (pushModule) pushModule.addSubscription(sub, body.replaceEndpoint);
res.writeHead(200, { "Content-Type": "application/json" });
res.end('{"ok":true}');
}).catch(function () {
res.writeHead(400);
res.end("Bad request");
});
return true;
}
// Permission response from push notification
if (req.method === "POST" && urlPath === "/api/permission-response") {
parseJsonBody(req).then(function (data) {
var requestId = data.requestId;
var decision = data.decision;
if (!requestId || !decision) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end('{"error":"missing requestId or decision"}');
return;
}
var found = false;
sm.sessions.forEach(function (session) {
var pending = session.pendingPermissions[requestId];
if (!pending) return;
found = true;
delete session.pendingPermissions[requestId];
if (decision === "allow") {
pending.resolve({ behavior: "allow", updatedInput: pending.toolInput });
} else {
pending.resolve({ behavior: "deny", message: "Denied via push notification" });
}
sm.sendAndRecord(session, {
type: "permission_resolved",
requestId: requestId,
decision: decision,
});
});
if (found) {
res.writeHead(200, { "Content-Type": "application/json" });
res.end('{"ok":true}');
} else {
res.writeHead(404, { "Content-Type": "application/json" });
res.end('{"error":"permission request not found"}');
}
}).catch(function () {
res.writeHead(400);
res.end("Bad request");
});
return true;
}
// /api/tui-notify is handled at the server top-level (server.js
// appHandler) so a single hook in ~/.claude/settings.json can route
// to any project. Don't shadow it here.
// VAPID public key
if (req.method === "GET" && urlPath === "/api/vapid-public-key") {
if (pushModule) {
res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-cache, no-store" });
res.end(JSON.stringify({ publicKey: pushModule.publicKey }));
} else {
res.writeHead(404, { "Content-Type": "application/json" });
res.end('{"error":"push not available"}');
}
return true;
}
if (fileHTTP.handleHTTP(req, res, urlPath)) return true;
// Skills permission gate
if (urlPath === "/api/install-skill" || urlPath === "/api/uninstall-skill" || urlPath === "/api/installed-skills") {
if (req._clayUser) {
var skPerms = usersModule.getEffectivePermissions(req._clayUser, osUsers);
if (!skPerms.skills) {
res.writeHead(403, { "Content-Type": "application/json" });
res.end('{"error":"Skills access is not permitted"}');
return true;
}
}
}
// Install a skill (background spawn)
if (req.method === "POST" && urlPath === "/api/install-skill") {
parseJsonBody(req).then(function (body) {
var url = body.url;
var skill = body.skill;
var scope = body.scope; // "global" or "project"
if (!url || !skill || !scope) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end('{"error":"missing url, skill, or scope"}');
return;
}
// Validate skill name: alphanumeric, hyphens, underscores only
if (!/^[a-zA-Z0-9_-]+$/.test(skill)) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end('{"error":"invalid skill name"}');
return;
}
// Validate URL: must be https://
if (!/^https:\/\//i.test(url)) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end('{"error":"only https:// URLs are allowed"}');
return;
}
var skillUserInfo = getOsUserInfoForReq(req);
if (osUsers && !skillUserInfo) throw new Error("A mapped OS user is required to install skills");
var clayOnly = claySkillStorage.isClaySkill(skill);
var spawnCwd = scope === "global" ? (skillUserInfo ? skillUserInfo.home : require("./config").REAL_HOME) : cwd;
var scopeFlag = scope === "global" ? "--global" : "--project";
var skillSpawnOpts = {
cwd: spawnCwd,
stdio: ["ignore", "pipe", "pipe"],
detached: false,
};
if (skillUserInfo) {
skillSpawnOpts.uid = skillUserInfo.uid;
skillSpawnOpts.gid = skillUserInfo.gid;
skillSpawnOpts.env = Object.assign({}, process.env, {
HOME: skillUserInfo.home,
npm_config_cache: require("path").join(skillUserInfo.home, ".npm"),
});
}
console.log("[skill-install] " + skill + " destination: " + (clayOnly ? claySkillStorage.privateRoot(spawnCwd) : spawnCwd));
var skillAddSpawn = require("./os-users").wrapSpawnAsUser("npx", ["skills", "add", url, "--skill", skill, "--yes", scopeFlag], skillSpawnOpts);
if (clayOnly) {
skillAddSpawn = require("./os-users").wrapSpawnAsUser(process.execPath, [require.resolve("./clay-skill-install"), spawnCwd, skill, url], skillSpawnOpts);
}
var child = spawn(skillAddSpawn.command, skillAddSpawn.args, skillAddSpawn.options);
var stdoutBuf = "";
var stderrBuf = "";
child.stdout.on("data", function (chunk) {
stdoutBuf += chunk.toString();
console.log("[skill-install] " + skill + " stdout chunk: " + chunk.toString().trim().slice(0, 500));
});
child.stderr.on("data", function (chunk) {
stderrBuf += chunk.toString();
console.log("[skill-install] " + skill + " stderr chunk: " + chunk.toString().trim().slice(0, 500));
});
// Timeout after 60 seconds
var installTimeout = setTimeout(function () {
console.error("[skill-install] " + skill + " timed out after 60s, killing process");
try { child.kill("SIGTERM"); } catch (e) {}
try {
send({ type: "skill_installed", skill: skill, scope: scope, success: false, error: "Installation timed out after 60 seconds" });
} catch (e) {}
}, 60000);
child.on("close", function (code) {
clearTimeout(installTimeout);
console.log("[skill-install] " + skill + " exited with code " + code + " (stdout=" + stdoutBuf.length + "b, stderr=" + stderrBuf.length + "b)");
if (stdoutBuf) console.log("[skill-install] stdout: " + stdoutBuf.slice(0, 2000));
if (stderrBuf) console.log("[skill-install] stderr: " + stderrBuf.slice(0, 2000));
try {
var success = code === 0;
send({
type: "skill_installed",
skill: skill,
scope: scope,
success: success,
error: success ? null : "Process exited with code " + code,
});
} catch (e) {
console.error("[project] skill_installed send failed:", e.message || e);
}
});
child.on("error", function (err) {
clearTimeout(installTimeout);
console.error("[skill-install] " + skill + " spawn error:", err.message || err);
try {
send({
type: "skill_installed",
skill: skill,
scope: scope,
success: false,
error: err.message,
});
} catch (e) {
console.error("[skill-install] " + skill + " send failed:", e.message || e);
}
});
res.writeHead(200, { "Content-Type": "application/json" });
res.end('{"ok":true}');
}).catch(function () {
res.writeHead(400);
res.end("Bad request");
});
return true;
}
// Uninstall a skill (remove directory)
if (req.method === "POST" && urlPath === "/api/uninstall-skill") {
parseJsonBody(req).then(function (body) {
var skill = body.skill;
var scope = body.scope; // "global" or "project"
if (!skill || !scope) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end('{"error":"missing skill or scope"}');
return;
}
// Validate skill name
if (!/^[a-zA-Z0-9_-]+$/.test(skill)) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end('{"error":"invalid skill name"}');
return;
}
var uninstallUserInfo = getOsUserInfoForReq(req);
var baseDir = scope === "global" ? (uninstallUserInfo ? uninstallUserInfo.home : require("./config").REAL_HOME) : cwd;
if (osUsers && !uninstallUserInfo) throw new Error("A mapped OS user is required to uninstall skills");
var skillDir = claySkillStorage.isClaySkill(skill) ? path.join(claySkillStorage.privateRoot(baseDir), skill) : path.join(require("./yoke/source-locations").legacySkillRoot(baseDir), skill);
// Safety: keep removal inside the selected skill storage root.
var expectedParent = claySkillStorage.isClaySkill(skill) ? claySkillStorage.privateRoot(baseDir) : require("./yoke/source-locations").legacySkillRoot(baseDir);
var resolved = path.resolve(skillDir);
if (!resolved.startsWith(expectedParent + path.sep)) {
res.writeHead(403, { "Content-Type": "application/json" });
res.end('{"error":"invalid skill path"}');
return;
}
try {
if (uninstallUserInfo) {
// Run rm as target user to respect permissions
var rmScript = "var fs = require('fs'); fs.rmSync(" + JSON.stringify(resolved) + ", { recursive: true, force: true });";
var rmSpawn = require("./os-users").wrapSpawnAsUser(process.execPath, ["-e", rmScript], {
uid: uninstallUserInfo.uid,
gid: uninstallUserInfo.gid,
timeout: 10000,
});
execFileSync(rmSpawn.command, rmSpawn.args, rmSpawn.options);
} else {
fs.rmSync(resolved, { recursive: true, force: true });
}
send({
type: "skill_uninstalled",
skill: skill,
scope: scope,
success: true,
});
res.writeHead(200, { "Content-Type": "application/json" });
res.end('{"ok":true}');
} catch (err) {
send({
type: "skill_uninstalled",
skill: skill,
scope: scope,
success: false,
error: err.message,
});
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: err.message }));
}
}).catch(function () {
res.writeHead(400);
res.end("Bad request");
});
return true;
}
// Installed skills (global + project)
if (req.method === "GET" && urlPath === "/api/installed-skills") {
var installed = {};
var installedUserInfo = getOsUserInfoForReq(req);
if (osUsers && !installedUserInfo) {
res.writeHead(403, { "Content-Type": "application/json" });
res.end('{"error":"A mapped OS user is required to list skills"}');
return true;
}
var installedOptions = installedUserInfo
? sharedSkillDiscovery.optionsForOsUser(installedUserInfo, fsAsUser)
: { homeDir: require("./config").REAL_HOME };
installedOptions.includeDuplicates = true;
var discoveredInstalled = sharedSkillDiscovery.discoverSkills(cwd, installedOptions);
for (var si = 0; si < discoveredInstalled.length; si++) {
var discoveredSkill = discoveredInstalled[si];
try {
var desc = discoveredSkill.description || "";
var version = discoveredSkill.version || "";
var scope = discoveredSkill.source.indexOf("-project") !== -1 || discoveredSkill.source === "project" ? "project" : "global";
if (!installed[discoveredSkill.name]) {
installed[discoveredSkill.name] = { scope: scope, description: desc || discoveredSkill.description, version: version, path: path.dirname(discoveredSkill.path), source: discoveredSkill.source, sources: [discoveredSkill.source] };
} else {
// project-level adds to existing global entry
var priorScope = installed[discoveredSkill.name].scope;
installed[discoveredSkill.name].scope = priorScope === scope ? scope : "both";
if (installed[discoveredSkill.name].sources.indexOf(discoveredSkill.source) === -1) installed[discoveredSkill.name].sources.push(discoveredSkill.source);
if (scope === "project" || priorScope === "project") {
installed[discoveredSkill.name].path = path.dirname(discoveredSkill.path);
installed[discoveredSkill.name].source = discoveredSkill.source;
installed[discoveredSkill.name].description = desc || discoveredSkill.description;
installed[discoveredSkill.name].version = version;
}
if (desc && !installed[discoveredSkill.name].description) installed[discoveredSkill.name].description = desc;
if (version && !installed[discoveredSkill.name].version) installed[discoveredSkill.name].version = version;
}
} catch (e) {}
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ installed: installed }));
return true;
}
// Skill update check cache (avoid redundant GitHub fetches)
if (!ctx._skillCheckCache) ctx._skillCheckCache = {};
var SKILL_CHECK_TTL = 5 * 60 * 1000; // 5 minutes
// Check skill updates (compare installed vs remote versions)
if (req.method === "POST" && urlPath === "/api/check-skill-updates") {
parseJsonBody(req).then(function (body) {
var skills = body.skills; // [{ name, url, scope }]
if (!Array.isArray(skills) || skills.length === 0) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end('{"error":"missing skills array"}');
return;
}
// Read installed versions (use requesting user's home in multi-user setups)
var skillUserHome = (function () {
var sui = getOsUserInfoForReq(req);
return sui ? sui.home : require("./config").REAL_HOME;
})();
var globalSkillsDir = require("./yoke/source-locations").legacySkillRoot(skillUserHome);
var projectSkillsDir = require("./yoke/source-locations").legacySkillRoot(cwd);
var results = [];
var pending = skills.length;
function parseVersionFromSkillMd(content) {
if (!content || !content.startsWith("---")) return "";
var endIdx = content.indexOf("---", 3);
if (endIdx === -1) return "";
var fm = content.substring(3, endIdx);
var m = fm.match(/version:\s*"?([^"\n]+)"?/m);
return m ? m[1].trim() : "";
}
function getInstalledVersion(name) {
var dirs = claySkillStorage.isClaySkill(name)
? [path.join(claySkillStorage.privateRoot(cwd), name, "SKILL.md"), path.join(claySkillStorage.privateRoot(skillUserHome), name, "SKILL.md"), path.join(__dirname, "bundled-skills", name, "SKILL.md")]
: [path.join(globalSkillsDir, name, "SKILL.md"), path.join(projectSkillsDir, name, "SKILL.md")];
for (var d = 0; d < dirs.length; d++) {
try {
var c = fs.readFileSync(dirs[d], "utf8");
var v = parseVersionFromSkillMd(c);
if (v) return v;
} catch (e) {}
}
return "";
}
function compareVersions(a, b) {
// returns -1 if a < b, 0 if equal, 1 if a > b
if (!a && !b) return 0;
if (!a) return -1;
if (!b) return 1;
var pa = a.split(".").map(Number);
var pb = b.split(".").map(Number);
for (var i = 0; i < Math.max(pa.length, pb.length); i++) {
var va = pa[i] || 0;
var vb = pb[i] || 0;
if (va < vb) return -1;
if (va > vb) return 1;
}
return 0;
}
function finishOne() {
pending--;
if (pending === 0) {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ results: results }));
}
}
for (var si = 0; si < skills.length; si++) {
(function (skill) {
var installedVer = getInstalledVersion(skill.name);
var installed = !!installedVer;
// Return cached result if fresh
var cacheKey = skill.name + ":" + (installedVer || "");
var cached = ctx._skillCheckCache[cacheKey];
if (cached && (Date.now() - cached.ts) < SKILL_CHECK_TTL) {
results.push(cached.result);
finishOne();
return;
}
// Convert GitHub repo URL to raw SKILL.md URL
var rawUrl = "";
var ghMatch = skill.url.match(/github\.com\/([^/]+)\/([^/]+)/);
if (ghMatch) {
rawUrl = "https://raw.githubusercontent.com/" + ghMatch[1] + "/" + ghMatch[2] + "/main/SKILL.md";
}
if (!rawUrl) {
var r0 = { name: skill.name, installed: installed, installedVersion: installedVer, remoteVersion: "", status: installed ? "ok" : "missing" };
ctx._skillCheckCache[cacheKey] = { ts: Date.now(), result: r0 };
results.push(r0);
finishOne();
return;
}
// Fetch remote SKILL.md
var https = require("https");
https.get(rawUrl, function (resp) {
var data = "";
resp.on("data", function (chunk) { data += chunk; });
resp.on("end", function () {
try {
var remoteVer = parseVersionFromSkillMd(data);
var status = "ok";
if (!installed) {
status = "missing";
} else if (remoteVer && compareVersions(installedVer, remoteVer) < 0) {
status = "outdated";
}
var r1 = { name: skill.name, installed: installed, installedVersion: installedVer, remoteVersion: remoteVer, status: status };
ctx._skillCheckCache[cacheKey] = { ts: Date.now(), result: r1 };
results.push(r1);
finishOne();
} catch (e) {
console.error("[skill-check] " + skill.name + " version parse failed:", e.message || e);
results.push({ name: skill.name, installed: installed, installedVersion: installedVer, remoteVersion: "", status: installed ? "ok" : "error" });
finishOne();
}
});
}).on("error", function (err) {
console.error("[skill-check] " + skill.name + " fetch error:", err.message || err);
results.push({ name: skill.name, installed: installed, installedVersion: installedVer, remoteVersion: "", status: installed ? "ok" : "missing" });
finishOne();
});
})(skills[si]);
}
}).catch(function () {
res.writeHead(400);
res.end("Bad request");
});
return true;
}
// Git panel status and actions
var isGitPanelRequest = urlPath === "/api/git/status" || urlPath === "/api/git/summary" ||
urlPath === "/api/git/action" || urlPath.indexOf("/api/git/file-diff?") === 0 ||
urlPath.indexOf("/api/git/session-diff?") === 0;
if (isGitPanelRequest && usersModule.isMultiUser()) {
var gitRequestUser = req._clayUser;
var gitPermissions = gitRequestUser ? usersModule.getEffectivePermissions(gitRequestUser, osUsers) : null;
if (!gitPermissions || !gitPermissions.fileBrowser) {
res.writeHead(403, { "Content-Type": "application/json" });
res.end('{"error":"Git access is not permitted"}');
return true;
}
}
if (req.method === "GET" && urlPath === "/api/git/status") {
var gitUserInfo = getOsUserInfoForReq(req);
try {
var gitStatus = gitCli.getStatus(cwd, gitUserInfo);
if (ctx.workspaceOperations) {
try { gitStatus.controlView = require('./project-control-git-view').project(ctx.workspaceOperations, { userId: req._clayUser && req._clayUser.id || null, singleUserOwnerId: ctx.getProjectOwnerId && ctx.getProjectOwnerId() || null }, gitStatus, sm); } catch (ignored) {}
}
if (gitAttribution) {
var visibleGitSessions = sm.sessions;
if (usersModule.isMultiUser() && req._clayUser) {
visibleGitSessions = new Map();
sm.sessions.forEach(function (session, id) {
if (usersModule.canAccessSession(req._clayUser.id, session, { visibility: "public" })) {
visibleGitSessions.set(id, session);
}
});
}
gitAttribution.decorateStatus(gitStatus, visibleGitSessions);
}
res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" });
res.end(JSON.stringify(gitStatus));
} catch (e) {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: e.message || "Unable to read Git status" }));
}
return true;
}
// Compact repository placard data for the always-visible sidebar block.
// Same permission gate as the panel, but no file payload and no path.
if (req.method === "GET" && urlPath === "/api/git/summary") {
try {
var gitSummary = gitCli.getSummary(cwd, getOsUserInfoForReq(req));
res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" });
res.end(JSON.stringify(gitSummary));
} catch (e) {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: e.message || "Unable to read Git summary" }));
}
return true;
}
if (req.method === "GET" && urlPath.indexOf("/api/git/file-diff?") === 0) {
var gitDiffParams = new URLSearchParams(urlPath.slice(urlPath.indexOf("?")));
var gitDiffPath = gitDiffParams.get("path");
if (!gitDiffPath) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end('{"error":"Missing file path"}');
return true;
}
try {
var gitDiffResult = gitCli.getFileDiff(cwd, gitDiffPath, getOsUserInfoForReq(req));
res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" });
res.end(JSON.stringify(gitDiffResult));
} catch (e) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: e.message || "Unable to read file diff" }));
}
return true;
}
if (req.method === "GET" && urlPath.indexOf("/api/git/session-diff?") === 0) {
var sessionDiffParams = new URLSearchParams(urlPath.slice(urlPath.indexOf("?")));
var sessionDiffKey = sessionDiffParams.get("session");
var sessionDiffPath = sessionDiffParams.get("path");
if (!gitAttribution || !sessionDiffKey || !sessionDiffPath) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end('{"error":"Missing session comparison"}');
return true;
}
try {
var requestedSessionVisible = false;
sm.sessions.forEach(function (session) {
var key = session.cliSessionId || ("local:" + session.localId);
if (key !== sessionDiffKey) return;
requestedSessionVisible = !usersModule.isMultiUser() ||
(req._clayUser && usersModule.canAccessSession(req._clayUser.id, session, { visibility: "public" }));
});
if (!requestedSessionVisible) throw new Error("Session comparison is unavailable");
var sessionDiff = gitAttribution.getSessionBaselineDiff(sessionDiffKey, sessionDiffPath, getOsUserInfoForReq(req));
res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" });
res.end(JSON.stringify(sessionDiff));
} catch (e) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: e.message || "Unable to compare session changes" }));
}
return true;
}
if (req.method === "POST" && urlPath === "/api/git/action") {
var gitActionUserInfo = getOsUserInfoForReq(req);
parseJsonBody(req).then(function (body) {
return gitCli.runAction(cwd, body, gitActionUserInfo);
}).then(function (output) {
var updatedStatus = gitCli.getStatus(cwd, gitActionUserInfo);
res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" });
res.end(JSON.stringify({ ok: true, output: output, status: updatedStatus }));
}).catch(function (err) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: err.userMessage || err.message || "Git action failed" }));
});
return true;
}
// Legacy dirty check used by the Ralph Loop flow
if (req.method === "GET" && urlPath === "/api/git-dirty") {
try {
var out = execFileSync("git", ["status", "--porcelain"], { cwd: cwd, encoding: "utf8", timeout: 5000 });
var lines = out.trim().split("\n").filter(function (line) {
return line.trim().length > 0 && !line.startsWith("??");
});
var dirty = lines.length > 0;
var files = lines.map(function (line) { return line.trim(); });
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ dirty: dirty, files: files }));
} catch (e) {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ dirty: false }));
}
return true;
}
// List branches for worktree modal
if (req.method === "GET" && urlPath === "/api/branches") {
try {
var brRaw = execFileSync("git", ["branch", "-a", "--format=%(refname:short)"], {
cwd: cwd, timeout: 5000, encoding: "utf8"
});
var brList = brRaw.trim().split("\n").filter(Boolean);
var defBr = "main";
try {
var hrRef = execFileSync("git", ["symbolic-ref", "refs/remotes/origin/HEAD", "--short"], {
cwd: cwd, timeout: 3000, encoding: "utf8"
}).trim();
defBr = hrRef.replace(/^origin\//, "");
} catch (e) {}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ branches: brList, defaultBranch: defBr }));
} catch (e) {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ branches: ["main"], defaultBranch: "main" }));
}
return true;
}
// MCP bridge endpoint: allows Codex's mcp-bridge-server.js to list/call
// in-app and remote MCP tools via HTTP (localhost only).
if (req.method === "POST" && urlPath === "/api/mcp-bridge") {
parseJsonBody(req).then(function (body) {
var action = body.action;
var getMcpBridgeHandler = ctx.getMcpBridgeHandler;
if (!getMcpBridgeHandler) {
res.writeHead(500, { "Content-Type": "application/json" });
res.end('{"error":"MCP bridge not configured"}');
return;
}
var sessionId = Number(body.sessionId);
if (!Number.isInteger(sessionId)) sessionId = null;
var queryGeneration = Number(body.queryGeneration);
if (!Number.isInteger(queryGeneration)) queryGeneration = null;
var handler = getMcpBridgeHandler(sessionId, body.sessionOnly === true, queryGeneration);
if (!handler) {
res.writeHead(500, { "Content-Type": "application/json" });
res.end('{"error":"MCP bridge handler unavailable"}');
return;
}
if (action === "list_tools") {
handler.listTools().then(function (tools) {
var serverCounts = {};
for (var ti = 0; ti < tools.length; ti++) {
serverCounts[tools[ti].server] = (serverCounts[tools[ti].server] || 0) + 1;
}
console.log("[mcp-bridge-http] list_tools:", tools.length, "tools -", Object.keys(serverCounts).map(function(s) { return s + "(" + serverCounts[s] + ")"; }).join(", ") || "(none)");
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ tools: tools }));
}).catch(function (err) {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: err.message || "Failed to list tools" }));
});
} else if (action === "call_tool") {
var server = body.server;
var tool = body.tool;
var args = body.args || {};
console.log("[mcp-bridge-http] call_tool:", server + "/" + tool);
handler.callTool(server, tool, args).then(function (result) {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ result: result }));
}).catch(function (err) {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: err.message || "Tool call failed" }));
});
} else {
res.writeHead(400, { "Content-Type": "application/json" });
res.end('{"error":"Unknown action: ' + (action || '') + '"}');
}
}).catch(function () {
res.writeHead(400, { "Content-Type": "application/json" });
res.end('{"error":"Invalid JSON body"}');
});
return true;
}
// Info endpoint
if (req.method === "GET" && urlPath === "/info") {
res.writeHead(200, {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
});
res.end(JSON.stringify({ cwd: cwd, project: project, slug: slug }));
return true;
}
return false; // not handled
}
return {
handleHTTP: handleHTTP,
};
}
module.exports = { attachHTTP: attachHTTP, parseJsonBody: parseJsonBody };