openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
492 lines (491 loc) • 21.9 kB
JavaScript
import { c as isRecord } from "./record-coerce-DItp3I4t.js";
import { l as normalizeOptionalString, m as readNonBlankString } from "./string-coerce-CIXf7egm.js";
import { r as isPathInside } from "./path-guards-Cp-mGr3-.js";
import { t as pruneMapToMaxSize } from "./map-size-CNcWiFKu.js";
import { O as parseAgentSessionKey } from "./session-key-BnWWjqNc.js";
import { t as formatErrorMessage } from "./errors-Db3Ymjlb.js";
import { c as WRITE_SCOPE } from "./operator-scopes-Dw7Gu2cA.js";
import { n as GatewayErrorDetailCodes, t as ErrorCodes } from "./gateway-error-details-w0nAGBBp.js";
import { $n as validateProjectsListParams, Qn as validateProjectsAddParams, er as validateProjectsRegisterParams, nr as validateProjectsSearchRemoteParams, tr as validateProjectsRemoveParams } from "./src-BiL5aQto.js";
import { r as authorizeOperatorScopesForRequiredScope } from "./method-scopes-K6J_UQGL.js";
import { d as errorShape } from "./error-codes-Bo8q2D1o.js";
import { d as isTrustedSecretSurfaceUnavailableError } from "./runtime-degraded-state-D5EZZ925.js";
import { a as sessionCreatorProfileId } from "./session-entry-provenance-jzrCUpdQ.js";
import { c as resolveUserProfileId, h as listProfiles } from "./user-profiles-4AB7AmiH.js";
import { n as loadCombinedSessionStoreForGatewayCore } from "./combined-store-gateway-DGm4nZAJ.js";
import "./session-utils-Cai0_C6U.js";
import { y as listRegistryWorktrees } from "./registry-Dvi_fTvk.js";
import { m as resolveGitHubApiCredentialScope, o as fetchGitHubApi, p as readGitHubJsonResponse, r as GITHUB_API_ORIGIN, s as fetchGitHubJson, t as CONTROL_UI_GITHUB_CREDENTIAL_UNAVAILABLE_MESSAGE, u as githubApiToken } from "./control-ui-github-api-CCpVJnCA.js";
import { r as createSessionListEntryFilter } from "./session-sharing-B7MI8hNo.js";
import { t as assertValidParams } from "./validation-pzrlzFvo.js";
import { i as registerProjectRegistry, l as resolveProjectRegistry, n as listProjectRegistry, o as removeProjectRegistry, t as ProjectCheckoutError } from "./project-registry-Cm-dqnhv.js";
import { i as ProjectCloneError, n as removeClonedProjectCheckout, r as parseProjectGitUrl, t as materializeProjectClone } from "./project-clone-CnFP5_gM.js";
import { l as managedWorktrees } from "./service-B1SvAvxa.js";
import path from "node:path";
//#region src/gateway/project-github-search.ts
const SEARCH_CACHE_MS = 6e4;
const SEARCH_CACHE_LIMIT = 100;
const SEARCH_RESULT_LIMIT = 10;
const AFFILIATED_RESULT_LIMIT = 10;
const EXACT_REPO_QUERY = /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\/[A-Za-z0-9._-]+$/;
const searchCache = /* @__PURE__ */ new Map();
function boundedString(value, maxLength) {
const trimmed = value?.trim();
return trimmed ? trimmed.slice(0, maxLength) : void 0;
}
function parseRepository(value) {
if (!isRecord(value)) return null;
const fullName = readNonBlankString(value.full_name);
const name = readNonBlankString(value.name);
if (!fullName || !name) return null;
const clone = parseProjectGitUrl(readNonBlankString(value.clone_url) ?? "");
const webUrl = boundedString(readNonBlankString(value.html_url), 2048);
if (!clone || !webUrl) return null;
const description = boundedString(readNonBlankString(value.description), 500);
return {
name: name.slice(0, 100),
fullName: fullName.slice(0, 200),
cloneUrl: clone.url,
webUrl,
private: value.private === true,
...description ? { description } : {}
};
}
function repositoryArray(value) {
return (Array.isArray(value) ? value : isRecord(value) && Array.isArray(value.items) ? value.items : []).flatMap((item) => {
const parsed = parseRepository(item);
return parsed ? [parsed] : [];
});
}
function matchesAffiliatedQuery(project, query) {
const needle = query.toLowerCase();
return [
project.name,
project.fullName,
project.description ?? ""
].join("\n").toLowerCase().includes(needle);
}
async function loadExactRepository(query, fetchImpl, token) {
const url = new URL(`/repos/${query}`, GITHUB_API_ORIGIN);
try {
return parseRepository(await fetchGitHubJson(url.href, fetchImpl, token));
} catch {
return null;
}
}
async function loadAffiliatedRepositories(fetchImpl, token) {
const url = new URL("/user/repos", GITHUB_API_ORIGIN);
url.searchParams.set("affiliation", "owner,collaborator,organization_member");
url.searchParams.set("sort", "updated");
url.searchParams.set("direction", "desc");
url.searchParams.set("per_page", String(AFFILIATED_RESULT_LIMIT));
try {
const response = await fetchGitHubApi(url.href, fetchImpl, token);
return repositoryArray(await readGitHubJsonResponse(response));
} catch {
return [];
}
}
async function loadRepositorySearch(query, fetchImpl, token) {
const url = new URL("/search/repositories", GITHUB_API_ORIGIN);
url.searchParams.set("q", `${query} in:name,description`);
url.searchParams.set("per_page", String(SEARCH_RESULT_LIMIT));
return repositoryArray(await fetchGitHubJson(url.href, fetchImpl, token));
}
async function searchProjectsUncached(params) {
const [exact, affiliated, global] = await Promise.all([
EXACT_REPO_QUERY.test(params.query) ? loadExactRepository(params.query, params.fetchImpl, params.token) : null,
params.token ? loadAffiliatedRepositories(params.fetchImpl, params.token) : [],
loadRepositorySearch(params.query, params.fetchImpl, params.token)
]);
const ranked = [
...exact ? [exact] : [],
...affiliated.filter((project) => matchesAffiliatedQuery(project, params.query)),
...global
];
const deduped = /* @__PURE__ */ new Map();
for (const project of ranked) {
const key = project.fullName.toLowerCase();
if (!deduped.has(key)) deduped.set(key, project);
}
return {
credential: params.token ? "configured" : "missing",
projects: [...deduped.values()].slice(0, SEARCH_RESULT_LIMIT)
};
}
/** Searches affiliated and public GitHub repositories for the project picker. */
function searchRemoteProjects(query, options = {}) {
const normalizedQuery = query.trim().toLowerCase();
const { token, cacheScope } = resolveGitHubApiCredentialScope(options.env);
const cacheKey = `${normalizedQuery}\0${cacheScope}`;
const now = options.now ?? Date.now();
const cached = searchCache.get(cacheKey);
if (cached && cached.expiresAt > now) {
searchCache.delete(cacheKey);
searchCache.set(cacheKey, cached);
return cached.promise;
}
const promise = searchProjectsUncached({
query: query.trim(),
fetchImpl: options.fetchImpl ?? fetch,
token
}).catch((error) => {
if (searchCache.get(cacheKey)?.promise === promise) searchCache.delete(cacheKey);
throw error;
});
searchCache.set(cacheKey, {
expiresAt: now + SEARCH_CACHE_MS,
promise
});
pruneMapToMaxSize(searchCache, SEARCH_CACHE_LIMIT);
return promise;
}
//#endregion
//#region src/gateway/server-methods/projects.ts
const PROJECTS_LIST_MAX_RAW_CANDIDATES = Math.max(50, 50, 32);
function folderDisplayName(folder) {
return folder.replace(/[\\/]+$/u, "").split(/[\\/]/u).at(-1) || folder;
}
function checkoutName(checkoutPath) {
const trimmed = checkoutPath.replace(/[\\/]+$/u, "");
return trimmed.split(/[\\/]/u).at(-1) || trimmed;
}
function compareRawProjectCandidates(left, right) {
return right.lastUsedAt - left.lastUsedAt || left.checkoutPath.localeCompare(right.checkoutPath) || left.kind.localeCompare(right.kind);
}
function retainNewestRawProjectCandidate(candidates, candidate) {
const insertionIndex = candidates.findIndex((existing) => compareRawProjectCandidates(candidate, existing) < 0);
if (insertionIndex < 0) {
if (candidates.length < PROJECTS_LIST_MAX_RAW_CANDIDATES) candidates.push(candidate);
return;
}
candidates.splice(insertionIndex, 0, candidate);
if (candidates.length > PROJECTS_LIST_MAX_RAW_CANDIDATES) candidates.pop();
}
function sanitizePublicOriginUrl(originUrl) {
const trimmed = originUrl.trim();
const suffixIndex = trimmed.search(/[?#]/u);
const withoutSuffix = suffixIndex < 0 ? trimmed : trimmed.slice(0, suffixIndex);
const scp = /^[^@\s/:]+@(\[[^\]]+\]|[^:\s]+):(.+)$/u.exec(withoutSuffix);
if (scp) return `${scp[1]}:${scp[2]}`;
let parsed;
try {
parsed = new URL(withoutSuffix);
} catch {
return;
}
if (!parsed.username && !parsed.password) return withoutSuffix;
parsed.username = "";
parsed.password = "";
return parsed.toString();
}
function sanitizeProjectRecord(project) {
const { originUrl, ...record } = project;
const sanitizedOriginUrl = originUrl ? sanitizePublicOriginUrl(originUrl) : void 0;
return {
...record,
...sanitizedOriginUrl ? { originUrl: sanitizedOriginUrl } : {}
};
}
function resolvePathProject(projects, folder, sessionKey) {
const sessionAgentId = parseAgentSessionKey(sessionKey)?.agentId;
return projects.filter((project) => project.repoRoot === folder).toSorted((left, right) => {
const rank = (project) => project.source === "workspace" && project.agentId === sessionAgentId ? 0 : project.source !== "workspace" ? 1 : 2;
return rank(left) - rank(right) || left.id.localeCompare(right.id);
})[0];
}
function listProjectRecents(cfg, profileIds, projects) {
const store = loadCombinedSessionStoreForGatewayCore(cfg, { projection: "list" }).store;
const candidates = Object.entries(store).filter(([, entry]) => Boolean(sessionCreatorProfileId(entry.createdActor)) && Boolean(entry.createdActor?.id && profileIds.has(entry.createdActor.id))).toSorted(([leftKey, left], [rightKey, right]) => (right.updatedAt ?? 0) - (left.updatedAt ?? 0) || leftKey.localeCompare(rightKey));
const projectsById = new Map(projects.map((project) => [project.id, project]));
const seen = /* @__PURE__ */ new Set();
const recents = [];
for (const [sessionKey, entry] of candidates) {
const projectId = normalizeOptionalString(entry.projectId);
const explicitProject = projectId ? projectsById.get(projectId) : void 0;
const worktreeRoot = normalizeOptionalString(entry.worktree?.repoRoot);
const spawnedCwd = normalizeOptionalString(entry.spawnedCwd);
const execCwd = normalizeOptionalString(entry.execCwd);
const folder = worktreeRoot ?? spawnedCwd ?? execCwd;
const project = explicitProject ?? (folder ? resolvePathProject(projects, folder, sessionKey) : void 0);
const key = project ? `project:${project.id}` : folder ? `folder:${normalizeOptionalString(entry.execNode) ?? ""}\0${folder}` : void 0;
if (!key || seen.has(key)) continue;
seen.add(key);
recents.push(project ? {
kind: "project",
projectId: project.id,
displayName: project.displayName
} : {
kind: "folder",
folder,
displayName: folderDisplayName(folder),
...normalizeOptionalString(entry.execNode) ? { execNode: normalizeOptionalString(entry.execNode) } : {}
});
if (recents.length === 8) break;
}
return recents;
}
function projectCandidatesToSummaries(candidates) {
const groups = /* @__PURE__ */ new Map();
for (const candidate of candidates) {
const group = groups.get(candidate.fingerprint) ?? {
checkouts: /* @__PURE__ */ new Map(),
lastUsedAt: candidate.lastUsedAt,
name: checkoutName(candidate.checkoutPath),
nameUsedAt: candidate.lastUsedAt
};
const checkout = group.checkouts.get(candidate.checkoutPath);
if (!checkout || candidate.lastUsedAt > checkout.lastUsedAt) group.checkouts.set(candidate.checkoutPath, {
path: candidate.checkoutPath,
lastUsedAt: candidate.lastUsedAt
});
group.lastUsedAt = Math.max(group.lastUsedAt, candidate.lastUsedAt);
if (candidate.lastUsedAt > group.nameUsedAt) {
group.name = checkoutName(candidate.checkoutPath);
group.nameUsedAt = candidate.lastUsedAt;
}
if (!group.originUrl && candidate.originUrl) group.originUrl = candidate.originUrl;
groups.set(candidate.fingerprint, group);
}
return [...groups.values()].toSorted((left, right) => right.lastUsedAt - left.lastUsedAt || left.name.localeCompare(right.name)).slice(0, 50).map((group) => {
const summary = {
name: group.name,
checkouts: [...group.checkouts.values()].toSorted((left, right) => right.lastUsedAt - left.lastUsedAt || left.path.localeCompare(right.path)).slice(0, 50).map((checkout) => ({
runnerId: "gateway",
path: checkout.path
})),
lastUsedAt: group.lastUsedAt
};
if (group.originUrl) {
const originUrl = sanitizePublicOriginUrl(group.originUrl);
if (originUrl) summary.originUrl = originUrl;
}
return summary;
});
}
async function listObservedProjects(service, context, client) {
const cfg = context.getRuntimeConfig();
const { store } = loadCombinedSessionStoreForGatewayCore(cfg, { projection: "list" });
const rawCandidates = [];
const visibilityFilter = createSessionListEntryFilter({
client,
cfg
});
const canSeeAll = !visibilityFilter;
for (const [sessionKey, entry] of Object.entries(store)) {
if (visibilityFilter && !visibilityFilter(sessionKey, entry)) continue;
const checkoutPath = entry.execCwd?.trim();
if (checkoutPath && !entry.execNode?.trim()) retainNewestRawProjectCandidate(rawCandidates, {
kind: "session",
checkoutPath,
lastUsedAt: entry.updatedAt
});
}
for (const worktree of service.listRegistryRecords()) {
if (worktree.removedAt !== void 0) continue;
if (!canSeeAll) {
const ownerId = worktree.ownerKind === "session" ? worktree.ownerId?.trim() : void 0;
const ownerEntry = ownerId ? store[ownerId] : void 0;
if (!ownerId || !ownerEntry || !visibilityFilter?.(ownerId, ownerEntry)) continue;
}
retainNewestRawProjectCandidate(rawCandidates, {
kind: "worktree",
checkoutPath: worktree.path,
fingerprint: worktree.repoFingerprint,
lastUsedAt: worktree.lastActiveAt,
repoRoot: worktree.repoRoot
});
}
const candidates = [];
const identities = /* @__PURE__ */ new Map();
let identityProbeCount = 0;
const resolveIdentity = (checkoutPath) => {
const existing = identities.get(checkoutPath);
if (existing) return existing;
if (identityProbeCount >= 32) return;
identityProbeCount += 1;
const identity = Promise.resolve().then(() => service.resolveRepositoryIdentity(checkoutPath));
identities.set(checkoutPath, identity);
return identity;
};
for (const raw of rawCandidates) {
if (raw.kind === "worktree") {
let originUrl;
const pendingIdentity = resolveIdentity(raw.repoRoot);
try {
originUrl = (pendingIdentity ? await pendingIdentity : void 0)?.originUrl || void 0;
} catch {}
candidates.push({
checkoutPath: raw.checkoutPath,
fingerprint: raw.fingerprint,
lastUsedAt: raw.lastUsedAt,
...originUrl ? { originUrl } : {}
});
continue;
}
const pendingIdentity = resolveIdentity(raw.checkoutPath);
if (!pendingIdentity) continue;
try {
const identity = await pendingIdentity;
candidates.push({
checkoutPath: identity.checkoutRoot,
fingerprint: identity.fingerprint,
lastUsedAt: raw.lastUsedAt,
...identity.originUrl ? { originUrl: identity.originUrl } : {}
});
} catch {}
}
return projectCandidatesToSummaries(candidates);
}
function findProjectCheckoutReference(cfg, repoRoot) {
const normalizedRoot = path.resolve(repoRoot);
const workspaceReference = listProjectRegistry(cfg).find((candidate) => candidate.source === "workspace" && path.resolve(candidate.repoRoot) === normalizedRoot);
const worktreeReference = listRegistryWorktrees(process.env).find((worktree) => !worktree.removedAt && path.resolve(worktree.repoRoot) === normalizedRoot);
const sessionReference = Object.entries(loadCombinedSessionStoreForGatewayCore(cfg, { projection: "list" }).store).find(([, entry]) => {
if (entry.archivedAt) return false;
const sessionRoot = entry.worktree?.repoRoot;
if (sessionRoot && path.resolve(sessionRoot) === normalizedRoot) return true;
const cwd = entry.spawnedCwd;
return Boolean(cwd && (path.resolve(cwd) === normalizedRoot || isPathInside(normalizedRoot, path.resolve(cwd))));
});
return workspaceReference ? `agent workspace ${workspaceReference.displayName}` : worktreeReference ? `managed worktree ${worktreeReference.name}` : sessionReference ? `session ${sessionReference[0]}` : void 0;
}
function createProjectsHandlers(service) {
return {
"projects.list": async ({ params, respond, context, client }) => {
if (!assertValidParams(params, validateProjectsListParams, "projects.list", respond)) return;
const registryProjects = listProjectRegistry(context.getRuntimeConfig());
const projects = registryProjects.map(sanitizeProjectRecord);
const profileId = client?.authenticatedUserProfile?.profileId;
const canonicalProfileId = profileId ? resolveUserProfileId(profileId) ?? profileId : void 0;
const recentProfileIds = canonicalProfileId ? /* @__PURE__ */ new Set([canonicalProfileId, ...listProfiles().filter((profile) => profile.mergedInto === canonicalProfileId).map((profile) => profile.id)]) : void 0;
const recents = recentProfileIds ? listProjectRecents(context.getRuntimeConfig(), recentProfileIds, registryProjects) : void 0;
const scopes = Array.isArray(client?.connect.scopes) ? client.connect.scopes : [];
const canWrite = authorizeOperatorScopesForRequiredScope(WRITE_SCOPE, scopes).allowed;
if (params.includeObserved && canWrite) {
try {
const observedProjects = await listObservedProjects(service, context, client);
respond(true, {
projects,
...recents ? { recents } : {},
observedProjects
}, void 0);
} catch (error) {
respond(false, void 0, errorShape(ErrorCodes.UNAVAILABLE, formatErrorMessage(error)));
}
return;
}
if (canWrite) {
respond(true, {
projects,
...recents ? { recents } : {}
}, void 0);
return;
}
respond(true, {
projects: projects.map((project) => project.agentId ? {
id: project.id,
displayName: project.displayName,
source: project.source,
agentId: project.agentId
} : {
id: project.id,
displayName: project.displayName,
source: project.source
}),
...recents ? { recents: recents.filter((recent) => recent.kind === "project") } : {}
}, void 0);
},
"projects.register": async ({ params, respond }) => {
if (!assertValidParams(params, validateProjectsRegisterParams, "projects.register", respond)) return;
try {
respond(true, sanitizeProjectRecord(await registerProjectRegistry({
path: params.path,
name: params.name
})), void 0);
} catch (error) {
respond(false, void 0, errorShape(error instanceof ProjectCheckoutError ? ErrorCodes.INVALID_REQUEST : ErrorCodes.UNAVAILABLE, formatErrorMessage(error)));
}
},
"projects.add": async ({ params, respond, context, signal }) => {
if (!assertValidParams(params, validateProjectsAddParams, "projects.add", respond)) return;
try {
respond(true, await materializeProjectClone({
cfg: context.getRuntimeConfig(),
gitUrl: params.gitUrl,
name: params.name
}, {
signal,
token: githubApiToken()
}), void 0);
} catch (error) {
if (isTrustedSecretSurfaceUnavailableError(error)) {
respond(false, void 0, errorShape(ErrorCodes.UNAVAILABLE, CONTROL_UI_GITHUB_CREDENTIAL_UNAVAILABLE_MESSAGE, {
details: {
code: GatewayErrorDetailCodes.PROJECT_CLONE_FAILED,
cause: "auth_required"
},
retryable: false
}));
return;
}
if (error instanceof ProjectCloneError) {
respond(false, void 0, errorShape(error.failure === "invalid_url" ? ErrorCodes.INVALID_REQUEST : ErrorCodes.UNAVAILABLE, error.message, {
details: {
code: GatewayErrorDetailCodes.PROJECT_CLONE_FAILED,
cause: error.failure
},
retryable: error.failure === "network" || error.failure === "clone_failed"
}));
return;
}
respond(false, void 0, errorShape(ErrorCodes.UNAVAILABLE, formatErrorMessage(error)));
}
},
"projects.searchRemote": async ({ params, respond }) => {
if (!assertValidParams(params, validateProjectsSearchRemoteParams, "projects.searchRemote", respond)) return;
try {
respond(true, await searchRemoteProjects(params.query), void 0);
} catch (error) {
const credentialUnavailable = isTrustedSecretSurfaceUnavailableError(error);
const message = credentialUnavailable ? CONTROL_UI_GITHUB_CREDENTIAL_UNAVAILABLE_MESSAGE : "GitHub project search is unavailable. Retry shortly.";
respond(false, void 0, errorShape(ErrorCodes.UNAVAILABLE, message, { retryable: !credentialUnavailable }));
}
},
"projects.remove": async ({ params, respond, context }) => {
if (!assertValidParams(params, validateProjectsRemoveParams, "projects.remove", respond)) return;
const respondUnknownProject = () => {
respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `unknown project id: ${params.id}`));
};
const project = resolveProjectRegistry(context.getRuntimeConfig(), params.id);
if (!project || project.source === "workspace") {
respondUnknownProject();
return;
}
let removed;
if (params.deleteCheckout) {
if (project.source !== "cloned") {
respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "Only projects cloned by the Gateway can delete their checkout."));
return;
}
try {
removed = await removeClonedProjectCheckout(project, () => {
const reference = findProjectCheckoutReference(context.getRuntimeConfig(), project.repoRoot);
if (reference) throw new ProjectCheckoutError(`Project checkout is still referenced by ${reference}. Remove that reference before deleting the checkout.`);
});
} catch (error) {
respond(false, void 0, errorShape(error instanceof ProjectCheckoutError ? ErrorCodes.INVALID_REQUEST : ErrorCodes.UNAVAILABLE, formatErrorMessage(error)));
return;
}
} else removed = removeProjectRegistry(params.id);
if (!removed) {
respondUnknownProject();
return;
}
respond(true, { removed: true }, void 0);
}
};
}
const projectsHandlers = createProjectsHandlers(managedWorktrees);
//#endregion
export { projectsHandlers };