UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

214 lines (213 loc) 9.81 kB
import { w as resolveStateDir } from "./paths-D2sRr1a_.js"; import { o as sha256HexPrefixCore } from "./crypto-digest-C4hqTb_e.js"; import { r as runCommandWithTimeout } from "./exec-BIE-3oLG.js"; import { n as withOpenClawStateLease } from "./openclaw-state-lease-Ciyr2uVu.js"; import { a as removeProjectCheckoutReference, d as withProjectCheckoutLifecycle, f as slugifyWorktreeTitle, n as listProjectRegistry, r as registerClonedProjectRegistry } from "./project-registry-Cm-dqnhv.js"; import path from "node:path"; import fs from "node:fs/promises"; import os from "node:os"; //#region src/projects/project-clone-runtime.ts const PROJECT_CLONE_TIMEOUT_MS = 6e5; var ProjectCloneError = class extends Error { constructor(failure, message) { super(message); this.failure = failure; this.name = "ProjectCloneError"; } }; function cloneCommandEnv(token, env) { const gitEnv = { ...env, GIT_TERMINAL_PROMPT: "0", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: os.devNull, GIT_TEMPLATE_DIR: "", GIT_EDITOR: "", GIT_SEQUENCE_EDITOR: "", GIT_EXTERNAL_DIFF: "", GIT_ASKPASS: void 0, SSH_ASKPASS: void 0, GIT_DIR: void 0, GIT_WORK_TREE: void 0, GIT_COMMON_DIR: void 0, GIT_INDEX_FILE: void 0, GIT_OBJECT_DIRECTORY: void 0, GIT_ALTERNATE_OBJECT_DIRECTORIES: void 0, GIT_NAMESPACE: void 0, GIT_EXEC_PATH: void 0, GIT_SSH: void 0, GIT_SSH_COMMAND: void 0, GIT_SSL_NO_VERIFY: void 0 }; if (token) { gitEnv.GIT_CONFIG_COUNT = "1"; gitEnv.GIT_CONFIG_KEY_0 = "http.https://github.com/.extraHeader"; gitEnv.GIT_CONFIG_VALUE_0 = `Authorization: Basic ${Buffer.from(`x-access-token:${token}`).toString("base64")}`; } return gitEnv; } function classifyCloneFailure(params) { const detail = params.output.toLowerCase(); if (params.timedOut || /could not resolve host|connection timed out|failed to connect/u.test(detail)) return new ProjectCloneError("network", "Git clone could not reach GitHub. Check the Gateway network connection and retry."); if (/authentication failed|permission denied|could not read username|access denied/u.test(detail)) return new ProjectCloneError("auth_required", params.tokenConfigured ? "GitHub rejected the active Control UI credential. Update gateway.controlUi.github.token when set; otherwise update the shared Gateway process environment, then retry." : "GitHub authentication is required. Configure gateway.controlUi.github.token or set GH_TOKEN/GITHUB_TOKEN in the shared Gateway process environment to clone private repositories."); if (/repository not found|not found/u.test(detail)) return params.tokenConfigured ? new ProjectCloneError("not_found", "GitHub could not find that repository. Check the URL and repository access.") : new ProjectCloneError("auth_required", "The repository was not found or is private. Check the URL, or configure gateway.controlUi.github.token (or the shared Gateway process environment) for private repositories."); return new ProjectCloneError("clone_failed", "Git could not clone that repository. Check the URL and Gateway Git configuration, then retry."); } /** Clones one already-validated source into an unoccupied managed target. */ async function cloneProjectCheckout(input, options = {}) { const env = options.env ?? process.env; if (await fs.lstat(input.target).then(() => true, () => false)) throw new ProjectCloneError("target_exists", "A managed checkout already exists for this repository. Register or remove it before retrying."); await fs.mkdir(path.dirname(input.target), { recursive: true }); const result = await runCommandWithTimeout([ "git", "clone", "--no-recurse-submodules", "--", input.url, input.target ], { env: cloneCommandEnv(options.token, env), timeoutMs: options.timeoutMs ?? PROJECT_CLONE_TIMEOUT_MS, signal: options.signal, killProcessTree: true, maxOutputBytes: 262144 }); if (result.code === 0 && result.termination === "exit") return; await fs.rm(input.target, { recursive: true, force: true }).catch(() => {}); throw classifyCloneFailure({ output: `${result.stderr}\n${result.stdout}`, tokenConfigured: Boolean(options.token), timedOut: result.termination === "timeout" || result.termination === "no-output-timeout" }); } //#endregion //#region src/projects/project-git-url.ts const GITHUB_PATH_SEGMENT = /^[A-Za-z0-9_.-]+$/u; function githubPathParts(pathname) { const segments = pathname.split("/").filter(Boolean); const owner = segments[0]; const repo = segments[1]?.replace(/\.git$/iu, ""); if (segments.length !== 2 || !owner || !repo || !GITHUB_PATH_SEGMENT.test(owner) || !GITHUB_PATH_SEGMENT.test(repo) || owner === "." || owner === ".." || repo === "." || repo === "..") return null; return { owner, repo }; } /** Canonicalizes the GitHub clone forms accepted by projects.add. */ function parseProjectGitUrl(raw) { const trimmed = raw.trim(); if (!trimmed || trimmed.startsWith("-") || trimmed.includes("\0") || /[\r\n\t ]/u.test(trimmed)) return null; const scp = /^git@github\.com:(.+)$/iu.exec(trimmed); let parts; if (scp) parts = githubPathParts(scp[1] ?? ""); else try { const url = new URL(trimmed); const isHttps = url.protocol === "https:"; const isDefaultSsh = url.protocol === "ssh:" && url.username === "git" && (!url.port || url.port === "22"); if (!isHttps && !isDefaultSsh || url.hostname.toLowerCase() !== "github.com" || url.password || isHttps && url.username || url.search || url.hash) return null; parts = githubPathParts(url.pathname); } catch { return null; } if (!parts) return null; return { url: `https://github.com/${parts.owner.toLowerCase()}/${parts.repo.toLowerCase()}.git`, name: parts.repo }; } //#endregion //#region src/projects/project-clone.ts const PROJECT_CLONE_LEASE_MS = 3e4; const PROJECT_CLONE_WAIT_MS = 3e4; function existingCanonicalProject(cfg, canonicalUrl, options) { return listProjectRegistry(cfg, options).find((project) => { return (project.originUrl ? parseProjectGitUrl(project.originUrl) : null)?.url === canonicalUrl; }); } /** Materializes and registers a project from an accepted GitHub remote. */ async function materializeProjectClone(input, options = {}) { const parsed = parseProjectGitUrl(input.gitUrl); if (!parsed) throw new ProjectCloneError("invalid_url", "Use a GitHub HTTPS or git@github.com repository URL. Local paths and file URLs are not accepted."); const env = options.env ?? process.env; const fingerprint = sha256HexPrefixCore(parsed.url, 16); return await withOpenClawStateLease({ scope: "projects.clone", key: fingerprint, database: { scope: "shared", options }, leaseMs: PROJECT_CLONE_LEASE_MS, waitMs: PROJECT_CLONE_WAIT_MS, ...options.signal ? { signal: options.signal } : {}, leaseLabel: "project clone lease", operationLabel: "projects.clone.lease" }, async (lease) => { while (true) { const candidate = existingCanonicalProject(input.cfg, parsed.url, options); if (!candidate) break; const existing = await withProjectCheckoutLifecycle(candidate.repoRoot, options, async () => { const current = existingCanonicalProject(input.cfg, parsed.url, options); return current?.repoRoot === candidate.repoRoot ? current : void 0; }); if (existing) return existing; } const displayName = input.name?.trim() || parsed.name; const directoryName = slugifyWorktreeTitle(displayName) ?? "project"; const target = path.join(resolveStateDir(env), "projects", fingerprint, directoryName); await cloneProjectCheckout({ url: parsed.url, target }, { env, signal: lease.signal, timeoutMs: options.timeoutMs, token: options.token }); try { lease.assertOwned(); return await registerClonedProjectRegistry({ path: target, name: displayName, originUrl: parsed.url }, options); } catch (error) { await fs.rm(target, { recursive: true, force: true }).catch(() => {}); throw error; } }); } async function resolveClonedProjectCheckout(project, options = {}) { if (project.source !== "cloned") throw new ProjectCloneError("clone_failed", "Only projects cloned by the Gateway can delete their checkout."); const managedRoot = await fs.realpath(path.join(resolveStateDir(options.env), "projects")); const checkout = await fs.realpath(project.repoRoot).catch(() => { throw new ProjectCloneError("clone_failed", "The managed project checkout is already unavailable. Remove only its registry entry instead."); }); const relative = path.relative(managedRoot, checkout); const segments = relative.split(path.sep); if (!relative || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative) || segments.length !== 2 || !/^[a-f0-9]{16}$/u.test(segments[0] ?? "")) throw new ProjectCloneError("clone_failed", "The cloned project is outside the Gateway-managed projects area, so its checkout was not deleted."); return checkout; } /** Removes one cloned-project reference and deletes its checkout only after the final reference. */ async function removeClonedProjectCheckout(project, assertUnreferenced, options = {}) { return await withProjectCheckoutLifecycle(project.repoRoot, options, async (lease) => { const checkout = await resolveClonedProjectCheckout(project, options); await assertUnreferenced(); const result = removeProjectCheckoutReference(project, lease, options); if (result === "missing") return false; if (result === "changed") throw new ProjectCloneError("clone_failed", "The cloned project changed before deletion."); if (result === "remaining") return true; lease.assertOwned(); await fs.rm(checkout, { recursive: true }); await fs.rmdir(path.dirname(checkout)).catch(() => {}); return true; }); } //#endregion export { ProjectCloneError as i, removeClonedProjectCheckout as n, parseProjectGitUrl as r, materializeProjectClone as t };