// Agents gateway methods expose agent listing, config mutation, workspace file // reads/writes, identity merging, and safe deletion for operator clients. import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { normalizeOptionalString as resolveOptionalStringParam } from "@openclaw/normalization-core/string-coerce"; import { GATEWAY_CLIENT_CAPS, GATEWAY_CLIENT_IDS, hasGatewayClientCap, } from "../../../packages/gateway-protocol/src/client-info.js"; import { ErrorCodes, errorShape, validateAgentsCreateParams, validateAgentsDeleteParams, validateAgentsFilesGetParams, validateAgentsFilesListParams, validateAgentsFilesSetParams, validateAgentsListParams, validateAgentsUpdateParams, } from "../../../packages/gateway-protocol/src/index.js"; import type { AgentsDeleteResult } from "../../../packages/gateway-protocol/src/schema/agents-models-skills.js"; import { createAgent } from "../../agents/agent-create.js"; import { AgentSharedStoreOwnerError, assertAgentSessionStoreDeletionSafe, isPathOwnedBySurvivingAgent, prepareAgentDeleteDatabases, readAgentDeleteDatabaseRegistry, resolveSurvivingDatabaseFilePaths, type AgentDeleteDatabasePlan, } from "../../agents/agent-delete-databases.js"; import { formatSharedAuthStoreOwnerDeleteError, isInheritedAuthStoreOwner, isSharedAuthStoreOwner, } from "../../agents/agent-delete-safety.js"; import { normalizeAgentDirRegistryPath, registerResolvedAgentDir, resolveRegisteredAgentIdForDir, unregisterResolvedAgentDir, } from "../../agents/agent-dir-registry.js"; import { AgentDeletionAuthorityRollbackError, AgentDeletionCommitUncertainError, withAgentDeletion, claimCompletedAgentDeletion, } from "../../agents/agent-lifecycle-registry.js"; import { listAgentIds, resolveAgentDir, resolveAgentWorkspaceDir, tryResolveSoleAgentId, } from "../../agents/agent-scope.js"; import { resolveSharedAuthStoreOwnership, resolveSharedAuthStorePath, } from "../../agents/auth-profiles/path-resolve.js"; import { resolveAuthProfileDatabasePath } from "../../agents/auth-profiles/sqlite.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 { trashAllowedRoots } from "../../commands/cleanup-utils.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 { movePathToTrash } from "../../plugin-sdk/browser-maintenance.js"; import { normalizeAgentIdStrict } from "../../routing/session-key.js"; import { readAgentDeletionJournal, type AgentDeletionJournalCleanupPath, } from "../../state/agent-deletion-journal.js"; import { unregisterOpenClawAgentDatabase } from "../../state/openclaw-agent-db-registry.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"; import { assertValidParams } from "./validation.js"; import { enqueueWorkspaceFileUpdate } from "./workspace-fs.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(WORKSPACE_BOOTSTRAP_FILENAMES); function resolveAgentWorkspaceFileOrRespondError( params: Record, 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) { respondAgentNotFound(respond, String(rawAgentId)); 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>; 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[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 { 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 { 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 normalized = normalizeAgentIdStrict(agentIdRaw); if (!normalized.ok) { return null; } const agentId = normalized.value; const allowed = new Set(listAgentIds(cfg)); if (!allowed.has(agentId)) { return null; } return agentId; } function respondAgentNotFound(respond: RespondFn, agentId: string): void { respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, `agent "${agentId}" not found`)); } type AgentDeleteRemovedPath = NonNullable[number]; type AgentDeleteFailedPath = NonNullable[number]; type AgentDeletePathOutcome = | { removed: AgentDeleteRemovedPath } | { skipped: AgentDeleteFailedPath } | { failed: AgentDeleteFailedPath }; class AgentCleanupIdentityMismatchError extends Error {} class AgentSharedAuthStoreOwnerError extends Error {} function agentOwnsSharedAuthStore(cfg: OpenClawConfig, agentId: string): boolean { const agentDir = resolveAgentDir(cfg, agentId); return isSharedAuthStoreOwner({ ownership: resolveSharedAuthStoreOwnership(), agentAuthDbPath: resolveAuthProfileDatabasePath(agentDir), sharedAuthDbPath: resolveSharedAuthStorePath(), }); } 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) { // The journal fence blocks legitimate claims on prepared-absent paths, so a // file that appeared here is leaked deleted-agent state (recreated WAL // sidecars, runtime home rewrites). Adopt it and sweep it; preserving it // cascades ancestor protection and finishes over a surviving tree. cleanupPath.preparedIdentity = identity; } 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, assertCurrent: () => void, ): Promise { 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. assertCurrent(); // statAgentCleanupPath verified the declared parent; fs-safe's default roots (home/tmp) // alone refuse every path of a volume-backed state dir. Keep those defaults so the // directory behind a workspace symlink stays fenced exactly as shipped, while the link // itself may always move (accepted edge: a link target beside its link is trashed too). await movePathToTrash(trashPath, { allowedRoots: [ ...trashAllowedRoots( cleanupPath.sourcePaths, cleanupPath.kind === "symlink" ? cleanupPath.canonicalPath : undefined, ), os.homedir(), os.tmpdir(), ], }); 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 { 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> | 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 { const uniquePaths = new Map(); 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> | 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)) ); } 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 { 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; } function hashWorkspaceFileContent(content: Buffer | string): string { return createHash("sha256").update(content).digest("hex"); } async function readWorkspaceFileHash( workspaceRoot: WorkspaceRoot, name: string, ): Promise { try { const safeRead = await workspaceRoot.read(name, { hardlinks: "reject", nonBlockingRead: true, }); return hashWorkspaceFileContent(safeRead.buffer); } catch (err) { if (isMissingPathError(err)) { return undefined; } throw err; } } function respondWorkspaceFileConflict( respond: RespondFn, name: string, currentHash: string | undefined, ) { respond( false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, `agent file "${name}" changed since it was read`, { details: { type: "agent_file_conflict", name, ...(currentHash ? { currentHash } : {}), }, }), ); } async function readWorkspaceFileContent( workspaceDir: string, name: string, ): Promise { 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 { 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 { 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 (!assertValidParams(params, validateAgentsListParams, "agents.list", respond)) { return; } const cfg = context.getRuntimeConfig(); const modelCatalogByAgentId = new Map( await Promise.all( listAgentIds(cfg).map( async (agentId) => [agentId, await readPreparedServerMethodModelCatalog(context, { agentId })] as const, ), ), ); respond( true, listAgentsForGateway(cfg, undefined, { modelCatalogByAgentId, includeSystem: hasGatewayClientCap(client?.connect.caps, GATEWAY_CLIENT_CAPS.AGENT_KIND), httpAvatarBasePath: client?.connect.client.id === GATEWAY_CLIENT_IDS.CONTROL_UI ? (cfg.gateway?.controlUi?.basePath ?? "") : undefined, }), undefined, ); }, "agents.create": async ({ params, respond }) => { if (!assertValidParams(params, validateAgentsCreateParams, "agents.create", respond)) { 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 (!assertValidParams(params, validateAgentsUpdateParams, "agents.update", respond)) { return; } const cfg = context.getRuntimeConfig(); const normalized = normalizeAgentIdStrict(params.agentId); if (!normalized.ok) { respondAgentNotFound(respond, params.agentId); return; } const agentId = normalized.value; 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[0] = { agentId, ...(safeName ? { name: safeName } : {}), ...(workspaceDir ? { workspace: workspaceDir } : {}), ...(model !== undefined ? { model } : {}), ...(identity ? { identity } : {}), }; const nextConfig = applyAgentConfig(cfg, agentConfigUpdate); let ensuredWorkspace: Awaited> | 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 (!assertValidParams(params, validateAgentsDeleteParams, "agents.delete", respond)) { return; } const cfg = context.getRuntimeConfig(); const normalized = normalizeAgentIdStrict(params.agentId); if (!normalized.ok) { respondAgentNotFound(respond, params.agentId); return; } const agentId = normalized.value; if (agentOwnsSharedAuthStore(cfg, agentId)) { respond( false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, formatSharedAuthStoreOwnerDeleteError(agentId)), ); return; } const existingJournal = readAgentDeletionJournal(agentId); if ( !isConfiguredAgent(cfg, agentId) && (!existingJournal || existingJournal.cleanupCompleted) ) { respondAgentNotFound(respond, agentId); return; } if (agentId === tryResolveSoleAgentId(cfg)) { respond( false, undefined, errorShape( ErrorCodes.INVALID_REQUEST, `Agent "${agentId}" is the only configured agent and cannot be deleted.`, ), ); return; } if (isInheritedAuthStoreOwner(cfg, agentId)) { respond( false, undefined, errorShape( ErrorCodes.INVALID_REQUEST, `Agent "${agentId}" owns inherited credentials through agents.defaults.authInheritance.agentId and cannot be deleted. Relocate those credentials, then re-point or remove that binding before retrying.`, ), ); return; } const requestedDeleteFiles = typeof params.deleteFiles === "boolean" ? params.deleteFiles : true; try { const result = await withAgentDeletion(agentId, async (begin) => withConfigMutationExclusive(async (lockedConfig) => { assertAgentSessionStoreDeletionSafe(lockedConfig, agentId); let lockedJournal = readAgentDeletionJournal(agentId); const configured = isConfiguredAgent(lockedConfig, agentId); if (agentOwnsSharedAuthStore(lockedConfig, agentId)) { throw new AgentSharedAuthStoreOwnerError( formatSharedAuthStoreOwnerDeleteError(agentId), ); } if (!configured && (!lockedJournal || lockedJournal.cleanupCompleted)) { throw new AgentConfigPreconditionError(`agent "${agentId}" not found`); } if (agentId === tryResolveSoleAgentId(lockedConfig)) { throw new AgentConfigPreconditionError( `agent "${agentId}" is the only configured agent`, ); } if (isInheritedAuthStoreOwner(lockedConfig, agentId)) { throw new AgentConfigPreconditionError( `agent "${agentId}" owns agents.defaults.authInheritance.agentId; relocate credentials and re-point it 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 = begin( lockedJournal ?? { agentId, agentDir: resolveAgentDir(lockedConfig, agentId), workspaceDir: resolveAgentWorkspaceDir(lockedConfig, agentId), sessionsDir: resolveSessionTranscriptsDirForAgent(agentId), deleteFiles, }, ); const journal = deletion.entry; let rosterCommitted = !configured; let committed: Awaited> | undefined; let databasePlan: AgentDeleteDatabasePlan | undefined; try { prepareJournaledAgentDirOwnership(lockedConfig, agentId, journal.agentDir); databasePlan = await prepareAgentDeleteDatabases( lockedConfig, agentId, journal.agentDir, ); deletion.assertCurrent(); 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 () => { deletion.assertCurrent(); if (!rosterCommitted) { try { committed = await deleteAgentConfigEntry({ agentId, assertCurrent: deletion.assertCurrent, }); } 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.assertCurrent(); } 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); const purgeFailed = await purgeAgentSessionStoreEntries(lockedConfig, agentId, { runDatabaseCleanup: deletion.runDatabaseCleanup, }); deletion.assertCurrent(); const removed: AgentDeleteRemovedPath[] = []; const failed: AgentDeleteFailedPath[] = []; if (deleteFiles && !purgeFailed) { const survivingDatabaseFilePaths = resolveSurvivingDatabaseFilePaths( readAgentDeleteDatabaseRegistry(), 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) { deletion.assertCurrent(); 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( readAgentDeleteDatabaseRegistry(), 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, deletion.assertCurrent); 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, { assertCurrent: deletion.assertCurrent, }); deletion.assertCurrent(); await deleteWorkspaceState(statePlan, { assertCurrent: deletion.assertCurrent }); } catch { // Best-effort cleanup. A later explicit reset can remove stale rows. } } deletion.assertCurrent(); const agentDirCleanupPaths = cleanupPaths.filter((cleanupPath) => cleanupPathCovers(cleanupPath, deleteResult.agentDir, agentDirRegistryPath), ); if ( agentDirCleanupPaths.length > 0 && agentDirCleanupPaths.every((cleanupPath) => completedCleanupPaths.has(cleanupPath)) ) { unregisterResolvedAgentDir({ agentId, agentDir: agentDirRegistryPath }); } } deletion.assertCurrent(); if (failed.length === 0 && !purgeFailed) { unregisterResolvedAgentDir({ agentId, agentDir: agentDirRegistryPath }); if (deleteFiles) { unregisterAgentDeleteDatabases(agentId, databasePlan?.registrationPaths ?? []); } deletion.finish(); } return { ok: true, agentId, removedBindings: deleteResult.removedBindings, removed, failed, ...(purgeFailed ? { purgeFailed: true as const } : {}), }; }), ); respond(true, result, undefined); } catch (error) { if ( error instanceof AgentSharedAuthStoreOwnerError || error instanceof AgentSharedStoreOwnerError ) { respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, error.message)); return; } if (error instanceof AgentConfigPreconditionError) { respondAgentNotFound(respond, agentId); return; } throw error; } }, "agents.files.list": async ({ params, respond, context }) => { if (!assertValidParams(params, validateAgentsFilesListParams, "agents.files.list", respond)) { return; } const cfg = context.getRuntimeConfig(); const agentId = resolveAgentIdOrError(params.agentId, cfg); if (!agentId) { respondAgentNotFound(respond, params.agentId); 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 (!assertValidParams(params, validateAgentsFilesGetParams, "agents.files.get", respond)) { 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), hash: hashWorkspaceFileContent(safeRead.buffer), content: safeRead.buffer.toString("utf-8"), }, }, undefined, ); }, "agents.files.set": async ({ params, respond, context }) => { if (!assertValidParams(params, validateAgentsFilesSetParams, "agents.files.set", respond)) { 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; let conflict: { currentHash: string | undefined } | undefined; try { workspaceRoot = await agentsHandlerDeps.root(workspaceDir); const writeRoot = workspaceRoot; const expectedHash = params.expectedHash?.toLowerCase(); conflict = await enqueueWorkspaceFileUpdate(async () => { if (expectedHash) { const currentHash = await readWorkspaceFileHash(writeRoot, name); if (currentHash !== expectedHash) { return { currentHash }; } } await writeRoot.write(name, content, { encoding: "utf8" }); return undefined; }); } catch (err) { if (!(err instanceof FsSafeError)) { throw err; } respondWorkspaceFileUnsafe(respond, name); return; } if (conflict) { respondWorkspaceFileConflict(respond, name, conflict.currentHash); 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, hash: hashWorkspaceFileContent(content), content, }, }, undefined, ); }, }; /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */