forked from EduCraft/curriculum-project-hub
feat(hub): workspace 文件工具 + cph 子进程工具(hub↔courseware 耦合点)
agent 的窄工具面补全: - workspace.ts: read_file/write_file/list_files,路径全 confinement 到 ctx.workspaceDir(ADR-0007 目录树);.. 与绝对路径逃逸返 error JSON 不中断 run - cph.ts: cph_check/cph_build 子进程,cwd=workspace;ADR-0016 版本契约由 cph 自身在 load 阶段校验(cphVersionMismatch),Hub 透传诊断不重复校验; CPH_BIN 可配,缺二进制返 exitCode 127 不抛 - server.ts: 注册全部 5 个工具 修 dynamic import(rule: ts-no-dynamic-import)——mkdir 改顶部静态 import; 三个 handler 的 confine 包进 try(操作性错误返 JSON 而非冒泡中断 run)。 tsc rc=0;smoke 通过(逃逸拒绝/读写回环/列举/cph ENOENT)。
This commit is contained in:
@@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* `cph` subprocess tools — the Hub↔Courseware coupling point.
|
||||||
|
*
|
||||||
|
* The Hub agent does not parse engineering-file models in-process; it calls the
|
||||||
|
* stable `cph` CLI (ADR-0016 version contract). `cph check` validates a
|
||||||
|
* project; `cph build` renders a target artifact. Version compatibility is the
|
||||||
|
* CLI's responsibility — it refuses incompatible `.cph-version` files with a
|
||||||
|
* `cphVersionMismatch` error diagnostic (ADR-0016); the Hub merely runs the
|
||||||
|
* subprocess and returns its stdout/stderr/exit to the model.
|
||||||
|
*
|
||||||
|
* The `cph` binary path is configurable (env `CPH_BIN`, default `cph` on
|
||||||
|
* PATH). All subprocesses run with `cwd = ctx.workspaceDir` so paths in
|
||||||
|
* diagnostics are relative to the project root.
|
||||||
|
*/
|
||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import type { RegisteredTool } from "./tools.js";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
const CPH_BIN = process.env["CPH_BIN"] ?? "cph";
|
||||||
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
|
interface ExecResult {
|
||||||
|
readonly exitCode: number;
|
||||||
|
readonly stdout: string;
|
||||||
|
readonly stderr: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runCph(args: readonly string[], workspaceDir: string): Promise<ExecResult> {
|
||||||
|
try {
|
||||||
|
const { stdout, stderr } = await execFileAsync(CPH_BIN, args, {
|
||||||
|
cwd: workspaceDir,
|
||||||
|
timeout: DEFAULT_TIMEOUT_MS,
|
||||||
|
maxBuffer: 10 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
return { exitCode: 0, stdout, stderr };
|
||||||
|
} catch (e) {
|
||||||
|
// execFile rejects on non-zero exit; the error carries stdout/stderr/code.
|
||||||
|
const err = e as NodeJS.ErrnoException & { stdout?: string; stderr?: string; code?: number | string };
|
||||||
|
if (err.code === "ENOENT") {
|
||||||
|
return {
|
||||||
|
exitCode: 127,
|
||||||
|
stdout: "",
|
||||||
|
stderr: `cph binary not found at "${CPH_BIN}" (set CPH_BIN env or install cph on PATH)`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
exitCode: typeof err.code === "number" ? err.code : 1,
|
||||||
|
stdout: err.stdout ?? "",
|
||||||
|
stderr: err.stderr ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CphCheckArgs {
|
||||||
|
readonly path?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `cph check <dir>` — validate a curriculum engineering file. Returns the
|
||||||
|
/// checker's diagnostics (stdout) for the model to act on. Non-zero exit means
|
||||||
|
/// the file has error diagnostics (ADR-0010); the output is still returned,
|
||||||
|
/// not thrown — the model needs to see the diagnostics to fix them.
|
||||||
|
export function cphCheckTool(): RegisteredTool {
|
||||||
|
return {
|
||||||
|
spec: {
|
||||||
|
name: "cph_check",
|
||||||
|
description:
|
||||||
|
"Run `cph check` on the project's curriculum engineering file. Returns diagnostics (7 classes: structure/schema/compile/version…). Non-zero exit means error diagnostics present — read the output to fix them.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
path: { type: "string", description: "Path to the engineering file dir; defaults to the workspace root." },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async execute(args, ctx): Promise<string> {
|
||||||
|
const a = (args as CphCheckArgs) ?? {};
|
||||||
|
const target = a.path ?? ".";
|
||||||
|
const res = await runCph(["check", target], ctx.workspaceDir);
|
||||||
|
return JSON.stringify({
|
||||||
|
exitCode: res.exitCode,
|
||||||
|
stdout: res.stdout,
|
||||||
|
stderr: res.stderr,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CphBuildArgs {
|
||||||
|
readonly target: string;
|
||||||
|
readonly path?: string;
|
||||||
|
readonly output?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `cph build <dir> --target <T> -o <out>` — render a target artifact. The
|
||||||
|
/// target (student/teacher/…) determines the build (ADR-0009). Output path
|
||||||
|
/// is relative to the workspace unless absolute.
|
||||||
|
export function cphBuildTool(): RegisteredTool {
|
||||||
|
return {
|
||||||
|
spec: {
|
||||||
|
name: "cph_build",
|
||||||
|
description:
|
||||||
|
"Run `cph build` to render a curriculum artifact (e.g. student/teacher PDF). Target selects the build; output names the result file.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
target: { type: "string", description: "Build target, e.g. 'student', 'teacher'." },
|
||||||
|
path: { type: "string", description: "Path to the engineering file dir; defaults to the workspace root." },
|
||||||
|
output: { type: "string", description: "Output file path (relative to workspace or absolute)." },
|
||||||
|
},
|
||||||
|
required: ["target"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async execute(args, ctx): Promise<string> {
|
||||||
|
const a = args as CphBuildArgs;
|
||||||
|
const target = a.path ?? ".";
|
||||||
|
const out = a.output ?? `build/${a.target}.pdf`;
|
||||||
|
const res = await runCph(["build", target, "--target", a.target, "-o", out], ctx.workspaceDir);
|
||||||
|
return JSON.stringify({
|
||||||
|
exitCode: res.exitCode,
|
||||||
|
stdout: res.stdout,
|
||||||
|
stderr: res.stderr,
|
||||||
|
output: out,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* Workspace file tools — bounded read/write/list against the project's
|
||||||
|
* curriculum engineering-file tree (ADR-0007 directory tree).
|
||||||
|
*
|
||||||
|
* All paths are **confined to `ctx.workspaceDir`**: the handler resolves the
|
||||||
|
* requested relative path against the workspace root and rejects any path that
|
||||||
|
* escapes it (via `..` or absolute paths). This is the agent's only file
|
||||||
|
* surface — there is no general bash escape hatch, by design (the Hub agent is
|
||||||
|
* narrower than a general "Claude Code").
|
||||||
|
*/
|
||||||
|
import { readFile, writeFile, readdir, mkdir } from "node:fs/promises";
|
||||||
|
import { join, resolve, relative, isAbsolute } from "node:path";
|
||||||
|
import type { RegisteredTool } from "./tools.js";
|
||||||
|
|
||||||
|
/** Thrown when a requested path escapes the project workspace root. */
|
||||||
|
export class PathEscape extends Error {
|
||||||
|
constructor(readonly requested: string, readonly workspaceDir: string) {
|
||||||
|
super(`path escapes workspace: ${requested} (root ${workspaceDir})`);
|
||||||
|
this.name = "PathEscape";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve a tool-supplied path against the workspace root, rejecting escapes. */
|
||||||
|
function confine(requestedPath: string, workspaceDir: string): string {
|
||||||
|
if (isAbsolute(requestedPath)) {
|
||||||
|
// Allow absolute paths only if they're already inside the workspace.
|
||||||
|
const rel = relative(workspaceDir, requestedPath);
|
||||||
|
if (rel.startsWith("..") || rel === "") {
|
||||||
|
throw new PathEscape(requestedPath, workspaceDir);
|
||||||
|
}
|
||||||
|
return requestedPath;
|
||||||
|
}
|
||||||
|
const resolved = resolve(workspaceDir, requestedPath);
|
||||||
|
const rel = relative(workspaceDir, resolved);
|
||||||
|
if (rel.startsWith("..")) {
|
||||||
|
throw new PathEscape(requestedPath, workspaceDir);
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReadFileArgs {
|
||||||
|
readonly path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readFileTool(): RegisteredTool {
|
||||||
|
return {
|
||||||
|
spec: {
|
||||||
|
name: "read_file",
|
||||||
|
description: "Read a file from the project's curriculum engineering-file workspace. Path is relative to the workspace root.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: { path: { type: "string", description: "Relative path within the workspace." } },
|
||||||
|
required: ["path"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async execute(args, ctx): Promise<string> {
|
||||||
|
const a = args as ReadFileArgs;
|
||||||
|
try {
|
||||||
|
const full = confine(a.path, ctx.workspaceDir);
|
||||||
|
return await readFile(full, "utf8");
|
||||||
|
} catch (e) {
|
||||||
|
return JSON.stringify({ error: e instanceof Error ? e.message : String(e) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WriteFileArgs {
|
||||||
|
readonly path: string;
|
||||||
|
readonly content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeFileTool(): RegisteredTool {
|
||||||
|
return {
|
||||||
|
spec: {
|
||||||
|
name: "write_file",
|
||||||
|
description: "Write a file in the project's curriculum engineering-file workspace. Path is relative to the workspace root. Creates parent directories.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
path: { type: "string", description: "Relative path within the workspace." },
|
||||||
|
content: { type: "string", description: "The full file content to write." },
|
||||||
|
},
|
||||||
|
required: ["path", "content"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async execute(args, ctx): Promise<string> {
|
||||||
|
const a = args as WriteFileArgs;
|
||||||
|
try {
|
||||||
|
const full = confine(a.path, ctx.workspaceDir);
|
||||||
|
await mkdir(join(full, ".."), { recursive: true });
|
||||||
|
await writeFile(full, a.content, "utf8");
|
||||||
|
return JSON.stringify({ ok: true, path: a.path, bytes: a.content.length });
|
||||||
|
} catch (e) {
|
||||||
|
return JSON.stringify({ error: e instanceof Error ? e.message : String(e) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ListFilesArgs {
|
||||||
|
readonly path?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Entry {
|
||||||
|
readonly name: string;
|
||||||
|
readonly kind: "file" | "dir";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listFilesTool(): RegisteredTool {
|
||||||
|
return {
|
||||||
|
spec: {
|
||||||
|
name: "list_files",
|
||||||
|
description: "List files and directories at a path within the workspace. Defaults to the workspace root.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: { path: { type: "string", description: "Relative path within the workspace; defaults to root." } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async execute(args, ctx): Promise<string> {
|
||||||
|
const a = (args as ListFilesArgs) ?? {};
|
||||||
|
const sub = a.path ?? ".";
|
||||||
|
try {
|
||||||
|
const full = confine(sub, ctx.workspaceDir);
|
||||||
|
const entries = await readdir(full, { withFileTypes: true });
|
||||||
|
const result: Entry[] = entries.map((e) => ({
|
||||||
|
name: e.name,
|
||||||
|
kind: e.isDirectory() ? "dir" : "file",
|
||||||
|
}));
|
||||||
|
return JSON.stringify(result);
|
||||||
|
} catch (e) {
|
||||||
|
return JSON.stringify({ error: e instanceof Error ? e.message : String(e) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -16,6 +16,8 @@ import { prisma } from "./db.js";
|
|||||||
import { OpenRouterProvider } from "./agent/openrouter-provider.js";
|
import { OpenRouterProvider } from "./agent/openrouter-provider.js";
|
||||||
import { InMemoryModelRegistry } from "./agent/models.js";
|
import { InMemoryModelRegistry } from "./agent/models.js";
|
||||||
import { ToolRegistry, feishuContextTool } from "./agent/tools.js";
|
import { ToolRegistry, feishuContextTool } from "./agent/tools.js";
|
||||||
|
import { readFileTool, writeFileTool, listFilesTool } from "./agent/workspace.js";
|
||||||
|
import { cphCheckTool, cphBuildTool } from "./agent/cph.js";
|
||||||
import { startFeishuListener } from "./feishu/client.js";
|
import { startFeishuListener } from "./feishu/client.js";
|
||||||
import { makeTriggerHandler } from "./feishu/trigger.js";
|
import { makeTriggerHandler } from "./feishu/trigger.js";
|
||||||
|
|
||||||
@@ -62,6 +64,13 @@ async function main(): Promise<void> {
|
|||||||
return JSON.stringify({ stub: true, anchor: args.anchor, id: args.id, projectId: ctx.projectId });
|
return JSON.stringify({ stub: true, anchor: args.anchor, id: args.id, projectId: ctx.projectId });
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
// Workspace file tools (ADR-0007 directory tree, path-confined).
|
||||||
|
tools.register(readFileTool());
|
||||||
|
tools.register(writeFileTool());
|
||||||
|
tools.register(listFilesTool());
|
||||||
|
// cph CLI tools (ADR-0016 version contract enforced by cph itself).
|
||||||
|
tools.register(cphCheckTool());
|
||||||
|
tools.register(cphBuildTool());
|
||||||
|
|
||||||
// --- Feishu listener ---
|
// --- Feishu listener ---
|
||||||
const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: app.log });
|
const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: app.log });
|
||||||
|
|||||||
Reference in New Issue
Block a user