feat(FN-1085): align agent routing and runtime contracts
- Harden core AgentStore lifecycle behavior and heartbeat runtime integration paths - Align dashboard agent APIs, server routes, and agent UI flows with the updated contract - Tighten CLI agent/message command routing and validate payload handling semantics - Expand test coverage across core, dashboard, engine, and CLI for route, heartbeat, and instruction regressions
This commit is contained in:
@@ -1,7 +1,68 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join, isAbsolute } from "node:path";
|
||||
import { isAbsolute, resolve, relative, normalize, sep } from "node:path";
|
||||
import type { Agent } from "@fusion/core";
|
||||
|
||||
const MAX_INSTRUCTIONS_PATH_LENGTH = 500;
|
||||
const MAX_INSTRUCTIONS_TEXT_LENGTH = 50_000;
|
||||
|
||||
function trimAndClamp(value: string, maxLength: number, label: string, agentId: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
if (trimmed.length <= maxLength) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`[agent-instructions] ${label} exceeded max length for agent ${agentId}; truncating to ${maxLength} chars`,
|
||||
);
|
||||
return trimmed.slice(0, maxLength);
|
||||
}
|
||||
|
||||
function isPathTraversal(path: string): boolean {
|
||||
return path.split(/[\\/]+/).includes("..");
|
||||
}
|
||||
|
||||
function resolveValidatedInstructionsPath(rawPath: string, rootDir: string, agentId: string): string | null {
|
||||
const trimmed = rawPath.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (trimmed.length > MAX_INSTRUCTIONS_PATH_LENGTH) {
|
||||
console.warn(
|
||||
`[agent-instructions] instructionsPath too long for agent ${agentId} (${trimmed.length} > ${MAX_INSTRUCTIONS_PATH_LENGTH})`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!trimmed.toLowerCase().endsWith(".md")) {
|
||||
console.warn(`[agent-instructions] instructionsPath must end in .md for agent ${agentId}: ${trimmed}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isAbsolute(trimmed)) {
|
||||
console.warn(`[agent-instructions] instructionsPath must be project-relative for agent ${agentId}: ${trimmed}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = normalize(trimmed);
|
||||
if (isPathTraversal(normalized)) {
|
||||
console.warn(`[agent-instructions] instructionsPath traversal is not allowed for agent ${agentId}: ${trimmed}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolvedPath = resolve(rootDir, normalized);
|
||||
const rel = relative(rootDir, resolvedPath);
|
||||
if (!rel || rel.startsWith(`..${sep}`) || rel === ".." || isAbsolute(rel)) {
|
||||
console.warn(`[agent-instructions] instructionsPath escapes project root for agent ${agentId}: ${trimmed}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return resolvedPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve custom instructions for an agent by combining inline text and/or
|
||||
* file-based instructions.
|
||||
@@ -20,32 +81,46 @@ export async function resolveAgentInstructions(
|
||||
|
||||
// Inline instructions take first position
|
||||
if (agent.instructionsText?.trim()) {
|
||||
parts.push(agent.instructionsText.trim());
|
||||
const inline = trimAndClamp(
|
||||
agent.instructionsText,
|
||||
MAX_INSTRUCTIONS_TEXT_LENGTH,
|
||||
"instructionsText",
|
||||
agent.id,
|
||||
);
|
||||
if (inline) {
|
||||
parts.push(inline);
|
||||
}
|
||||
}
|
||||
|
||||
// File-based instructions appended after inline text
|
||||
if (agent.instructionsPath?.trim()) {
|
||||
const filePath = isAbsolute(agent.instructionsPath)
|
||||
? agent.instructionsPath
|
||||
: join(rootDir, agent.instructionsPath);
|
||||
const filePath = resolveValidatedInstructionsPath(agent.instructionsPath, rootDir, agent.id);
|
||||
|
||||
try {
|
||||
const content = await readFile(filePath, "utf-8");
|
||||
if (content.trim()) {
|
||||
parts.push(content.trim());
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
// Graceful fallback: file doesn't exist or is unreadable
|
||||
// Log a warning but don't throw — instructionsText is still used
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === "ENOENT") {
|
||||
console.warn(
|
||||
`[agent-instructions] Instructions file not found for agent ${agent.id}: ${filePath}`,
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
`[agent-instructions] Failed to read instructions file for agent ${agent.id}: ${filePath} (${code})`,
|
||||
if (filePath) {
|
||||
try {
|
||||
const content = await readFile(filePath, "utf-8");
|
||||
const normalizedContent = trimAndClamp(
|
||||
content,
|
||||
MAX_INSTRUCTIONS_TEXT_LENGTH,
|
||||
"instructions file content",
|
||||
agent.id,
|
||||
);
|
||||
if (normalizedContent) {
|
||||
parts.push(normalizedContent);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
// Graceful fallback: file doesn't exist or is unreadable
|
||||
// Log a warning but don't throw — instructionsText is still used
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === "ENOENT") {
|
||||
console.warn(
|
||||
`[agent-instructions] Instructions file not found for agent ${agent.id}: ${filePath}`,
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
`[agent-instructions] Failed to read instructions file for agent ${agent.id}: ${filePath} (${code})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user