Files
Moltbot/src/gateway/server-methods/agents.ts
2026-02-26 00:31:08 +01:00

739 lines
22 KiB
TypeScript

import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import {
listAgentIds,
resolveAgentDir,
resolveAgentWorkspaceDir,
} from "../../agents/agent-scope.js";
import {
DEFAULT_AGENTS_FILENAME,
DEFAULT_BOOTSTRAP_FILENAME,
DEFAULT_HEARTBEAT_FILENAME,
DEFAULT_IDENTITY_FILENAME,
DEFAULT_MEMORY_ALT_FILENAME,
DEFAULT_MEMORY_FILENAME,
DEFAULT_SOUL_FILENAME,
DEFAULT_TOOLS_FILENAME,
DEFAULT_USER_FILENAME,
ensureAgentWorkspace,
isWorkspaceOnboardingCompleted,
} from "../../agents/workspace.js";
import { movePathToTrash } from "../../browser/trash.js";
import {
applyAgentConfig,
findAgentEntryIndex,
listAgentEntries,
pruneAgentConfig,
} from "../../commands/agents.config.js";
import { loadConfig, writeConfigFile } from "../../config/config.js";
import { resolveSessionTranscriptsDirForAgent } from "../../config/sessions/paths.js";
import { sameFileIdentity } from "../../infra/file-identity.js";
import { SafeOpenError, readLocalFileSafely } from "../../infra/fs-safe.js";
import { isNotFoundPathError, isPathInside } from "../../infra/path-guards.js";
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../../routing/session-key.js";
import { resolveUserPath } from "../../utils.js";
import {
ErrorCodes,
errorShape,
formatValidationErrors,
validateAgentsCreateParams,
validateAgentsDeleteParams,
validateAgentsFilesGetParams,
validateAgentsFilesListParams,
validateAgentsFilesSetParams,
validateAgentsListParams,
validateAgentsUpdateParams,
} from "../protocol/index.js";
import { listAgentsForGateway } from "../session-utils.js";
import type { GatewayRequestHandlers, RespondFn } from "./types.js";
const BOOTSTRAP_FILE_NAMES = [
DEFAULT_AGENTS_FILENAME,
DEFAULT_SOUL_FILENAME,
DEFAULT_TOOLS_FILENAME,
DEFAULT_IDENTITY_FILENAME,
DEFAULT_USER_FILENAME,
DEFAULT_HEARTBEAT_FILENAME,
DEFAULT_BOOTSTRAP_FILENAME,
] as const;
const BOOTSTRAP_FILE_NAMES_POST_ONBOARDING = BOOTSTRAP_FILE_NAMES.filter(
(name) => name !== DEFAULT_BOOTSTRAP_FILENAME,
);
const MEMORY_FILE_NAMES = [DEFAULT_MEMORY_FILENAME, DEFAULT_MEMORY_ALT_FILENAME] as const;
const ALLOWED_FILE_NAMES = new Set<string>([...BOOTSTRAP_FILE_NAMES, ...MEMORY_FILE_NAMES]);
function resolveAgentWorkspaceFileOrRespondError(
params: Record<string, unknown>,
respond: RespondFn,
): {
cfg: ReturnType<typeof loadConfig>;
agentId: string;
workspaceDir: string;
name: string;
} | null {
const cfg = loadConfig();
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 ResolvedAgentWorkspaceFilePath =
| {
kind: "ready";
requestPath: string;
ioPath: string;
workspaceReal: string;
}
| {
kind: "missing";
requestPath: string;
ioPath: string;
workspaceReal: string;
}
| {
kind: "invalid";
requestPath: string;
reason: string;
};
const SUPPORTS_NOFOLLOW = process.platform !== "win32" && "O_NOFOLLOW" in fsConstants;
const OPEN_WRITE_FLAGS =
fsConstants.O_WRONLY |
fsConstants.O_CREAT |
fsConstants.O_TRUNC |
(SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0);
async function resolveWorkspaceRealPath(workspaceDir: string): Promise<string> {
try {
return await fs.realpath(workspaceDir);
} catch {
return path.resolve(workspaceDir);
}
}
async function resolveAgentWorkspaceFilePath(params: {
workspaceDir: string;
name: string;
allowMissing: boolean;
}): Promise<ResolvedAgentWorkspaceFilePath> {
const requestPath = path.join(params.workspaceDir, params.name);
const workspaceReal = await resolveWorkspaceRealPath(params.workspaceDir);
const candidatePath = path.resolve(workspaceReal, params.name);
if (!isPathInside(workspaceReal, candidatePath)) {
return { kind: "invalid", requestPath, reason: "path escapes workspace root" };
}
let candidateLstat: Awaited<ReturnType<typeof fs.lstat>>;
try {
candidateLstat = await fs.lstat(candidatePath);
} catch (err) {
if (isNotFoundPathError(err)) {
if (params.allowMissing) {
return { kind: "missing", requestPath, ioPath: candidatePath, workspaceReal };
}
return { kind: "invalid", requestPath, reason: "file not found" };
}
throw err;
}
if (candidateLstat.isSymbolicLink()) {
let targetReal: string;
try {
targetReal = await fs.realpath(candidatePath);
} catch (err) {
if (isNotFoundPathError(err)) {
if (params.allowMissing) {
return { kind: "missing", requestPath, ioPath: candidatePath, workspaceReal };
}
return { kind: "invalid", requestPath, reason: "symlink target not found" };
}
throw err;
}
if (!isPathInside(workspaceReal, targetReal)) {
return { kind: "invalid", requestPath, reason: "symlink target escapes workspace root" };
}
try {
const targetStat = await fs.stat(targetReal);
if (!targetStat.isFile()) {
return { kind: "invalid", requestPath, reason: "symlink target is not a file" };
}
} catch (err) {
if (isNotFoundPathError(err) && params.allowMissing) {
return { kind: "missing", requestPath, ioPath: targetReal, workspaceReal };
}
throw err;
}
return { kind: "ready", requestPath, ioPath: targetReal, workspaceReal };
}
if (!candidateLstat.isFile()) {
return { kind: "invalid", requestPath, reason: "path is not a regular file" };
}
const candidateReal = await fs.realpath(candidatePath).catch(() => candidatePath);
if (!isPathInside(workspaceReal, candidateReal)) {
return { kind: "invalid", requestPath, reason: "resolved file escapes workspace root" };
}
return { kind: "ready", requestPath, ioPath: candidateReal, workspaceReal };
}
async function statFileSafely(filePath: string): Promise<FileMeta | null> {
try {
const [stat, lstat] = await Promise.all([fs.stat(filePath), fs.lstat(filePath)]);
if (lstat.isSymbolicLink() || !stat.isFile()) {
return null;
}
if (!sameFileIdentity(stat, lstat)) {
return null;
}
return {
size: stat.size,
updatedAtMs: Math.floor(stat.mtimeMs),
};
} catch {
return null;
}
}
async function writeFileSafely(filePath: string, content: string): Promise<void> {
const handle = await fs.open(filePath, OPEN_WRITE_FLAGS, 0o600);
try {
const [stat, lstat] = await Promise.all([handle.stat(), fs.lstat(filePath)]);
if (lstat.isSymbolicLink() || !stat.isFile()) {
throw new Error("unsafe file path");
}
if (!sameFileIdentity(stat, lstat)) {
throw new Error("path changed during write");
}
await handle.writeFile(content, "utf-8");
} finally {
await handle.close().catch(() => {});
}
}
async function listAgentFiles(workspaceDir: string, options?: { hideBootstrap?: boolean }) {
const files: Array<{
name: string;
path: string;
missing: boolean;
size?: number;
updatedAtMs?: number;
}> = [];
const bootstrapFileNames = options?.hideBootstrap
? BOOTSTRAP_FILE_NAMES_POST_ONBOARDING
: BOOTSTRAP_FILE_NAMES;
for (const name of bootstrapFileNames) {
const resolved = await resolveAgentWorkspaceFilePath({
workspaceDir,
name,
allowMissing: true,
});
const filePath = resolved.requestPath;
const meta =
resolved.kind === "ready"
? await statFileSafely(resolved.ioPath)
: resolved.kind === "missing"
? null
: null;
if (meta) {
files.push({
name,
path: filePath,
missing: false,
size: meta.size,
updatedAtMs: meta.updatedAtMs,
});
} else {
files.push({ name, path: filePath, missing: true });
}
}
const primaryResolved = await resolveAgentWorkspaceFilePath({
workspaceDir,
name: DEFAULT_MEMORY_FILENAME,
allowMissing: true,
});
const primaryMeta =
primaryResolved.kind === "ready" ? await statFileSafely(primaryResolved.ioPath) : null;
if (primaryMeta) {
files.push({
name: DEFAULT_MEMORY_FILENAME,
path: primaryResolved.requestPath,
missing: false,
size: primaryMeta.size,
updatedAtMs: primaryMeta.updatedAtMs,
});
} else {
const altMemoryResolved = await resolveAgentWorkspaceFilePath({
workspaceDir,
name: DEFAULT_MEMORY_ALT_FILENAME,
allowMissing: true,
});
const altMeta =
altMemoryResolved.kind === "ready" ? await statFileSafely(altMemoryResolved.ioPath) : null;
if (altMeta) {
files.push({
name: DEFAULT_MEMORY_ALT_FILENAME,
path: altMemoryResolved.requestPath,
missing: false,
size: altMeta.size,
updatedAtMs: altMeta.updatedAtMs,
});
} else {
files.push({
name: DEFAULT_MEMORY_FILENAME,
path: primaryResolved.requestPath,
missing: true,
});
}
}
return files;
}
function resolveAgentIdOrError(agentIdRaw: string, cfg: ReturnType<typeof loadConfig>) {
const agentId = normalizeAgentId(agentIdRaw);
const allowed = new Set(listAgentIds(cfg));
if (!allowed.has(agentId)) {
return null;
}
return agentId;
}
function sanitizeIdentityLine(value: string): string {
return value.replace(/\s+/g, " ").trim();
}
function resolveOptionalStringParam(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
async function moveToTrashBestEffort(pathname: string): Promise<void> {
if (!pathname) {
return;
}
try {
await fs.access(pathname);
} catch {
return;
}
try {
await movePathToTrash(pathname);
} catch {
// Best-effort: path may already be gone or trash unavailable.
}
}
export const agentsHandlers: GatewayRequestHandlers = {
"agents.list": ({ params, respond }) => {
if (!validateAgentsListParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid agents.list params: ${formatValidationErrors(validateAgentsListParams.errors)}`,
),
);
return;
}
const cfg = loadConfig();
const result = listAgentsForGateway(cfg);
respond(true, result, undefined);
},
"agents.create": async ({ params, respond }) => {
if (!validateAgentsCreateParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid agents.create params: ${formatValidationErrors(
validateAgentsCreateParams.errors,
)}`,
),
);
return;
}
const cfg = loadConfig();
const rawName = String(params.name ?? "").trim();
const agentId = normalizeAgentId(rawName);
if (agentId === DEFAULT_AGENT_ID) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `"${DEFAULT_AGENT_ID}" is reserved`),
);
return;
}
if (findAgentEntryIndex(listAgentEntries(cfg), agentId) >= 0) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `agent "${agentId}" already exists`),
);
return;
}
const workspaceDir = resolveUserPath(String(params.workspace ?? "").trim());
// Resolve agentDir against the config we're about to persist (vs the pre-write config),
// so subsequent resolutions can't disagree about the agent's directory.
let nextConfig = applyAgentConfig(cfg, {
agentId,
name: rawName,
workspace: workspaceDir,
});
const agentDir = resolveAgentDir(nextConfig, agentId);
nextConfig = applyAgentConfig(nextConfig, { agentId, agentDir });
// Ensure workspace & transcripts exist BEFORE writing config so a failure
// here does not leave a broken config entry behind.
const skipBootstrap = Boolean(nextConfig.agents?.defaults?.skipBootstrap);
await ensureAgentWorkspace({ dir: workspaceDir, ensureBootstrapFiles: !skipBootstrap });
await fs.mkdir(resolveSessionTranscriptsDirForAgent(agentId), { recursive: true });
await writeConfigFile(nextConfig);
// Always write Name to IDENTITY.md; optionally include emoji/avatar.
const safeName = sanitizeIdentityLine(rawName);
const emoji = resolveOptionalStringParam(params.emoji);
const avatar = resolveOptionalStringParam(params.avatar);
const identityPath = path.join(workspaceDir, DEFAULT_IDENTITY_FILENAME);
const lines = [
"",
`- Name: ${safeName}`,
...(emoji ? [`- Emoji: ${sanitizeIdentityLine(emoji)}`] : []),
...(avatar ? [`- Avatar: ${sanitizeIdentityLine(avatar)}`] : []),
"",
];
await fs.appendFile(identityPath, lines.join("\n"), "utf-8");
respond(true, { ok: true, agentId, name: rawName, workspace: workspaceDir }, undefined);
},
"agents.update": async ({ params, respond }) => {
if (!validateAgentsUpdateParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid agents.update params: ${formatValidationErrors(
validateAgentsUpdateParams.errors,
)}`,
),
);
return;
}
const cfg = loadConfig();
const agentId = normalizeAgentId(String(params.agentId ?? ""));
if (findAgentEntryIndex(listAgentEntries(cfg), agentId) < 0) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `agent "${agentId}" not found`),
);
return;
}
const workspaceDir =
typeof params.workspace === "string" && params.workspace.trim()
? resolveUserPath(params.workspace.trim())
: undefined;
const model = resolveOptionalStringParam(params.model);
const avatar = resolveOptionalStringParam(params.avatar);
const nextConfig = applyAgentConfig(cfg, {
agentId,
...(typeof params.name === "string" && params.name.trim()
? { name: params.name.trim() }
: {}),
...(workspaceDir ? { workspace: workspaceDir } : {}),
...(model ? { model } : {}),
});
await writeConfigFile(nextConfig);
if (workspaceDir) {
const skipBootstrap = Boolean(nextConfig.agents?.defaults?.skipBootstrap);
await ensureAgentWorkspace({ dir: workspaceDir, ensureBootstrapFiles: !skipBootstrap });
}
if (avatar) {
const workspace = workspaceDir ?? resolveAgentWorkspaceDir(nextConfig, agentId);
await fs.mkdir(workspace, { recursive: true });
const identityPath = path.join(workspace, DEFAULT_IDENTITY_FILENAME);
await fs.appendFile(identityPath, `\n- Avatar: ${sanitizeIdentityLine(avatar)}\n`, "utf-8");
}
respond(true, { ok: true, agentId }, undefined);
},
"agents.delete": async ({ params, respond }) => {
if (!validateAgentsDeleteParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid agents.delete params: ${formatValidationErrors(
validateAgentsDeleteParams.errors,
)}`,
),
);
return;
}
const cfg = loadConfig();
const agentId = normalizeAgentId(String(params.agentId ?? ""));
if (agentId === DEFAULT_AGENT_ID) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `"${DEFAULT_AGENT_ID}" cannot be deleted`),
);
return;
}
if (findAgentEntryIndex(listAgentEntries(cfg), agentId) < 0) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `agent "${agentId}" not found`),
);
return;
}
const deleteFiles = typeof params.deleteFiles === "boolean" ? params.deleteFiles : true;
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
const agentDir = resolveAgentDir(cfg, agentId);
const sessionsDir = resolveSessionTranscriptsDirForAgent(agentId);
const result = pruneAgentConfig(cfg, agentId);
await writeConfigFile(result.config);
if (deleteFiles) {
await Promise.all([
moveToTrashBestEffort(workspaceDir),
moveToTrashBestEffort(agentDir),
moveToTrashBestEffort(sessionsDir),
]);
}
respond(true, { ok: true, agentId, removedBindings: result.removedBindings }, undefined);
},
"agents.files.list": async ({ params, respond }) => {
if (!validateAgentsFilesListParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid agents.files.list params: ${formatValidationErrors(
validateAgentsFilesListParams.errors,
)}`,
),
);
return;
}
const cfg = loadConfig();
const agentId = resolveAgentIdOrError(String(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 isWorkspaceOnboardingCompleted(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 }) => {
if (!validateAgentsFilesGetParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid agents.files.get params: ${formatValidationErrors(
validateAgentsFilesGetParams.errors,
)}`,
),
);
return;
}
const resolved = resolveAgentWorkspaceFileOrRespondError(params, respond);
if (!resolved) {
return;
}
const { agentId, workspaceDir, name } = resolved;
const filePath = path.join(workspaceDir, name);
const resolvedPath = await resolveAgentWorkspaceFilePath({
workspaceDir,
name,
allowMissing: true,
});
if (resolvedPath.kind === "invalid") {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`unsafe workspace file "${name}" (${resolvedPath.reason})`,
),
);
return;
}
if (resolvedPath.kind === "missing") {
respond(
true,
{
agentId,
workspace: workspaceDir,
file: { name, path: filePath, missing: true },
},
undefined,
);
return;
}
let safeRead: Awaited<ReturnType<typeof readLocalFileSafely>>;
try {
safeRead = await readLocalFileSafely({ filePath: resolvedPath.ioPath });
} catch (err) {
if (err instanceof SafeOpenError && err.code === "not-found") {
respond(
true,
{
agentId,
workspace: workspaceDir,
file: { name, path: filePath, missing: true },
},
undefined,
);
return;
}
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `unsafe workspace file "${name}"`),
);
return;
}
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 }) => {
if (!validateAgentsFilesSetParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid agents.files.set params: ${formatValidationErrors(
validateAgentsFilesSetParams.errors,
)}`,
),
);
return;
}
const resolved = resolveAgentWorkspaceFileOrRespondError(params, respond);
if (!resolved) {
return;
}
const { agentId, workspaceDir, name } = resolved;
await fs.mkdir(workspaceDir, { recursive: true });
const filePath = path.join(workspaceDir, name);
const resolvedPath = await resolveAgentWorkspaceFilePath({
workspaceDir,
name,
allowMissing: true,
});
if (resolvedPath.kind === "invalid") {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`unsafe workspace file "${name}" (${resolvedPath.reason})`,
),
);
return;
}
const content = String(params.content ?? "");
try {
await writeFileSafely(resolvedPath.ioPath, content);
} catch {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `unsafe workspace file "${name}"`),
);
return;
}
const meta = await statFileSafely(resolvedPath.ioPath);
respond(
true,
{
ok: true,
agentId,
workspace: workspaceDir,
file: {
name,
path: filePath,
missing: false,
size: meta?.size,
updatedAtMs: meta?.updatedAtMs,
content,
},
},
undefined,
);
},
};