mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
1597 lines
55 KiB
TypeScript
1597 lines
55 KiB
TypeScript
// Agents gateway methods expose agent listing, config mutation, workspace file
|
|
// reads/writes, identity merging, and safe deletion for operator clients.
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { normalizeOptionalString as resolveOptionalStringParam } from "@openclaw/normalization-core/string-coerce";
|
|
import {
|
|
GATEWAY_CLIENT_CAPS,
|
|
hasGatewayClientCap,
|
|
} from "../../../packages/gateway-protocol/src/client-info.js";
|
|
import {
|
|
ErrorCodes,
|
|
errorShape,
|
|
formatValidationErrors,
|
|
validateAgentsCreateParams,
|
|
validateAgentsDeleteParams,
|
|
validateAgentsFilesGetParams,
|
|
validateAgentsFilesListParams,
|
|
validateAgentsFilesSetParams,
|
|
validateAgentsListParams,
|
|
validateAgentsUpdateParams,
|
|
} from "../../../packages/gateway-protocol/src/index.js";
|
|
import { createAgent } from "../../agents/agent-create.js";
|
|
import { findOverlappingWorkspaceAgentIds } from "../../agents/agent-delete-safety.js";
|
|
import {
|
|
isPathOwnedByAnotherRegisteredAgent,
|
|
normalizeAgentDirRegistryPath,
|
|
registerResolvedAgentDir,
|
|
resolveRegisteredAgentIdForDir,
|
|
unregisterResolvedAgentDir,
|
|
} from "../../agents/agent-dir-registry.js";
|
|
import {
|
|
AgentDeletionAuthorityRollbackError,
|
|
AgentDeletionCommitUncertainError,
|
|
beginAgentDeletion,
|
|
claimCompletedAgentDeletion,
|
|
} from "../../agents/agent-lifecycle-registry.js";
|
|
import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
|
import {
|
|
listAgentIds,
|
|
resolveAgentDir,
|
|
resolveAgentWorkspaceDir,
|
|
} from "../../agents/agent-scope.js";
|
|
import {
|
|
createAgentIdentityConfig,
|
|
mergeIdentityMarkdownContent,
|
|
normalizeIdentityForFile,
|
|
sanitizeAgentIdentityLine,
|
|
} from "../../agents/identity-file.js";
|
|
import { resolveAgentIdentity } from "../../agents/identity.js";
|
|
import {
|
|
prepareLegacyWorkspaceStateReset,
|
|
removeLegacyWorkspaceStateForReset,
|
|
} from "../../agents/workspace-legacy-state.js";
|
|
import {
|
|
deleteWorkspaceState,
|
|
prepareWorkspaceStateDeletion,
|
|
} from "../../agents/workspace-state-store.js";
|
|
import {
|
|
DEFAULT_BOOTSTRAP_FILENAME,
|
|
DEFAULT_IDENTITY_FILENAME,
|
|
ensureAgentWorkspace,
|
|
isExpectedAbsentBootstrapFile,
|
|
isWorkspaceSetupCompleted,
|
|
WORKSPACE_BOOTSTRAP_FILENAMES,
|
|
} from "../../agents/workspace.js";
|
|
import { applyAgentConfig } from "../../commands/agents.config.js";
|
|
import {
|
|
readConfigFileSnapshotForWrite,
|
|
withConfigMutationExclusive,
|
|
} from "../../config/config.js";
|
|
import { purgeAgentSessionStoreEntries } from "../../config/sessions.js";
|
|
import { resolveSessionTranscriptsDirForAgent } from "../../config/sessions/paths.js";
|
|
import type { IdentityConfig } from "../../config/types.base.js";
|
|
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
|
import { isMissingPathError } from "../../infra/errors.js";
|
|
import { withAgentExecApprovalsRemoved } from "../../infra/exec-approvals.js";
|
|
import { root, FsSafeError, type ReadResult } from "../../infra/fs-safe.js";
|
|
import { isPathInside } from "../../infra/path-guards.js";
|
|
import { resolveSqliteDatabaseFilePaths } from "../../infra/sqlite-files.js";
|
|
import { movePathToTrash } from "../../plugin-sdk/browser-maintenance.js";
|
|
import { LEGACY_IMPLICIT_AGENT_ID, normalizeAgentId } from "../../routing/session-key.js";
|
|
import {
|
|
readAgentDeletionJournal,
|
|
type AgentDeletionJournalCleanupPath,
|
|
} from "../../state/agent-deletion-journal.js";
|
|
import { assertNoOpenClawAgentDatabaseLeases } from "../../state/openclaw-agent-db-lease.js";
|
|
import { unregisterOpenClawAgentDatabase } from "../../state/openclaw-agent-db-registry.js";
|
|
import {
|
|
closeOpenClawAgentDatabaseByPath,
|
|
listOpenClawRegisteredAgentDatabases,
|
|
resolveOpenClawAgentSqlitePath,
|
|
} from "../../state/openclaw-agent-db.js";
|
|
import { resolveUserPath } from "../../utils.js";
|
|
import { listAgentsForGateway } from "../session-utils.js";
|
|
import {
|
|
AgentConfigPreconditionError,
|
|
deleteAgentConfigEntry,
|
|
isConfiguredAgent,
|
|
updateAgentConfigEntry,
|
|
} from "./agents-config-mutations.js";
|
|
import { readPreparedServerMethodModelCatalog } from "./optional-model-catalog.js";
|
|
import type { GatewayRequestHandlers, RespondFn } from "./types.js";
|
|
|
|
// Derived from the canonical workspace list so retiring a bootstrap file cannot
|
|
// leave the Control UI advertising a file the runtime no longer reads.
|
|
// IDENTITY.md is excluded: it is a parsed record that `agents.update` rewrites via
|
|
// mergeIdentityMarkdownContent, so a second freeform editor would clobber fields
|
|
// (Creature/Vibe, unfilled placeholders) that the identity form round-trips.
|
|
// It stays writable through agents.files.set for clients that want raw access.
|
|
const CORE_FILE_NAMES = WORKSPACE_BOOTSTRAP_FILENAMES.filter(
|
|
(name) => name !== DEFAULT_IDENTITY_FILENAME,
|
|
);
|
|
const CORE_FILE_NAMES_POST_ONBOARDING = CORE_FILE_NAMES.filter(
|
|
(name) => name !== DEFAULT_BOOTSTRAP_FILENAME,
|
|
);
|
|
|
|
const agentsHandlerDeps = {
|
|
root,
|
|
isWorkspaceSetupCompleted,
|
|
};
|
|
|
|
export const testing = {
|
|
setDepsForTests(
|
|
overrides: Partial<{
|
|
root: typeof root;
|
|
isWorkspaceSetupCompleted: typeof isWorkspaceSetupCompleted;
|
|
}>,
|
|
) {
|
|
if (overrides.isWorkspaceSetupCompleted) {
|
|
agentsHandlerDeps.isWorkspaceSetupCompleted = overrides.isWorkspaceSetupCompleted;
|
|
}
|
|
if (overrides.root) {
|
|
agentsHandlerDeps.root = overrides.root;
|
|
}
|
|
},
|
|
resetDepsForTests() {
|
|
agentsHandlerDeps.root = root;
|
|
agentsHandlerDeps.isWorkspaceSetupCompleted = isWorkspaceSetupCompleted;
|
|
},
|
|
};
|
|
|
|
// Writes stay capped to canonical workspace files, and deliberately remain wider
|
|
// than the listed core files: IDENTITY.md is not offered as an editor tab but is
|
|
// still writable for clients that manage it directly.
|
|
const ALLOWED_FILE_NAMES = new Set<string>(WORKSPACE_BOOTSTRAP_FILENAMES);
|
|
|
|
function resolveAgentWorkspaceFileOrRespondError(
|
|
params: Record<string, unknown>,
|
|
respond: RespondFn,
|
|
cfg: OpenClawConfig,
|
|
): {
|
|
cfg: OpenClawConfig;
|
|
agentId: string;
|
|
workspaceDir: string;
|
|
name: string;
|
|
} | null {
|
|
const rawAgentId = params.agentId;
|
|
const agentId = resolveAgentIdOrError(
|
|
typeof rawAgentId === "string" || typeof rawAgentId === "number" ? String(rawAgentId) : "",
|
|
cfg,
|
|
);
|
|
if (!agentId) {
|
|
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown agent id"));
|
|
return null;
|
|
}
|
|
const rawName = params.name;
|
|
const name = (
|
|
typeof rawName === "string" || typeof rawName === "number" ? String(rawName) : ""
|
|
).trim();
|
|
if (!ALLOWED_FILE_NAMES.has(name)) {
|
|
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, `unsupported file "${name}"`));
|
|
return null;
|
|
}
|
|
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
|
|
return { cfg, agentId, workspaceDir, name };
|
|
}
|
|
|
|
type FileMeta = {
|
|
size: number;
|
|
updatedAtMs: number;
|
|
};
|
|
|
|
type WorkspaceRoot = Awaited<ReturnType<typeof root>>;
|
|
|
|
function isRegularWorkspaceFileStat(stat: {
|
|
isFile: boolean | (() => boolean);
|
|
isSymbolicLink: boolean | (() => boolean);
|
|
nlink: number;
|
|
}): boolean {
|
|
const isFile = typeof stat.isFile === "function" ? stat.isFile() : stat.isFile;
|
|
const isSymbolicLink =
|
|
typeof stat.isSymbolicLink === "function" ? stat.isSymbolicLink() : stat.isSymbolicLink;
|
|
// Reject links even after path-root containment so workspace reads cannot follow shared files.
|
|
return isFile && !isSymbolicLink && stat.nlink <= 1;
|
|
}
|
|
|
|
function toWorkspaceFileMeta(
|
|
stat: {
|
|
size: number;
|
|
mtimeMs: number;
|
|
} & Parameters<typeof isRegularWorkspaceFileStat>[0],
|
|
): FileMeta | null {
|
|
if (!isRegularWorkspaceFileStat(stat)) {
|
|
return null;
|
|
}
|
|
return {
|
|
size: stat.size,
|
|
updatedAtMs: Math.floor(stat.mtimeMs),
|
|
};
|
|
}
|
|
|
|
async function statWorkspaceFileSafely(
|
|
workspaceRoot: WorkspaceRoot | null,
|
|
workspaceDir: string,
|
|
name: string,
|
|
): Promise<FileMeta | null> {
|
|
try {
|
|
const stat = workspaceRoot
|
|
? await workspaceRoot.stat(name)
|
|
: await fs.lstat(path.join(workspaceDir, name));
|
|
return toWorkspaceFileMeta(stat);
|
|
} catch {
|
|
if (!workspaceRoot) {
|
|
return null;
|
|
}
|
|
try {
|
|
// fs-safe roots can reject fixtures that are still valid regular files for listing metadata.
|
|
const stat = await fs.lstat(path.join(workspaceDir, name));
|
|
return toWorkspaceFileMeta(stat);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function openWorkspaceRootSafely(workspaceDir: string): Promise<WorkspaceRoot | null> {
|
|
try {
|
|
return await agentsHandlerDeps.root(workspaceDir);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function listAgentFiles(workspaceDir: string, options?: { hideBootstrap?: boolean }) {
|
|
const files: Array<{
|
|
name: string;
|
|
path: string;
|
|
missing: boolean;
|
|
expectedAbsent?: boolean;
|
|
size?: number;
|
|
updatedAtMs?: number;
|
|
}> = [];
|
|
|
|
const workspaceRoot = await openWorkspaceRootSafely(workspaceDir);
|
|
if (!workspaceRoot) {
|
|
// Keep the UI shape stable when the workspace path is missing or unsafe.
|
|
const missingNames = options?.hideBootstrap ? CORE_FILE_NAMES_POST_ONBOARDING : CORE_FILE_NAMES;
|
|
return missingNames.map((name) => ({
|
|
name,
|
|
path: path.join(workspaceDir, name),
|
|
missing: true,
|
|
expectedAbsent: isExpectedAbsentBootstrapFile(name),
|
|
}));
|
|
}
|
|
|
|
const coreFileNames = options?.hideBootstrap ? CORE_FILE_NAMES_POST_ONBOARDING : CORE_FILE_NAMES;
|
|
for (const name of coreFileNames) {
|
|
const filePath = path.join(workspaceDir, name);
|
|
const meta = await statWorkspaceFileSafely(workspaceRoot, workspaceDir, name);
|
|
if (meta) {
|
|
files.push({
|
|
name,
|
|
path: filePath,
|
|
missing: false,
|
|
size: meta.size,
|
|
updatedAtMs: meta.updatedAtMs,
|
|
});
|
|
} else {
|
|
files.push({
|
|
name,
|
|
path: filePath,
|
|
missing: true,
|
|
expectedAbsent: isExpectedAbsentBootstrapFile(name),
|
|
});
|
|
}
|
|
}
|
|
|
|
return files;
|
|
}
|
|
|
|
function resolveAgentIdOrError(agentIdRaw: string, cfg: OpenClawConfig) {
|
|
const agentId = normalizeAgentId(agentIdRaw);
|
|
const allowed = new Set(listAgentIds(cfg));
|
|
if (!allowed.has(agentId)) {
|
|
return null;
|
|
}
|
|
return agentId;
|
|
}
|
|
|
|
function respondInvalidMethodParams(
|
|
respond: RespondFn,
|
|
method: string,
|
|
errors: Parameters<typeof formatValidationErrors>[0],
|
|
): void {
|
|
respond(
|
|
false,
|
|
undefined,
|
|
errorShape(
|
|
ErrorCodes.INVALID_REQUEST,
|
|
`invalid ${method} params: ${formatValidationErrors(errors)}`,
|
|
),
|
|
);
|
|
}
|
|
|
|
function respondAgentNotFound(respond: RespondFn, agentId: string): void {
|
|
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, `agent "${agentId}" not found`));
|
|
}
|
|
|
|
type AgentDeleteRemovedPath = {
|
|
path: string;
|
|
method: "trash" | "missing";
|
|
};
|
|
|
|
type AgentDeleteFailedPath = {
|
|
path: string;
|
|
reason: string;
|
|
};
|
|
|
|
type AgentDeletePathOutcome =
|
|
| { removed: AgentDeleteRemovedPath }
|
|
| { skipped: AgentDeleteFailedPath }
|
|
| { failed: AgentDeleteFailedPath };
|
|
|
|
class AgentCleanupIdentityMismatchError extends Error {}
|
|
|
|
function cleanupFailure(pathname: string, error: unknown): AgentDeletePathOutcome {
|
|
const reason = error instanceof Error && error.message ? error.message : String(error);
|
|
return { failed: { path: pathname, reason: reason || "unknown error" } };
|
|
}
|
|
|
|
function cleanupPathIdentity(stat: { dev?: number | bigint; ino?: number | bigint } | undefined) {
|
|
if (
|
|
(typeof stat?.dev !== "number" && typeof stat?.dev !== "bigint") ||
|
|
(typeof stat.ino !== "number" && typeof stat.ino !== "bigint")
|
|
) {
|
|
return null;
|
|
}
|
|
const dev = Number(stat.dev);
|
|
const ino = Number(stat.ino);
|
|
if (!Number.isSafeInteger(dev) || !Number.isSafeInteger(ino)) {
|
|
throw new Error("cleanup path identity exceeds the safe integer range");
|
|
}
|
|
return { dev, ino };
|
|
}
|
|
|
|
async function statAgentCleanupPath(cleanupPath: AgentDeleteCleanupPath) {
|
|
const parentPath = cleanupPath.parentPath;
|
|
const parentRoot = await agentsHandlerDeps.root(parentPath, {
|
|
hardlinks: "reject",
|
|
symlinks: "reject",
|
|
});
|
|
if (path.resolve(parentRoot.rootReal) !== parentPath) {
|
|
throw new FsSafeError("path-mismatch", "cleanup path parent changed before deletion");
|
|
}
|
|
const stat = await parentRoot.stat(path.basename(cleanupPath.trashPath));
|
|
const isSymlink = stat.isSymbolicLink;
|
|
if (isSymlink !== (cleanupPath.kind === "symlink")) {
|
|
throw new AgentCleanupIdentityMismatchError(
|
|
`cleanup path changed from ${cleanupPath.kind} before deletion`,
|
|
);
|
|
}
|
|
if (stat.isFile && stat.nlink > 1) {
|
|
throw new AgentCleanupIdentityMismatchError("hardlinked cleanup replacement preserved");
|
|
}
|
|
const identity = cleanupPathIdentity(stat);
|
|
if (cleanupPath.preparedIdentity === null) {
|
|
if (identity !== null) {
|
|
throw new AgentCleanupIdentityMismatchError(
|
|
"cleanup path appeared after deletion preparation",
|
|
);
|
|
}
|
|
} else if (
|
|
identity === null ||
|
|
identity.dev !== cleanupPath.preparedIdentity.dev ||
|
|
identity.ino !== cleanupPath.preparedIdentity.ino
|
|
) {
|
|
throw new AgentCleanupIdentityMismatchError("cleanup path identity changed before deletion");
|
|
}
|
|
}
|
|
|
|
async function removeAgentPath(
|
|
cleanupPath: AgentDeleteCleanupPath,
|
|
): Promise<AgentDeletePathOutcome> {
|
|
const pathname = cleanupPath.path;
|
|
const trashPath = cleanupPath.trashPath;
|
|
try {
|
|
await statAgentCleanupPath(cleanupPath);
|
|
} catch (error) {
|
|
if (error instanceof AgentCleanupIdentityMismatchError) {
|
|
return { skipped: { path: pathname, reason: error.message } };
|
|
}
|
|
return isMissingPathError(error)
|
|
? { removed: { path: pathname, method: "missing" } }
|
|
: cleanupFailure(pathname, error);
|
|
}
|
|
try {
|
|
// fs-safe pins traversal and identity for validation; Trash has no fd-relative move API, so
|
|
// replacement after this check and before its rename is the accepted residual race bound.
|
|
await movePathToTrash(trashPath);
|
|
return { removed: { path: pathname, method: "trash" } };
|
|
} catch (error) {
|
|
if (!isMissingPathError(error)) {
|
|
return cleanupFailure(pathname, error);
|
|
}
|
|
try {
|
|
await statAgentCleanupPath(cleanupPath);
|
|
return cleanupFailure(pathname, error);
|
|
} catch (statError) {
|
|
return isMissingPathError(statError)
|
|
? { removed: { path: pathname, method: "missing" } }
|
|
: cleanupFailure(pathname, statError);
|
|
}
|
|
}
|
|
}
|
|
|
|
type AgentDeleteCleanupPath = {
|
|
path: string;
|
|
parentPath: string;
|
|
canonicalPath: string;
|
|
trashPath: string;
|
|
trashCoversDescendants: boolean;
|
|
kind: "target" | "symlink";
|
|
preparedIdentity: { dev: number; ino: number } | null;
|
|
done: boolean;
|
|
note?: string;
|
|
preparationError?: unknown;
|
|
sourcePaths: string[];
|
|
};
|
|
|
|
async function resolveAgentDeleteCleanupTarget(pathname: string): Promise<string> {
|
|
let candidate = path.resolve(pathname);
|
|
const missingSuffix: string[] = [];
|
|
while (true) {
|
|
try {
|
|
return path.resolve(await fs.realpath(candidate), ...missingSuffix);
|
|
} catch (error) {
|
|
if (!isMissingPathError(error)) {
|
|
throw error;
|
|
}
|
|
let candidateStat: Awaited<ReturnType<typeof fs.lstat>> | undefined;
|
|
try {
|
|
candidateStat = await fs.lstat(candidate);
|
|
} catch (statError) {
|
|
if (!isMissingPathError(statError)) {
|
|
throw statError;
|
|
}
|
|
}
|
|
if (candidateStat?.isSymbolicLink()) {
|
|
const linkTarget = await fs.readlink(candidate);
|
|
const resolvedLinkTarget = await resolveAgentDeleteCleanupTarget(
|
|
path.isAbsolute(linkTarget)
|
|
? linkTarget
|
|
: path.resolve(path.dirname(candidate), linkTarget),
|
|
);
|
|
return path.resolve(resolvedLinkTarget, ...missingSuffix);
|
|
}
|
|
const parent = path.dirname(candidate);
|
|
if (parent === candidate) {
|
|
throw error;
|
|
}
|
|
missingSuffix.unshift(path.basename(candidate));
|
|
candidate = parent;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function prepareAgentDeleteCleanupPaths(
|
|
paths: readonly string[],
|
|
persistedPaths: readonly AgentDeletionJournalCleanupPath[] = [],
|
|
): Promise<AgentDeleteCleanupPath[]> {
|
|
const uniquePaths = new Map<string, AgentDeleteCleanupPath>();
|
|
const addPath = (candidate: AgentDeleteCleanupPath) => {
|
|
const existing = uniquePaths.get(candidate.trashPath);
|
|
if (!existing) {
|
|
uniquePaths.set(candidate.trashPath, candidate);
|
|
return;
|
|
}
|
|
existing.sourcePaths = [...new Set([...existing.sourcePaths, ...candidate.sourcePaths])];
|
|
existing.done ||= candidate.done;
|
|
existing.note ??= candidate.note;
|
|
existing.preparationError ??= candidate.preparationError;
|
|
if (candidate.kind === "target") {
|
|
existing.kind = "target";
|
|
existing.canonicalPath = candidate.canonicalPath;
|
|
existing.parentPath = candidate.parentPath;
|
|
existing.trashCoversDescendants ||= candidate.trashCoversDescendants;
|
|
}
|
|
};
|
|
if (persistedPaths.length > 0) {
|
|
for (const persistedPath of persistedPaths) {
|
|
const journalPath = path.resolve(persistedPath.path);
|
|
const trashPath = path.resolve(persistedPath.canonicalPath);
|
|
addPath({
|
|
path: journalPath,
|
|
parentPath: path.resolve(persistedPath.parentPath),
|
|
canonicalPath: normalizeAgentDirRegistryPath(trashPath),
|
|
trashPath,
|
|
trashCoversDescendants: persistedPath.coversDescendants,
|
|
kind: persistedPath.kind,
|
|
preparedIdentity:
|
|
persistedPath.dev === null || persistedPath.ino === null
|
|
? null
|
|
: { dev: persistedPath.dev, ino: persistedPath.ino },
|
|
done: persistedPath.done,
|
|
note: persistedPath.note,
|
|
sourcePaths: persistedPath.sourcePaths.map((sourcePath) => path.resolve(sourcePath)),
|
|
});
|
|
}
|
|
}
|
|
for (const pathname of paths) {
|
|
const sourcePath = path.resolve(pathname);
|
|
let sourceParentPath = path.dirname(sourcePath);
|
|
let resolvedPath = sourcePath;
|
|
let preparationError: unknown;
|
|
try {
|
|
resolvedPath = await resolveAgentDeleteCleanupTarget(pathname);
|
|
sourceParentPath = await resolveAgentDeleteCleanupTarget(path.dirname(sourcePath));
|
|
} catch (error) {
|
|
preparationError = error;
|
|
}
|
|
let sourceStat: Awaited<ReturnType<typeof fs.lstat>> | undefined;
|
|
try {
|
|
sourceStat = await fs.lstat(pathname);
|
|
} catch (error) {
|
|
if (!isMissingPathError(error)) {
|
|
preparationError ??= error;
|
|
}
|
|
}
|
|
let targetStat = sourceStat;
|
|
if (resolvedPath !== sourcePath) {
|
|
try {
|
|
targetStat = await fs.lstat(resolvedPath);
|
|
} catch (error) {
|
|
if (!isMissingPathError(error)) {
|
|
preparationError ??= error;
|
|
}
|
|
targetStat = undefined;
|
|
}
|
|
}
|
|
const canonicalPath = normalizeAgentDirRegistryPath(resolvedPath);
|
|
let trashCoversDescendants = false;
|
|
if (targetStat) {
|
|
trashCoversDescendants = !targetStat.isSymbolicLink();
|
|
}
|
|
addPath({
|
|
path: resolvedPath,
|
|
parentPath: path.dirname(resolvedPath),
|
|
canonicalPath,
|
|
trashPath: resolvedPath,
|
|
trashCoversDescendants,
|
|
kind: "target",
|
|
preparedIdentity: cleanupPathIdentity(targetStat),
|
|
done: false,
|
|
preparationError,
|
|
sourcePaths: [sourcePath],
|
|
});
|
|
if (sourceStat?.isSymbolicLink() && sourcePath !== resolvedPath) {
|
|
addPath({
|
|
path: sourcePath,
|
|
parentPath: sourceParentPath,
|
|
canonicalPath,
|
|
trashPath: path.join(sourceParentPath, path.basename(sourcePath)),
|
|
trashCoversDescendants: false,
|
|
kind: "symlink",
|
|
preparedIdentity: cleanupPathIdentity(sourceStat),
|
|
done: false,
|
|
sourcePaths: [sourcePath],
|
|
});
|
|
}
|
|
}
|
|
const depth = (pathname: string) =>
|
|
path.relative(path.parse(pathname).root, pathname).split(path.sep).filter(Boolean).length;
|
|
const cleanupDepth = (cleanupPath: AgentDeleteCleanupPath) =>
|
|
Math.max(
|
|
depth(cleanupPath.canonicalPath),
|
|
depth(cleanupPath.trashPath),
|
|
...cleanupPath.sourcePaths.map(depth),
|
|
);
|
|
const compareFallback = (left: AgentDeleteCleanupPath, right: AgentDeleteCleanupPath) => {
|
|
if (left.kind !== right.kind) {
|
|
return left.kind === "target" ? -1 : 1;
|
|
}
|
|
const depthDifference = cleanupDepth(right) - cleanupDepth(left);
|
|
if (depthDifference !== 0) {
|
|
return depthDifference;
|
|
}
|
|
const trashDepth = depth(right.trashPath) - depth(left.trashPath);
|
|
return trashDepth || left.trashPath.localeCompare(right.trashPath);
|
|
};
|
|
const mustPrecede = (left: AgentDeleteCleanupPath, right: AgentDeleteCleanupPath) => {
|
|
if (left.kind !== right.kind) {
|
|
return left.kind === "target";
|
|
}
|
|
if (isPathInside(right.trashPath, left.trashPath)) {
|
|
return true;
|
|
}
|
|
if (isPathInside(left.trashPath, right.trashPath)) {
|
|
return false;
|
|
}
|
|
const rightRoots = [right.trashPath, ...right.sourcePaths];
|
|
return left.sourcePaths.some((leftSource) =>
|
|
rightRoots.some((rightRoot) => isPathInside(rightRoot, leftSource)),
|
|
);
|
|
};
|
|
const remaining = [...uniquePaths.values()].toSorted(compareFallback);
|
|
const ordered: AgentDeleteCleanupPath[] = [];
|
|
// Snapshot real targets and clean every physical or lexical descendant first; moving an
|
|
// ancestor symlink would otherwise hide surviving child data and let recovery finalize.
|
|
while (remaining.length > 0) {
|
|
const nextIndex = remaining.findIndex((candidate, candidateIndex) =>
|
|
remaining.every(
|
|
(other, otherIndex) => otherIndex === candidateIndex || !mustPrecede(other, candidate),
|
|
),
|
|
);
|
|
ordered.push(...remaining.splice(Math.max(0, nextIndex), 1));
|
|
}
|
|
return ordered;
|
|
}
|
|
|
|
function cleanupPathCovers(
|
|
cleanupPath: AgentDeleteCleanupPath,
|
|
targetPath: string,
|
|
canonicalTargetPath: string,
|
|
): boolean {
|
|
const trashTargetPath = path.resolve(targetPath);
|
|
return (
|
|
cleanupPath.sourcePaths.includes(trashTargetPath) ||
|
|
cleanupPath.trashPath === trashTargetPath ||
|
|
(cleanupPath.trashCoversDescendants &&
|
|
(cleanupPath.kind === "target" || isPathInside(cleanupPath.trashPath, trashTargetPath)) &&
|
|
isPathInside(cleanupPath.canonicalPath, canonicalTargetPath))
|
|
);
|
|
}
|
|
|
|
type AgentDeleteDatabasePlan = {
|
|
paths: string[];
|
|
registrationPaths: string[];
|
|
fileGroups: string[][];
|
|
relocatedFileGroups: string[][];
|
|
};
|
|
|
|
function resolveSurvivingDatabaseFilePaths(
|
|
registeredDatabases: ReturnType<typeof listOpenClawRegisteredAgentDatabases>,
|
|
agentId: string,
|
|
): string[] {
|
|
return [
|
|
...new Set(
|
|
registeredDatabases
|
|
.filter((entry) => normalizeAgentId(entry.agentId) !== agentId)
|
|
.flatMap((entry) => resolveSqliteDatabaseFilePaths(entry.path))
|
|
.map((pathname) => normalizeAgentDirRegistryPath(pathname)),
|
|
),
|
|
];
|
|
}
|
|
|
|
function isPathOwnedBySurvivingAgent(
|
|
cfg: OpenClawConfig,
|
|
agentId: string,
|
|
pathname: string,
|
|
survivingDatabaseFilePaths: readonly string[] = [],
|
|
): boolean {
|
|
const canonicalPath = normalizeAgentDirRegistryPath(pathname);
|
|
return (
|
|
isPathOwnedByAnotherRegisteredAgent({ agentId, pathname }) ||
|
|
findOverlappingWorkspaceAgentIds(cfg, agentId, pathname).length > 0 ||
|
|
survivingDatabaseFilePaths.some(
|
|
(databasePath) =>
|
|
databasePath === canonicalPath ||
|
|
isPathInside(databasePath, canonicalPath) ||
|
|
isPathInside(canonicalPath, databasePath),
|
|
)
|
|
);
|
|
}
|
|
|
|
function prepareAgentDeleteDatabases(
|
|
cfg: OpenClawConfig,
|
|
agentId: string,
|
|
agentDir: string,
|
|
): AgentDeleteDatabasePlan {
|
|
const registeredDatabases = listOpenClawRegisteredAgentDatabases();
|
|
const survivingDatabaseFilePaths = resolveSurvivingDatabaseFilePaths(
|
|
registeredDatabases,
|
|
agentId,
|
|
);
|
|
const registeredDatabasePaths = new Set([
|
|
resolveOpenClawAgentSqlitePath({
|
|
agentId,
|
|
path: path.join(agentDir, "openclaw-agent.sqlite"),
|
|
}),
|
|
...registeredDatabases
|
|
.filter((entry) => normalizeAgentId(entry.agentId) === agentId)
|
|
.map((entry) => entry.path),
|
|
]);
|
|
const databasePaths = [...registeredDatabasePaths].filter((pathname) =>
|
|
resolveSqliteDatabaseFilePaths(pathname).every(
|
|
(filePath) =>
|
|
!isPathOwnedBySurvivingAgent(cfg, agentId, filePath, survivingDatabaseFilePaths),
|
|
),
|
|
);
|
|
for (const databasePath of databasePaths) {
|
|
closeOpenClawAgentDatabaseByPath(databasePath);
|
|
}
|
|
assertNoOpenClawAgentDatabaseLeases(agentId);
|
|
const fileGroups = databasePaths.map(resolveSqliteDatabaseFilePaths);
|
|
const relocatedFileGroups = fileGroups.filter((fileGroup) => {
|
|
const relative = path.relative(agentDir, fileGroup[0] ?? agentDir);
|
|
return relative.startsWith("..") || path.isAbsolute(relative);
|
|
});
|
|
return {
|
|
paths: databasePaths,
|
|
registrationPaths: [...registeredDatabasePaths],
|
|
fileGroups,
|
|
relocatedFileGroups,
|
|
};
|
|
}
|
|
|
|
function unregisterAgentDeleteDatabases(agentId: string, databasePaths: string[]): void {
|
|
for (const databasePath of databasePaths) {
|
|
unregisterOpenClawAgentDatabase({ agentId, path: databasePath });
|
|
}
|
|
}
|
|
|
|
function prepareJournaledAgentDirOwnership(
|
|
cfg: OpenClawConfig,
|
|
agentId: string,
|
|
agentDir: string,
|
|
): void {
|
|
for (const configuredAgentId of listAgentIds(cfg)) {
|
|
resolveAgentDir(cfg, configuredAgentId);
|
|
}
|
|
const registeredOwner = resolveRegisteredAgentIdForDir(agentDir);
|
|
if (registeredOwner !== undefined) {
|
|
return;
|
|
}
|
|
// The durable journal retains ownership across restarts after the roster entry is gone.
|
|
registerResolvedAgentDir({ agentId, agentDir });
|
|
}
|
|
|
|
function respondWorkspaceFileUnsafe(respond: RespondFn, name: string): void {
|
|
respond(
|
|
false,
|
|
undefined,
|
|
errorShape(ErrorCodes.INVALID_REQUEST, `unsafe workspace file "${name}"`),
|
|
);
|
|
}
|
|
|
|
function respondWorkspaceFileMissing(params: {
|
|
respond: RespondFn;
|
|
agentId: string;
|
|
workspaceDir: string;
|
|
name: string;
|
|
filePath: string;
|
|
}): void {
|
|
params.respond(
|
|
true,
|
|
{
|
|
agentId: params.agentId,
|
|
workspace: params.workspaceDir,
|
|
// Clients merge this entry over the listed one, so it must carry the same
|
|
// absence classification or a picked optional file re-renders as a fault.
|
|
file: {
|
|
name: params.name,
|
|
path: params.filePath,
|
|
missing: true,
|
|
expectedAbsent: isExpectedAbsentBootstrapFile(params.name),
|
|
},
|
|
},
|
|
undefined,
|
|
);
|
|
}
|
|
|
|
async function writeWorkspaceFileOrRespond(params: {
|
|
respond: RespondFn;
|
|
workspaceDir: string;
|
|
name: string;
|
|
content: string;
|
|
}): Promise<boolean> {
|
|
await fs.mkdir(params.workspaceDir, { recursive: true });
|
|
try {
|
|
const workspaceRoot = await agentsHandlerDeps.root(params.workspaceDir);
|
|
await workspaceRoot.write(params.name, params.content, { encoding: "utf8" });
|
|
} catch (err) {
|
|
if (err instanceof FsSafeError) {
|
|
respondWorkspaceFileUnsafe(params.respond, params.name);
|
|
return false;
|
|
}
|
|
throw err;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function readWorkspaceFileContent(
|
|
workspaceDir: string,
|
|
name: string,
|
|
): Promise<string | undefined> {
|
|
try {
|
|
const workspaceRoot = await agentsHandlerDeps.root(workspaceDir);
|
|
const safeRead = await workspaceRoot.read(name, {
|
|
hardlinks: "reject",
|
|
nonBlockingRead: true,
|
|
});
|
|
return safeRead.buffer.toString("utf-8");
|
|
} catch (err) {
|
|
if (isMissingPathError(err)) {
|
|
return undefined;
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async function buildIdentityMarkdownForWrite(params: {
|
|
workspaceDir: string;
|
|
identity: IdentityConfig;
|
|
fallbackWorkspaceDir?: string;
|
|
preferFallbackWorkspaceContent?: boolean;
|
|
}): Promise<string> {
|
|
let baseContent: string | undefined;
|
|
if (params.preferFallbackWorkspaceContent && params.fallbackWorkspaceDir) {
|
|
// Workspace moves may create a blank identity file; merge into the previous user-edited file.
|
|
baseContent = await readWorkspaceFileContent(
|
|
params.fallbackWorkspaceDir,
|
|
DEFAULT_IDENTITY_FILENAME,
|
|
);
|
|
if (baseContent === undefined) {
|
|
baseContent = await readWorkspaceFileContent(params.workspaceDir, DEFAULT_IDENTITY_FILENAME);
|
|
}
|
|
} else {
|
|
baseContent = await readWorkspaceFileContent(params.workspaceDir, DEFAULT_IDENTITY_FILENAME);
|
|
if (baseContent === undefined && params.fallbackWorkspaceDir) {
|
|
baseContent = await readWorkspaceFileContent(
|
|
params.fallbackWorkspaceDir,
|
|
DEFAULT_IDENTITY_FILENAME,
|
|
);
|
|
}
|
|
}
|
|
|
|
return mergeIdentityMarkdownContent(baseContent, params.identity);
|
|
}
|
|
|
|
async function buildIdentityMarkdownOrRespondUnsafe(params: {
|
|
respond: RespondFn;
|
|
workspaceDir: string;
|
|
identity: IdentityConfig;
|
|
fallbackWorkspaceDir?: string;
|
|
preferFallbackWorkspaceContent?: boolean;
|
|
}): Promise<string | null> {
|
|
try {
|
|
return await buildIdentityMarkdownForWrite(params);
|
|
} catch (err) {
|
|
if (err instanceof FsSafeError) {
|
|
respondWorkspaceFileUnsafe(params.respond, DEFAULT_IDENTITY_FILENAME);
|
|
return null;
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
export const agentsHandlers: GatewayRequestHandlers = {
|
|
"agents.list": async ({ params, respond, context, client }) => {
|
|
if (!validateAgentsListParams(params)) {
|
|
respondInvalidMethodParams(respond, "agents.list", validateAgentsListParams.errors);
|
|
return;
|
|
}
|
|
|
|
const cfg = context.getRuntimeConfig();
|
|
const modelCatalog = await readPreparedServerMethodModelCatalog(context);
|
|
const result = listAgentsForGateway(cfg, modelCatalog, {
|
|
includeSystem: hasGatewayClientCap(client?.connect.caps, GATEWAY_CLIENT_CAPS.AGENT_KIND),
|
|
});
|
|
respond(true, result, undefined);
|
|
},
|
|
"agents.create": async ({ params, respond }) => {
|
|
if (!validateAgentsCreateParams(params)) {
|
|
respondInvalidMethodParams(respond, "agents.create", validateAgentsCreateParams.errors);
|
|
return;
|
|
}
|
|
|
|
const result = await createAgent({
|
|
name: params.name,
|
|
workspace: params.workspace,
|
|
model: params.model,
|
|
emoji: params.emoji,
|
|
avatar: params.avatar,
|
|
});
|
|
if (result.status === "error") {
|
|
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, result.message));
|
|
return;
|
|
}
|
|
respond(
|
|
true,
|
|
{
|
|
ok: true,
|
|
agentId: result.agentId,
|
|
name: result.name,
|
|
workspace: result.workspace,
|
|
...(result.model ? { model: result.model } : {}),
|
|
},
|
|
undefined,
|
|
);
|
|
},
|
|
"agents.update": async ({ params, respond, context }) => {
|
|
if (!validateAgentsUpdateParams(params)) {
|
|
respondInvalidMethodParams(respond, "agents.update", validateAgentsUpdateParams.errors);
|
|
return;
|
|
}
|
|
|
|
const cfg = context.getRuntimeConfig();
|
|
const agentId = normalizeAgentId(params.agentId);
|
|
if (!isConfiguredAgent(cfg, agentId)) {
|
|
respondAgentNotFound(respond, agentId);
|
|
return;
|
|
}
|
|
|
|
const workspaceDir =
|
|
typeof params.workspace === "string" && params.workspace.trim()
|
|
? resolveUserPath(params.workspace.trim())
|
|
: undefined;
|
|
|
|
const model = params.model === null ? null : resolveOptionalStringParam(params.model);
|
|
|
|
const safeName =
|
|
typeof params.name === "string" && params.name.trim()
|
|
? sanitizeAgentIdentityLine(params.name.trim())
|
|
: undefined;
|
|
|
|
const identity = createAgentIdentityConfig({
|
|
name: safeName,
|
|
emoji: params.emoji,
|
|
avatar: params.avatar,
|
|
});
|
|
const hasIdentityFields = Boolean(identity);
|
|
|
|
const agentConfigUpdate: Parameters<typeof updateAgentConfigEntry>[0] = {
|
|
agentId,
|
|
...(safeName ? { name: safeName } : {}),
|
|
...(workspaceDir ? { workspace: workspaceDir } : {}),
|
|
...(model !== undefined ? { model } : {}),
|
|
...(identity ? { identity } : {}),
|
|
};
|
|
const nextConfig = applyAgentConfig(cfg, agentConfigUpdate);
|
|
|
|
let ensuredWorkspace: Awaited<ReturnType<typeof ensureAgentWorkspace>> | undefined;
|
|
if (workspaceDir) {
|
|
const skipBootstrap = Boolean(nextConfig.agents?.defaults?.skipBootstrap);
|
|
ensuredWorkspace = await ensureAgentWorkspace({
|
|
dir: workspaceDir,
|
|
ensureBootstrapFiles: !skipBootstrap,
|
|
skipOptionalBootstrapFiles: nextConfig.agents?.defaults?.skipOptionalBootstrapFiles,
|
|
});
|
|
}
|
|
|
|
const persistedIdentity = normalizeIdentityForFile(resolveAgentIdentity(nextConfig, agentId));
|
|
if (persistedIdentity && (workspaceDir || hasIdentityFields)) {
|
|
const identityWorkspaceDir = resolveAgentWorkspaceDir(nextConfig, agentId);
|
|
const previousWorkspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
|
|
const fallbackWorkspaceDir =
|
|
workspaceDir && identityWorkspaceDir !== previousWorkspaceDir
|
|
? previousWorkspaceDir
|
|
: undefined;
|
|
const identityContent = await buildIdentityMarkdownOrRespondUnsafe({
|
|
respond,
|
|
workspaceDir: identityWorkspaceDir,
|
|
identity: persistedIdentity,
|
|
fallbackWorkspaceDir,
|
|
preferFallbackWorkspaceContent:
|
|
Boolean(fallbackWorkspaceDir) && ensuredWorkspace?.identityPathCreated === true,
|
|
});
|
|
if (identityContent === null) {
|
|
return;
|
|
}
|
|
if (
|
|
!(await writeWorkspaceFileOrRespond({
|
|
respond,
|
|
workspaceDir: identityWorkspaceDir,
|
|
name: DEFAULT_IDENTITY_FILENAME,
|
|
content: identityContent,
|
|
}))
|
|
) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
try {
|
|
await updateAgentConfigEntry(agentConfigUpdate);
|
|
} catch (error) {
|
|
if (error instanceof AgentConfigPreconditionError) {
|
|
respondAgentNotFound(respond, agentId);
|
|
return;
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
respond(true, { ok: true, agentId }, undefined);
|
|
},
|
|
"agents.delete": async ({ params, respond, context }) => {
|
|
if (!validateAgentsDeleteParams(params)) {
|
|
respondInvalidMethodParams(respond, "agents.delete", validateAgentsDeleteParams.errors);
|
|
return;
|
|
}
|
|
|
|
const cfg = context.getRuntimeConfig();
|
|
const agentId = normalizeAgentId(params.agentId);
|
|
// agents/main/agent also owns the shipped shared legacy auth store.
|
|
// Keep main undeletable until named agents make auth-store ownership explicit.
|
|
if (agentId === LEGACY_IMPLICIT_AGENT_ID) {
|
|
respond(
|
|
false,
|
|
undefined,
|
|
errorShape(ErrorCodes.INVALID_REQUEST, `"${LEGACY_IMPLICIT_AGENT_ID}" cannot be deleted`),
|
|
);
|
|
return;
|
|
}
|
|
const existingJournal = readAgentDeletionJournal(agentId);
|
|
if (
|
|
!isConfiguredAgent(cfg, agentId) &&
|
|
(!existingJournal || existingJournal.cleanupCompleted)
|
|
) {
|
|
respondAgentNotFound(respond, agentId);
|
|
return;
|
|
}
|
|
if (agentId === resolveDefaultAgentId(cfg)) {
|
|
respond(
|
|
false,
|
|
undefined,
|
|
errorShape(
|
|
ErrorCodes.INVALID_REQUEST,
|
|
`Agent "${agentId}" is the default and cannot be deleted. Reassign default first.`,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
const requestedDeleteFiles =
|
|
typeof params.deleteFiles === "boolean" ? params.deleteFiles : true;
|
|
try {
|
|
const result = await withConfigMutationExclusive(async (lockedConfig) => {
|
|
let lockedJournal = readAgentDeletionJournal(agentId);
|
|
const configured = isConfiguredAgent(lockedConfig, agentId);
|
|
if (!configured && (!lockedJournal || lockedJournal.cleanupCompleted)) {
|
|
throw new AgentConfigPreconditionError(`agent "${agentId}" not found`);
|
|
}
|
|
if (agentId === resolveDefaultAgentId(lockedConfig)) {
|
|
throw new AgentConfigPreconditionError(
|
|
`agent "${agentId}" is the default; reassign default first`,
|
|
);
|
|
}
|
|
if (configured && lockedJournal?.cleanupCompleted) {
|
|
const claimed = claimCompletedAgentDeletion(agentId, lockedJournal.operationId);
|
|
const remainingJournal = readAgentDeletionJournal(agentId);
|
|
if (!claimed && remainingJournal) {
|
|
throw new Error(`agent "${agentId}" deletion tombstone changed before fresh deletion`);
|
|
}
|
|
lockedJournal = undefined;
|
|
}
|
|
const deleteFiles = lockedJournal?.deleteFiles ?? requestedDeleteFiles;
|
|
const deletion = beginAgentDeletion(
|
|
lockedJournal ?? {
|
|
agentId,
|
|
agentDir: resolveAgentDir(lockedConfig, agentId),
|
|
workspaceDir: resolveAgentWorkspaceDir(lockedConfig, agentId),
|
|
sessionsDir: resolveSessionTranscriptsDirForAgent(agentId),
|
|
deleteFiles,
|
|
},
|
|
);
|
|
const journal = deletion.entry;
|
|
let rosterCommitted = !configured;
|
|
let committed: Awaited<ReturnType<typeof deleteAgentConfigEntry>> | undefined;
|
|
let databasePlan: AgentDeleteDatabasePlan | undefined;
|
|
try {
|
|
prepareJournaledAgentDirOwnership(lockedConfig, agentId, journal.agentDir);
|
|
databasePlan = prepareAgentDeleteDatabases(lockedConfig, agentId, journal.agentDir);
|
|
deletion.fenceDatabasePaths([
|
|
...journal.databasePaths,
|
|
...databasePlan.fileGroups.flat(),
|
|
]);
|
|
if (deleteFiles) {
|
|
const fencedSourcePaths = new Set(
|
|
journal.cleanupPaths.flatMap((cleanupPath) =>
|
|
cleanupPath.sourcePaths.map((sourcePath) => path.resolve(sourcePath)),
|
|
),
|
|
);
|
|
const unfencedSourcePaths = [
|
|
journal.workspaceDir,
|
|
journal.agentDir,
|
|
journal.sessionsDir,
|
|
...journal.databasePaths,
|
|
].filter((sourcePath) => !fencedSourcePaths.has(path.resolve(sourcePath)));
|
|
if (unfencedSourcePaths.length > 0) {
|
|
const unfencedSourcePathSet = new Set(
|
|
unfencedSourcePaths.map((sourcePath) => path.resolve(sourcePath)),
|
|
);
|
|
const cleanupPlan = await prepareAgentDeleteCleanupPaths(
|
|
unfencedSourcePaths,
|
|
journal.cleanupPaths,
|
|
);
|
|
const unresolvedPath = cleanupPlan.find(
|
|
(cleanupPath) =>
|
|
cleanupPath.preparationError !== undefined &&
|
|
cleanupPath.sourcePaths.some((sourcePath) =>
|
|
unfencedSourcePathSet.has(path.resolve(sourcePath)),
|
|
),
|
|
);
|
|
if (unresolvedPath) {
|
|
throw unresolvedPath.preparationError;
|
|
}
|
|
deletion.fenceCleanupPaths(
|
|
cleanupPlan.map(
|
|
({
|
|
path: cleanupPath,
|
|
trashPath,
|
|
parentPath,
|
|
kind,
|
|
preparedIdentity,
|
|
trashCoversDescendants,
|
|
done,
|
|
note,
|
|
sourcePaths,
|
|
}) => {
|
|
const journalPath: AgentDeletionJournalCleanupPath = {
|
|
path: cleanupPath,
|
|
canonicalPath: trashPath,
|
|
parentPath,
|
|
kind,
|
|
sourcePaths,
|
|
dev: preparedIdentity?.dev ?? null,
|
|
ino: preparedIdentity?.ino ?? null,
|
|
coversDescendants: trashCoversDescendants,
|
|
done,
|
|
};
|
|
if (note) {
|
|
journalPath.note = note;
|
|
}
|
|
return journalPath;
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
await context.cron.removeAgentJobsTransactional(
|
|
agentId,
|
|
async () =>
|
|
await withAgentExecApprovalsRemoved(agentId, async () => {
|
|
if (!rosterCommitted) {
|
|
try {
|
|
committed = await deleteAgentConfigEntry({ agentId });
|
|
} catch (error) {
|
|
try {
|
|
const persisted = await readConfigFileSnapshotForWrite();
|
|
if (!isConfiguredAgent(persisted.snapshot.sourceConfig, agentId)) {
|
|
rosterCommitted = true;
|
|
throw new AgentDeletionCommitUncertainError(error);
|
|
}
|
|
} catch (readError) {
|
|
if (readError instanceof AgentDeletionCommitUncertainError) {
|
|
throw readError;
|
|
}
|
|
throw new AgentDeletionCommitUncertainError(error);
|
|
}
|
|
throw error;
|
|
}
|
|
if (!committed.result) {
|
|
rosterCommitted = !isConfiguredAgent(committed.nextConfig, agentId);
|
|
const missingResultError = new Error(
|
|
"agent delete config mutation did not return its target",
|
|
);
|
|
if (rosterCommitted) {
|
|
throw new AgentDeletionCommitUncertainError(missingResultError);
|
|
}
|
|
throw missingResultError;
|
|
}
|
|
rosterCommitted = true;
|
|
}
|
|
}),
|
|
);
|
|
deletion.commit();
|
|
} catch (error) {
|
|
let canReleaseFence =
|
|
!rosterCommitted &&
|
|
!lockedJournal &&
|
|
!(error instanceof AgentDeletionAuthorityRollbackError) &&
|
|
!(error instanceof AgentDeletionCommitUncertainError);
|
|
if (canReleaseFence) {
|
|
try {
|
|
const persisted = await readConfigFileSnapshotForWrite();
|
|
canReleaseFence = isConfiguredAgent(persisted.snapshot.sourceConfig, agentId);
|
|
} catch {
|
|
canReleaseFence = false;
|
|
}
|
|
}
|
|
if (canReleaseFence) {
|
|
deletion.rollback();
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
const deleteResult = committed?.result ?? {
|
|
agentDir: journal.agentDir,
|
|
workspaceDir: journal.workspaceDir,
|
|
sessionsDir: journal.sessionsDir,
|
|
removedBindings: 0,
|
|
};
|
|
const nextConfig = committed?.nextConfig ?? lockedConfig;
|
|
|
|
// A journaled path is trash-eligible only while registry ownership still points at the
|
|
// deleted agent; recovery must not consume a path claimed by a surviving agent.
|
|
const agentDirRegistryPath = normalizeAgentDirRegistryPath(deleteResult.agentDir);
|
|
await purgeAgentSessionStoreEntries(lockedConfig, agentId);
|
|
|
|
const removed: AgentDeleteRemovedPath[] = [];
|
|
const failed: AgentDeleteFailedPath[] = [];
|
|
|
|
if (deleteFiles) {
|
|
const survivingDatabaseFilePaths = resolveSurvivingDatabaseFilePaths(
|
|
listOpenClawRegisteredAgentDatabases(),
|
|
agentId,
|
|
);
|
|
const workspaceTrashEligible = !isPathOwnedBySurvivingAgent(
|
|
nextConfig,
|
|
agentId,
|
|
deleteResult.workspaceDir,
|
|
survivingDatabaseFilePaths,
|
|
);
|
|
// The config mutation lock and durable journal fence block new roster and database
|
|
// claims across this final ownership recheck and the filesystem cleanup below.
|
|
const agentDirTrashEligible =
|
|
resolveRegisteredAgentIdForDir(deleteResult.agentDir) === agentId &&
|
|
!isPathOwnedBySurvivingAgent(
|
|
nextConfig,
|
|
agentId,
|
|
deleteResult.agentDir,
|
|
survivingDatabaseFilePaths,
|
|
);
|
|
const sessionsDirTrashEligible = !isPathOwnedBySurvivingAgent(
|
|
nextConfig,
|
|
agentId,
|
|
deleteResult.sessionsDir,
|
|
survivingDatabaseFilePaths,
|
|
);
|
|
const databaseFilePaths = [
|
|
...(agentDirTrashEligible
|
|
? (databasePlan?.relocatedFileGroups ?? [])
|
|
: (databasePlan?.fileGroups ?? [])
|
|
).flat(),
|
|
...journal.databasePaths,
|
|
].filter(
|
|
(pathname) =>
|
|
!isPathOwnedBySurvivingAgent(
|
|
nextConfig,
|
|
agentId,
|
|
pathname,
|
|
survivingDatabaseFilePaths,
|
|
),
|
|
);
|
|
const eligibleSourcePaths = new Set(
|
|
[
|
|
...(workspaceTrashEligible ? [deleteResult.workspaceDir] : []),
|
|
...(agentDirTrashEligible ? [deleteResult.agentDir] : []),
|
|
...(sessionsDirTrashEligible ? [deleteResult.sessionsDir] : []),
|
|
...databaseFilePaths,
|
|
].map((sourcePath) => path.resolve(sourcePath)),
|
|
);
|
|
const cleanupPaths = (
|
|
await prepareAgentDeleteCleanupPaths([], journal.cleanupPaths)
|
|
).filter(
|
|
(cleanupPath) =>
|
|
cleanupPath.sourcePaths.some((sourcePath) => eligibleSourcePaths.has(sourcePath)) &&
|
|
(agentDirTrashEligible ||
|
|
!cleanupPathCovers(cleanupPath, deleteResult.agentDir, agentDirRegistryPath)),
|
|
);
|
|
const workspaceCanonicalPath = normalizeAgentDirRegistryPath(deleteResult.workspaceDir);
|
|
const workspaceCleanupPaths = cleanupPaths.filter((cleanupPath) =>
|
|
cleanupPathCovers(cleanupPath, deleteResult.workspaceDir, workspaceCanonicalPath),
|
|
);
|
|
const legacyPlan =
|
|
workspaceCleanupPaths.length > 0
|
|
? prepareLegacyWorkspaceStateReset(deleteResult.workspaceDir)
|
|
: undefined;
|
|
const statePlan =
|
|
workspaceCleanupPaths.length > 0
|
|
? prepareWorkspaceStateDeletion(deleteResult.workspaceDir)
|
|
: undefined;
|
|
const outcomes: Array<{
|
|
cleanupPath: AgentDeleteCleanupPath;
|
|
outcome: AgentDeletePathOutcome;
|
|
}> = [];
|
|
const completedCleanupPaths = new Set(
|
|
cleanupPaths.filter((cleanupPath) => cleanupPath.done),
|
|
);
|
|
const markCleanupPathDone = (cleanupPath: AgentDeleteCleanupPath, note?: string) => {
|
|
const canonicalPath = path.resolve(cleanupPath.trashPath);
|
|
deletion.fenceCleanupPaths(
|
|
journal.cleanupPaths.map((entry) => {
|
|
if (
|
|
path.resolve(entry.canonicalPath) !== canonicalPath ||
|
|
entry.kind !== cleanupPath.kind
|
|
) {
|
|
return entry;
|
|
}
|
|
const updated = Object.assign({}, entry, { done: true });
|
|
if (note) {
|
|
updated.note = note;
|
|
}
|
|
return updated;
|
|
}),
|
|
);
|
|
cleanupPath.done = true;
|
|
cleanupPath.note = note;
|
|
completedCleanupPaths.add(cleanupPath);
|
|
};
|
|
const protectedCleanupPaths: Array<{
|
|
cleanupPath: AgentDeleteCleanupPath;
|
|
protectAliases: boolean;
|
|
terminal: boolean;
|
|
note?: string;
|
|
}> = [];
|
|
for (const cleanupPath of cleanupPaths) {
|
|
if (cleanupPath.done) {
|
|
let replacementPresent = true;
|
|
let note =
|
|
cleanupPath.note ?? "completed cleanup path is occupied; replacement preserved";
|
|
try {
|
|
await statAgentCleanupPath(cleanupPath);
|
|
} catch (error) {
|
|
if (isMissingPathError(error)) {
|
|
replacementPresent = false;
|
|
} else if (!(error instanceof AgentCleanupIdentityMismatchError)) {
|
|
note = "completed cleanup path could not be verified; replacement preserved";
|
|
}
|
|
}
|
|
if (replacementPresent) {
|
|
markCleanupPathDone(cleanupPath, note);
|
|
protectedCleanupPaths.push({
|
|
cleanupPath,
|
|
protectAliases: true,
|
|
terminal: true,
|
|
note,
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
const refreshedDatabaseFilePaths = resolveSurvivingDatabaseFilePaths(
|
|
listOpenClawRegisteredAgentDatabases(),
|
|
agentId,
|
|
);
|
|
const blockingProtection = protectedCleanupPaths.find(
|
|
({ cleanupPath: protectedPath, protectAliases }) =>
|
|
((cleanupPath.kind !== "symlink" || protectAliases) &&
|
|
(protectedPath.canonicalPath === cleanupPath.canonicalPath ||
|
|
isPathInside(cleanupPath.canonicalPath, protectedPath.canonicalPath))) ||
|
|
[
|
|
protectedPath.trashPath,
|
|
...(protectAliases ? protectedPath.sourcePaths : []),
|
|
].some(
|
|
(protectedSourcePath) =>
|
|
protectedSourcePath === cleanupPath.trashPath ||
|
|
isPathInside(cleanupPath.trashPath, protectedSourcePath),
|
|
),
|
|
);
|
|
const ownedBySurvivor =
|
|
isPathOwnedBySurvivingAgent(
|
|
nextConfig,
|
|
agentId,
|
|
cleanupPath.path,
|
|
refreshedDatabaseFilePaths,
|
|
) ||
|
|
(cleanupPathCovers(cleanupPath, deleteResult.agentDir, agentDirRegistryPath) &&
|
|
resolveRegisteredAgentIdForDir(deleteResult.agentDir) !== agentId);
|
|
if (blockingProtection || ownedBySurvivor) {
|
|
const terminal = ownedBySurvivor || blockingProtection?.terminal === true;
|
|
const note = ownedBySurvivor
|
|
? "replacement owned by a surviving agent"
|
|
: blockingProtection?.note;
|
|
if (terminal) {
|
|
markCleanupPathDone(cleanupPath, note ?? "protected replacement preserved");
|
|
}
|
|
protectedCleanupPaths.push({
|
|
cleanupPath,
|
|
protectAliases: blockingProtection?.protectAliases ?? false,
|
|
terminal,
|
|
note,
|
|
});
|
|
continue;
|
|
}
|
|
const outcome = cleanupPath.preparationError
|
|
? cleanupFailure(cleanupPath.path, cleanupPath.preparationError)
|
|
: await removeAgentPath(cleanupPath);
|
|
outcomes.push({
|
|
cleanupPath,
|
|
outcome,
|
|
});
|
|
if ("removed" in outcome) {
|
|
markCleanupPathDone(cleanupPath);
|
|
} else if ("skipped" in outcome) {
|
|
markCleanupPathDone(cleanupPath, outcome.skipped.reason);
|
|
protectedCleanupPaths.push({
|
|
cleanupPath,
|
|
protectAliases: true,
|
|
terminal: true,
|
|
note: outcome.skipped.reason,
|
|
});
|
|
} else {
|
|
protectedCleanupPaths.push({
|
|
cleanupPath,
|
|
protectAliases: true,
|
|
terminal: false,
|
|
});
|
|
}
|
|
}
|
|
for (const { outcome } of outcomes) {
|
|
if ("removed" in outcome) {
|
|
removed.push(outcome.removed);
|
|
} else if ("failed" in outcome) {
|
|
failed.push(outcome.failed);
|
|
}
|
|
}
|
|
if (
|
|
workspaceCleanupPaths.length > 0 &&
|
|
workspaceCleanupPaths.every((cleanupPath) => completedCleanupPaths.has(cleanupPath)) &&
|
|
legacyPlan &&
|
|
statePlan
|
|
) {
|
|
try {
|
|
await removeLegacyWorkspaceStateForReset(legacyPlan);
|
|
deleteWorkspaceState(statePlan);
|
|
} catch {
|
|
// Best-effort cleanup. A later explicit reset can remove stale rows.
|
|
}
|
|
}
|
|
const agentDirCleanupPaths = cleanupPaths.filter((cleanupPath) =>
|
|
cleanupPathCovers(cleanupPath, deleteResult.agentDir, agentDirRegistryPath),
|
|
);
|
|
if (
|
|
agentDirCleanupPaths.length > 0 &&
|
|
agentDirCleanupPaths.every((cleanupPath) => completedCleanupPaths.has(cleanupPath))
|
|
) {
|
|
unregisterResolvedAgentDir({ agentId, agentDir: agentDirRegistryPath });
|
|
}
|
|
}
|
|
if (failed.length === 0) {
|
|
unregisterResolvedAgentDir({ agentId, agentDir: agentDirRegistryPath });
|
|
if (deleteFiles) {
|
|
unregisterAgentDeleteDatabases(agentId, databasePlan?.registrationPaths ?? []);
|
|
}
|
|
deletion.finish();
|
|
}
|
|
return {
|
|
ok: true,
|
|
agentId,
|
|
removedBindings: deleteResult.removedBindings,
|
|
removed,
|
|
failed,
|
|
};
|
|
});
|
|
respond(true, result, undefined);
|
|
} catch (error) {
|
|
if (error instanceof AgentConfigPreconditionError) {
|
|
respondAgentNotFound(respond, agentId);
|
|
return;
|
|
}
|
|
throw error;
|
|
}
|
|
},
|
|
"agents.files.list": async ({ params, respond, context }) => {
|
|
if (!validateAgentsFilesListParams(params)) {
|
|
respondInvalidMethodParams(
|
|
respond,
|
|
"agents.files.list",
|
|
validateAgentsFilesListParams.errors,
|
|
);
|
|
return;
|
|
}
|
|
const cfg = context.getRuntimeConfig();
|
|
const agentId = resolveAgentIdOrError(params.agentId, cfg);
|
|
if (!agentId) {
|
|
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown agent id"));
|
|
return;
|
|
}
|
|
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
|
|
let hideBootstrap = false;
|
|
try {
|
|
hideBootstrap = await agentsHandlerDeps.isWorkspaceSetupCompleted(workspaceDir);
|
|
} catch {
|
|
// Fall back to showing BOOTSTRAP if workspace state cannot be read.
|
|
}
|
|
const files = await listAgentFiles(workspaceDir, { hideBootstrap });
|
|
respond(true, { agentId, workspace: workspaceDir, files }, undefined);
|
|
},
|
|
"agents.files.get": async ({ params, respond, context }) => {
|
|
if (!validateAgentsFilesGetParams(params)) {
|
|
respondInvalidMethodParams(respond, "agents.files.get", validateAgentsFilesGetParams.errors);
|
|
return;
|
|
}
|
|
const resolved = resolveAgentWorkspaceFileOrRespondError(
|
|
params,
|
|
respond,
|
|
context.getRuntimeConfig(),
|
|
);
|
|
if (!resolved) {
|
|
return;
|
|
}
|
|
const { agentId, workspaceDir, name } = resolved;
|
|
const filePath = path.join(workspaceDir, name);
|
|
let safeRead: ReadResult;
|
|
try {
|
|
const workspaceRoot = await agentsHandlerDeps.root(workspaceDir);
|
|
safeRead = await workspaceRoot.read(name, {
|
|
hardlinks: "reject",
|
|
nonBlockingRead: true,
|
|
});
|
|
} catch (err) {
|
|
if (isMissingPathError(err)) {
|
|
respondWorkspaceFileMissing({ respond, agentId, workspaceDir, name, filePath });
|
|
return;
|
|
}
|
|
if (err instanceof FsSafeError) {
|
|
respondWorkspaceFileUnsafe(respond, name);
|
|
return;
|
|
}
|
|
throw err;
|
|
}
|
|
respond(
|
|
true,
|
|
{
|
|
agentId,
|
|
workspace: workspaceDir,
|
|
file: {
|
|
name,
|
|
path: filePath,
|
|
missing: false,
|
|
size: safeRead.stat.size,
|
|
updatedAtMs: Math.floor(safeRead.stat.mtimeMs),
|
|
content: safeRead.buffer.toString("utf-8"),
|
|
},
|
|
},
|
|
undefined,
|
|
);
|
|
},
|
|
"agents.files.set": async ({ params, respond, context }) => {
|
|
if (!validateAgentsFilesSetParams(params)) {
|
|
respondInvalidMethodParams(respond, "agents.files.set", validateAgentsFilesSetParams.errors);
|
|
return;
|
|
}
|
|
const resolved = resolveAgentWorkspaceFileOrRespondError(
|
|
params,
|
|
respond,
|
|
context.getRuntimeConfig(),
|
|
);
|
|
if (!resolved) {
|
|
return;
|
|
}
|
|
const { agentId, workspaceDir, name } = resolved;
|
|
await fs.mkdir(workspaceDir, { recursive: true });
|
|
const filePath = path.join(workspaceDir, name);
|
|
const content = params.content;
|
|
let workspaceRoot: WorkspaceRoot;
|
|
try {
|
|
workspaceRoot = await agentsHandlerDeps.root(workspaceDir);
|
|
await workspaceRoot.write(name, content, { encoding: "utf8" });
|
|
} catch (err) {
|
|
if (!(err instanceof FsSafeError)) {
|
|
throw err;
|
|
}
|
|
respondWorkspaceFileUnsafe(respond, name);
|
|
return;
|
|
}
|
|
const meta = await statWorkspaceFileSafely(workspaceRoot, workspaceDir, name);
|
|
respond(
|
|
true,
|
|
{
|
|
ok: true,
|
|
agentId,
|
|
workspace: workspaceDir,
|
|
file: {
|
|
name,
|
|
path: filePath,
|
|
missing: false,
|
|
size: meta?.size,
|
|
updatedAtMs: meta?.updatedAtMs,
|
|
content,
|
|
},
|
|
},
|
|
undefined,
|
|
);
|
|
},
|
|
};
|
|
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|