openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
522 lines (521 loc) • 22.3 kB
JavaScript
import { a as extractErrorCode, i as collectNestedErrorCandidates } from "./error-coercion-W9BtigRm.mjs";
import { t as hasErrnoCode } from "./errno-FR4CRyya.mjs";
import { r as resolveStateDir } from "./state-dir-CYkBUd_G.mjs";
import "./paths-BP0DYpE8.mjs";
import { r as sha256HexPrefixCore } from "./node-crypto-BG4u3UnX.mjs";
import "./crypto-digest-y6oZN-_F.mjs";
import { r as withGitProcessOperation } from "./spawn-diagnostics-D1OSGice.mjs";
import { r as cloneEnvWithPlatformSemantics } from "./config-env-vars-BzUE_dOh.mjs";
import { r as captureOpenClawStateWorkerContext } from "./openclaw-state-worker-context-BvvgY6-t.mjs";
import { i as runCommandWithTimeout } from "./exec-DdJWnONN.mjs";
import { a as executeGitCommand, f as requireGitCommandOutput, h as withGitNetworkRetry, l as gitNullConfigPath } from "./git-exec-BOEpfMeZ.mjs";
import { t as withOpenClawStateLease } from "./openclaw-state-lease-BVIgxmmg.mjs";
import { h as withGitRepositoryRepair } from "./git-D3obqZyl.mjs";
import { i as resolveConfiguredGitHubHost } from "./github-host-N2fYlLEb.mjs";
import { n as slugifyWorktreeTitle } from "./name-B-ezQkAd.mjs";
import { l as prepareProjectRegistration, m as withProjectCheckoutLifecycle, p as resolveProjectDirectory, t as listProjectRegistry, u as registerPreparedProjectRegistry } from "./project-registry-hZM6QWbe.mjs";
import { t as parseProjectGitUrl } from "./project-git-url-BUjq6Sa-.mjs";
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;
const PROJECT_FETCH_TIMEOUT_MS = 6e4;
var ProjectCloneError = class extends Error {
constructor(failure, message) {
super(message);
this.failure = failure;
this.name = "ProjectCloneError";
}
};
function cloneCommandEnv(repositoryUrl, token, env) {
const gitEnv = {
...env,
GIT_TERMINAL_PROMPT: "0",
GIT_CONFIG_NOSYSTEM: "1",
GIT_CONFIG_GLOBAL: gitNullConfigPath(),
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 && repositoryUrl) {
const origin = new URL(repositoryUrl).origin;
gitEnv.GIT_CONFIG_COUNT = "1";
gitEnv.GIT_CONFIG_KEY_0 = `http.${origin}/.extraHeader`;
gitEnv.GIT_CONFIG_VALUE_0 = `Authorization: Basic ${Buffer.from(`x-access-token:${token}`).toString("base64")}`;
}
return gitEnv;
}
function classifyProjectGitFailure(result, operation, tokenConfigured) {
const detail = `${result.stderr}\n${result.stdout}`.toLowerCase();
if (result.termination === "timeout" || result.termination === "no-output-timeout" || /could not resolve host|connection timed out|failed to connect/u.test(detail)) return new ProjectCloneError("network", `Git ${operation} 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", 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 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 ${operation === "clone" ? "clone" : "refresh"} 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, (error) => {
if (hasErrnoCode(error, "ENOENT")) return false;
throw new ProjectCloneError("clone_failed", "The managed checkout target could not be inspected. Check Gateway filesystem access and retry.");
})) 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 commandEnv = cloneCommandEnv(input.url, options.token, env);
const result = await withGitNetworkRetry("clone", {
timeoutMs: options.timeoutMs ?? PROJECT_CLONE_TIMEOUT_MS,
signal: options.signal,
beforeRun: options.assertCurrent,
startRun: options.startRun
}, async (timeoutMs) => {
const attempt = await withGitProcessOperation("project.clone", () => runCommandWithTimeout([
"git",
"clone",
"--no-recurse-submodules",
"--",
input.url,
input.target
], {
env: commandEnv,
timeoutMs,
signal: options.signal,
killProcessTree: true,
maxOutputBytes: 262144
}));
if (attempt.code !== 0 || attempt.termination !== "exit") await fs.rm(input.target, {
recursive: true,
force: true
}).catch(() => {});
return attempt;
});
if (result.code === 0 && result.termination === "exit") {
if (input.requiredCommit) try {
await ensureProjectCheckoutCommit({
...input,
commit: input.requiredCommit
}, options);
} catch (error) {
await fs.rm(input.target, {
recursive: true,
force: true
});
throw error;
}
return;
}
throw classifyProjectGitFailure(result, "clone", Boolean(options.token));
}
/** Refreshes refs in an existing Gateway-managed project checkout. */
async function refreshProjectCheckout(input, options = {}) {
const [objectPath, objectFormatResult, initialRefs] = await Promise.all([
runProjectCheckoutGit(input, options, [
"rev-parse",
"--path-format=absolute",
"--git-path",
"objects"
]),
runProjectCheckoutGit(input, options, ["rev-parse", "--show-object-format"]),
readProjectRemoteRefs(input, options)
]);
let currentRefs = initialRefs;
if (objectPath.code !== 0 || objectPath.termination !== "exit") throw new ProjectCloneError("clone_failed", "The managed repository object store could not be verified. Remove the repository and retry.");
const objectFormat = objectFormatResult.stdout.trim();
if (objectFormatResult.code !== 0 || objectFormatResult.termination !== "exit" || objectFormat !== "sha1" && objectFormat !== "sha256") throw new ProjectCloneError("clone_failed", "The managed repository object format could not be verified. Remove the repository and retry.");
const objects = await fs.realpath(objectPath.stdout.trim());
const staging = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-project-fetch-"));
try {
const initialized = await runProjectCheckoutGit({ target: staging }, options, [
"init",
"--bare",
`--object-format=${objectFormat}`
]);
if (initialized.code !== 0 || initialized.termination !== "exit") throw new ProjectCloneError("clone_failed", "Git could not prepare a safe repository refresh. Retry the session.");
const stagingOptions = {
...options,
objectDirectory: objects
};
let protectedRefs;
if (currentRefs.size > 0) {
const seed = () => runProjectCheckoutGit({ target: staging }, stagingOptions, ["update-ref", "--stdin"], { input: Array.from(currentRefs, ([ref, commit]) => `update ${ref} ${commit}\n`).join("") });
const seeded = await withGitRepositoryRepair({
cwd: input.target,
result: await seed(),
remote: input.url,
canonicalTracking: true,
onRepaired: (refs) => {
protectedRefs = refs;
},
signal: options.signal,
assertCurrent: options.assertCurrent,
run: (args, commandOptions) => args.includes("fetch") || args.includes("ls-remote") ? runProjectCheckoutGit({
target: staging,
url: input.url
}, stagingOptions, args, commandOptions) : runProjectCheckoutGit(input, options, args, commandOptions),
retry: async () => {
currentRefs = await readProjectRemoteRefs(input, options);
return seed();
}
});
if (seeded.code !== 0 || seeded.termination !== "exit") throw new ProjectCloneError("clone_failed", "Git could not prepare the managed repository refs for refresh. Retry the session.");
}
const result = await runProjectCheckoutGit({
target: staging,
url: input.url
}, {
...stagingOptions,
timeoutMs: options.timeoutMs ?? PROJECT_FETCH_TIMEOUT_MS
}, [
"fetch",
"--no-auto-maintenance",
"--no-recurse-submodules",
"--prune",
"--",
input.url,
"+refs/heads/*:refs/remotes/origin/*"
]);
if (result.code !== 0 || result.termination !== "exit") throw classifyProjectGitFailure(result, "fetch", Boolean(options.token));
const fetchedRefs = await readProjectRemoteRefs({ target: staging }, stagingOptions);
const updates = [...Array.from(fetchedRefs, ([ref, commit]) => `update ${ref} ${commit}`), ...Array.from(currentRefs.keys()).filter((ref) => !fetchedRefs.has(ref) && !protectedRefs?.has(ref)).map((ref) => `delete ${ref}`)];
if (updates.length > 0) {
const updated = await runProjectCheckoutGit(input, options, [
"update-ref",
"--no-deref",
"--stdin"
], { input: `${updates.join("\n")}\n` });
if (updated.code !== 0 || updated.termination !== "exit") throw new ProjectCloneError("clone_failed", "The managed repository changed while its branches were refreshed. Retry the session.");
}
} finally {
await fs.rm(staging, {
recursive: true,
force: true
});
}
}
async function readProjectRemoteRefs(input, options) {
const result = await runProjectCheckoutGit(input, options, [
"for-each-ref",
"--format=%(refname) %(objectname) %(symref)",
"refs/remotes/origin"
]);
const stdout = requireGitCommandOutput("git for-each-ref", result, (_command, failed) => failed.outputLimitExceeded ? new ProjectCloneError("clone_failed", "Git returned too many managed repository refs to refresh safely. Remove obsolete remote branches, then retry.") : new ProjectCloneError("clone_failed", "Git could not read the managed repository refs."));
const refs = /* @__PURE__ */ new Map();
for (const line of stdout.trim().split("\n").filter(Boolean)) {
const match = /^(refs\/remotes\/origin\/\S+) ([a-f0-9]{40}|[a-f0-9]{64})(?: (\S+))?$/u.exec(line.trimEnd());
if (!match) throw new ProjectCloneError("clone_failed", "Git returned an invalid managed repository ref.");
const [, ref, commit, symbolicTarget] = match;
if (!ref || !commit) throw new ProjectCloneError("clone_failed", "Git returned an incomplete managed repository ref.");
if (symbolicTarget) continue;
refs.set(ref, commit);
}
return refs;
}
function runProjectCheckoutGit(input, options, args, commandOptions = {}) {
return executeGitCommand(input.target, [
"-c",
`core.hooksPath=${os.devNull}`,
"-c",
"core.fsmonitor=false",
...args
], {
operation: "project.clone",
timeoutMs: options.timeoutMs ?? PROJECT_CLONE_TIMEOUT_MS,
signal: options.signal,
killProcessTree: true,
maxOutputBytes: 262144,
beforeRun: options.assertCurrent,
startRun: options.startRun,
...commandOptions,
env: {
...cloneCommandEnv(input.url, options.token, options.env ?? process.env),
...commandOptions.env,
...options.objectDirectory ? { GIT_OBJECT_DIRECTORY: options.objectDirectory } : {}
}
});
}
/** Fetch the pinned source when a reused project clone predates the remote session. */
async function ensureProjectCheckoutCommit(input, options = {}) {
if (!/^[a-f0-9]{40}(?:[a-f0-9]{24})?$/u.test(input.commit)) throw new ProjectCloneError("clone_failed", "The repository commit is invalid.");
const command = (args) => runProjectCheckoutGit(input, options, args);
const present = await command([
"cat-file",
"-e",
`${input.commit}^{commit}`
]);
if (present.code === 0 && present.termination === "exit") return;
const fetch = () => command([
"fetch",
"--no-auto-maintenance",
"--no-tags",
"--no-recurse-submodules",
"--",
input.url,
input.commit
]);
const fetched = await withGitRepositoryRepair({
cwd: input.target,
result: await fetch(),
remote: input.url,
signal: options.signal,
assertCurrent: options.assertCurrent,
run: (args, commandOptions) => runProjectCheckoutGit(input, options, args, commandOptions),
retry: fetch
});
if (fetched.code !== 0 || fetched.termination !== "exit") throw classifyProjectGitFailure(fetched, "fetch", Boolean(options.token));
}
/** Observe only the named source branch using the same shared fetch identity as cloning. */
async function readProjectCheckoutRemoteHead(input, options = {}) {
const ref = `refs/heads/${input.branch}`;
const result = await runProjectCheckoutGit(input, options, [
"ls-remote",
"--refs",
"--",
input.url,
ref
]);
if (result.code !== 0 || result.termination !== "exit") throw new ProjectCloneError("network", "The repository branch could not be verified; retry the Gateway move.");
const raw = result.stdout.trim();
if (!raw) return;
const [sha, observedRef, ...extra] = raw.split(/\s+/u);
if (!sha || !/^[a-f0-9]{40}(?:[a-f0-9]{24})?$/u.test(sha) || observedRef !== ref || extra.length) throw new ProjectCloneError("clone_failed", "The repository branch observation is invalid.");
return sha;
}
//#endregion
//#region src/projects/project-clone.ts
const PROJECT_CLONE_LEASE_MS = 3e4;
const PROJECT_CLONE_WAIT_MS = 3e4;
async function existingCanonicalProject(cfg, canonicalUrl, options) {
return (await listProjectRegistry(cfg, options)).find((project) => {
return (project.originUrl ? parseProjectGitUrl(project.originUrl, resolveConfiguredGitHubHost(cfg)) : null)?.url === canonicalUrl;
});
}
/** Materializes and registers a project from an accepted GitHub remote. */
async function materializeProjectClone(input, options = {}) {
const { cfg, gitUrl, name, requiredCommit } = input;
const { signal, timeoutMs, token, assertCurrent, startRun } = options;
const parsed = parseProjectGitUrl(gitUrl, resolveConfiguredGitHubHost(cfg));
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 = cloneEnvWithPlatformSemantics(options.env ?? process.env);
const context = captureOpenClawStateWorkerContext({
path: options.path,
env
});
const databaseOptions = {
path: context.admission.databasePath,
env
};
const fingerprint = sha256HexPrefixCore(parsed.url, 16);
return await withOpenClawStateLease({
scope: "projects.clone",
key: fingerprint,
database: {
scope: "shared",
options: databaseOptions
},
leaseMs: PROJECT_CLONE_LEASE_MS,
waitMs: PROJECT_CLONE_WAIT_MS,
...signal ? { signal } : {},
leaseLabel: "project clone lease",
operationLabel: "projects.clone.lease"
}, async (lease) => {
const assertCloneCurrent = () => {
lease.assertOwned();
assertCurrent?.();
};
while (true) {
const candidate = await existingCanonicalProject(cfg, parsed.url, databaseOptions);
assertCloneCurrent();
if (!candidate) break;
const existing = await withProjectCheckoutLifecycle(candidate.repoRoot, {
...databaseOptions,
signal: lease.signal
}, async (checkoutLease) => {
const current = await existingCanonicalProject(cfg, parsed.url, databaseOptions);
assertCloneCurrent();
checkoutLease.assertOwned();
if (current?.repoRoot !== candidate.repoRoot) return;
if (requiredCommit) {
await ensureProjectCheckoutCommit({
url: parsed.url,
target: current.repoRoot,
commit: requiredCommit
}, {
env,
signal: checkoutLease.signal,
timeoutMs,
token,
startRun,
assertCurrent: () => {
assertCloneCurrent();
checkoutLease.assertOwned();
}
});
checkoutLease.assertOwned();
}
return current;
});
if (existing) return existing;
}
const displayName = name?.trim() || parsed.name;
const directoryName = slugifyWorktreeTitle(displayName) ?? "project";
const target = path.join(resolveStateDir(env), "projects", fingerprint, directoryName);
await cloneProjectCheckout({
url: parsed.url,
target,
requiredCommit
}, {
env,
signal: lease.signal,
timeoutMs,
token,
assertCurrent: assertCloneCurrent,
startRun
});
lease.assertOwned();
const repoRoot = await resolveProjectDirectory(target);
lease.assertOwned();
return await withProjectCheckoutLifecycle(repoRoot, {
...databaseOptions,
signal: lease.signal
}, async (checkoutLease) => {
let registered = false;
try {
assertCloneCurrent();
const prepared = await prepareProjectRegistration({
path: repoRoot,
name: displayName,
originUrl: parsed.url,
source: "cloned"
});
return await registerPreparedProjectRegistry(prepared, checkoutLease, context, () => {
registered = true;
}, assertCloneCurrent);
} catch (error) {
if (registered || collectNestedErrorCandidates(error).some((candidate) => extractErrorCode(candidate) === "outcome-unknown")) throw error;
try {
lease.assertOwned();
checkoutLease.assertOwned();
await fs.rm(target, {
recursive: true,
force: true
});
} catch {}
throw error;
}
});
});
}
/** Refreshes an existing project clone while holding its checkout lifecycle lease. */
async function refreshProjectClone(project, options = {}) {
if (project.source !== "cloned") return;
const selectedProject = {
id: project.id,
repoRoot: project.repoRoot,
source: project.source,
originUrl: project.originUrl
};
const { signal, timeoutMs, token, assertCurrent, startRun } = options;
const env = cloneEnvWithPlatformSemantics(options.env ?? process.env);
const context = captureOpenClawStateWorkerContext({
path: options.path,
env
});
await withProjectCheckoutLifecycle(selectedProject.repoRoot, {
path: context.admission.databasePath,
env,
signal
}, async (lease) => {
const assertRefreshCurrent = () => {
lease.assertOwned();
assertCurrent?.();
};
const { runWithOpenClawStateLeaseWorker } = await import("./openclaw-state-lease-worker-operation-IoOMMWFv.mjs");
const current = await runWithOpenClawStateLeaseWorker(lease, context, (scope, identity) => scope.execute({
type: "projects.resolveRefreshOwner",
input: {
project: selectedProject,
lease: identity
}
}));
assertRefreshCurrent();
if (!current) throw new ProjectCloneError("clone_failed", "This project is no longer a Gateway-managed clone. Reselect the repository and retry.");
const originUrl = current.originUrl;
if (!originUrl) throw new ProjectCloneError("invalid_url", "Saved project repository is invalid; select the repository and retry.");
await refreshProjectCheckout({
target: current.repoRoot,
url: originUrl
}, {
env,
signal: lease.signal,
timeoutMs,
token,
assertCurrent: assertRefreshCurrent,
startRun
});
assertRefreshCurrent();
});
}
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 = {}) {
const selectedProject = { ...project };
const env = cloneEnvWithPlatformSemantics(options.env ?? process.env);
const context = captureOpenClawStateWorkerContext({
path: options.path,
env
});
const databaseOptions = {
path: context.admission.databasePath,
env
};
return await withProjectCheckoutLifecycle(selectedProject.repoRoot, databaseOptions, async (lease) => {
const checkout = await resolveClonedProjectCheckout(selectedProject, databaseOptions);
lease.assertOwned();
await assertUnreferenced();
const { runWithOpenClawStateLeaseWorker } = await import("./openclaw-state-lease-worker-operation-IoOMMWFv.mjs");
const result = await runWithOpenClawStateLeaseWorker(lease, context, (scope, identity) => scope.execute({
type: "projects.removeCheckoutReference",
input: {
project: selectedProject,
lease: identity
}
}), options.assertCurrent ? { assertCurrent: options.assertCurrent } : void 0);
if (result === "missing") return false;
if (result === "changed") throw new ProjectCloneError("clone_failed", "The cloned project changed before deletion.");
if (result === "remaining") return true;
await assertUnreferenced();
context.admission.assertCurrent();
lease.assertOwned();
options.assertCurrent?.();
await fs.rm(checkout, { recursive: true });
context.admission.assertCurrent();
lease.assertOwned();
await fs.rmdir(path.dirname(checkout)).catch(() => {});
return true;
});
}
//#endregion
export { readProjectCheckoutRemoteHead as a, ProjectCloneError as i, refreshProjectClone as n, removeClonedProjectCheckout as r, materializeProjectClone as t };