Ctrl K
ComunidadGuías…

lib/project-http.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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 };