From a4d52e18502131514ec2bc2786d012ea13a81330 Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Mon, 6 Jul 2026 23:19:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(hub):=20workspace=20=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=20+=20cph=20=E5=AD=90=E8=BF=9B=E7=A8=8B?= =?UTF-8?q?=E5=B7=A5=E5=85=B7(hub=E2=86=94courseware=20=E8=80=A6=E5=90=88?= =?UTF-8?q?=E7=82=B9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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)。 --- hub/src/agent/cph.ts | 128 ++++++++++++++++++++++++++++++++++ hub/src/agent/workspace.ts | 136 +++++++++++++++++++++++++++++++++++++ hub/src/server.ts | 9 +++ 3 files changed, 273 insertions(+) create mode 100644 hub/src/agent/cph.ts create mode 100644 hub/src/agent/workspace.ts diff --git a/hub/src/agent/cph.ts b/hub/src/agent/cph.ts new file mode 100644 index 0000000..12eeb08 --- /dev/null +++ b/hub/src/agent/cph.ts @@ -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 { + 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 ` — 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 { + 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 --target -o ` — 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 { + 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, + }); + }, + }; +} diff --git a/hub/src/agent/workspace.ts b/hub/src/agent/workspace.ts new file mode 100644 index 0000000..f2bcf1f --- /dev/null +++ b/hub/src/agent/workspace.ts @@ -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 { + 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 { + 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 { + 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) }); + } + }, + }; +} diff --git a/hub/src/server.ts b/hub/src/server.ts index 2c49972..cd11b2f 100644 --- a/hub/src/server.ts +++ b/hub/src/server.ts @@ -16,6 +16,8 @@ import { prisma } from "./db.js"; import { OpenRouterProvider } from "./agent/openrouter-provider.js"; import { InMemoryModelRegistry } from "./agent/models.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 { makeTriggerHandler } from "./feishu/trigger.js"; @@ -62,6 +64,13 @@ async function main(): Promise { 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 --- const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: app.log });