739 lines
22 KiB
TypeScript
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,
|
|
);
|
|
},
|
|
};
|