/** * SPEC: PJ2026-010404 项目管理 draft-2026-06-25-p0-mdtodo-web-active-editing-hwpod-source; * draft-2026-06-26-p1-mdtodo-web-operable. * Owns MDTODO Source normalization and bounded file access for local and HWPOD workspace sources. */ import { createHash, randomUUID } from "node:crypto"; import { promises as fs } from "node:fs"; import path from "node:path"; import { HWPOD_NODE_OPS_CONTRACT_VERSION } from "../../tools/src/hwpod-node-ops-contract.ts"; import { discoverMdtodoDocuments, parseMdtodoDocument } from "./mdtodo.ts"; const maxContentBytes = 1024 * 1024; const hiddenOrSecretPattern = /(^\.|secret|secrets|token|credential|credentials|\.env$)/iu; export function createMdtodoSourceAdapter(options = {}) { const env = options.env ?? process.env; const hwpodNodeOpsHandler = options.hwpodNodeOpsHandler ?? createHwpodNodeOpsHttpClient({ env, fetchImpl: options.fetchImpl ?? globalThis.fetch }); return { async probe(source) { if (source.sourceKind === "hwpod-workspace") { const output = await runHwpodOp(hwpodNodeOpsHandler, source, "workspace.ls", { path: normalizeDirectoryRef(source.mdtodoRootRef ?? ".") }); return { ok: true, status: "ok", sourceId: source.sourceId, sourceKind: source.sourceKind, entries: Array.isArray(output.entries) ? output.entries.length : 0, valuesRedacted: true }; } const root = localRootForSource(source); const entries = await fs.readdir(root, { withFileTypes: true }); return { ok: true, status: "ok", sourceId: source.sourceId, sourceKind: source.sourceKind, entries: entries.length, valuesRedacted: true }; }, async discover(source) { if (source.sourceKind === "hwpod-workspace") return discoverHwpodDocuments(hwpodNodeOpsHandler, source); return discoverMdtodoDocuments({ root: localRootForSource(source), sourceId: source.sourceId, projectId: source.projectId, maxFiles: source.maxFiles }); }, async readFile(source, relativePath) { const safePath = normalizeMdtodoRelativePath(relativePath); const content = source.sourceKind === "hwpod-workspace" ? (await runHwpodOp(hwpodNodeOpsHandler, source, "workspace.cat", { path: hwpodWorkspacePath(source, safePath), maxBytes: maxContentBytes })).content : await fs.readFile(localFilePathForSource(source, safePath), "utf8"); return fileContentPayload(source, safePath, String(content ?? "")); }, async readLinkedMarkdown(source, documentRelativePath, href) { const safePath = normalizeLinkedMarkdownPath(documentRelativePath, href); const content = source.sourceKind === "hwpod-workspace" ? (await runHwpodOp(hwpodNodeOpsHandler, source, "workspace.cat", { path: hwpodWorkspacePath(source, safePath), maxBytes: maxContentBytes })).content : await readBoundedLocalMarkdown(source, safePath); return linkedMarkdownPayload(source, safePath, String(content ?? "")); }, async writeFile(source, relativePath, content, expectedFingerprint) { const safePath = normalizeMdtodoRelativePath(relativePath); const nextContent = String(content ?? ""); if (Buffer.byteLength(nextContent, "utf8") > maxContentBytes) throw sourceError("mdtodo_content_too_large", "MDTODO content is too large", 413); const before = await this.readFile(source, safePath); if (expectedFingerprint && before.fingerprint !== expectedFingerprint) { throw sourceError("revision_conflict", "MDTODO document fingerprint does not match expectedFingerprint", 409, { expectedFingerprint, actualFingerprint: before.fingerprint }); } if (source.sourceKind === "hwpod-workspace") { await runHwpodOp(hwpodNodeOpsHandler, source, "workspace.write", { path: hwpodWorkspacePath(source, safePath), content: nextContent, expectedSha: expectedFingerprint ?? before.fingerprint }); } else { const target = localFilePathForSource(source, safePath); await fs.mkdir(path.dirname(target), { recursive: true }); await fs.writeFile(target, nextContent, "utf8"); } return fileContentPayload(source, safePath, nextContent); } }; } export function normalizeProjectSourceInput(input, defaults = {}) { try { const sourceKind = normalizeSourceKind(input.sourceKind ?? input.kind ?? defaults.sourceKind ?? "local-file-tree"); const projectId = safeToken(input.projectId, defaults.projectId ?? "project_hwlab_v03"); const displayName = shortText(input.displayName ?? input.name ?? defaults.displayName ?? "HWLAB MDTODO", 180) ?? "HWLAB MDTODO"; const status = safeToken(input.status, "active"); const maxFiles = positiveInteger(input.maxFiles ?? defaults.maxFiles, 300); const mdtodoRootRef = normalizeDirectoryRef(input.mdtodoRootRef ?? defaults.mdtodoRootRef ?? "."); const hwpodId = sourceKind === "hwpod-workspace" ? requiredOpaque(input.hwpodId ?? defaults.hwpodId, "hwpodId") : null; const nodeId = sourceKind === "hwpod-workspace" ? requiredOpaque(input.nodeId ?? defaults.nodeId, "nodeId") : null; const workspaceRootRef = sourceKind === "hwpod-workspace" ? requiredWorkspaceRoot(input.workspaceRootRef ?? defaults.workspaceRootRef) : null; const rootRef = sourceKind === "hwpod-workspace" ? `${hwpodId}:${mdtodoRootRef}` : path.resolve(String(input.rootRef ?? defaults.rootRef ?? process.cwd()), mdtodoRootRef === "." ? "" : mdtodoRootRef); const sourceId = safeToken(input.sourceId, stableSourceId({ sourceKind, projectId, rootRef, hwpodId, nodeId, mdtodoRootRef })); const capabilities = normalizeCapabilities(input.capabilities, sourceKind); return { ok: true, source: { sourceId, kind: sourceKind === "hwpod-workspace" ? "hwpod-workspace" : "mdtodo-file-tree", sourceKind, displayName, projectId, rootRef, status, hwpodId, nodeId, workspaceRootRef, mdtodoRootRef, maxFiles, capabilities, valuesRedacted: true } }; } catch (error) { return { ok: false, code: error?.code ?? "invalid_source", message: error?.message ?? "Invalid MDTODO source", status: error?.statusCode ?? 400 }; } } export function sourceProjection(source, documents) { return { sourceId: source.sourceId, sourceKind: source.sourceKind, rootRef: source.rootRef, documentCount: documents.length, taskCount: documents.reduce((sum, document) => sum + document.taskCount, 0), projectedAt: new Date().toISOString(), valuesRedacted: true }; } function createHwpodNodeOpsHttpClient({ env, fetchImpl }) { const endpoint = String(env.HWLAB_PROJECT_MANAGEMENT_HWPOD_NODE_OPS_URL ?? env.HWLAB_HWPOD_NODE_OPS_URL ?? "").trim(); const apiKey = String(env.HWLAB_PROJECT_MANAGEMENT_HWPOD_NODE_OPS_API_KEY ?? "").trim(); if (!endpoint || typeof fetchImpl !== "function") return null; return async (plan) => { const headers = { "content-type": "application/json", "x-source-service-id": "hwlab-project-management" }; if (apiKey) headers.authorization = `Bearer ${apiKey}`; let response; try { response = await fetchImpl(endpoint, { method: "POST", headers, body: JSON.stringify(plan), signal: AbortSignal.timeout(30000) }); } catch { throw sourceError("hwpod_transport_unavailable", "HWPOD node-ops transport is unavailable", 502, { sourceKind: "hwpod-workspace" }); } try { return await response.json(); } catch { throw sourceError("hwpod_invalid_response", "HWPOD node-ops returned an invalid response", 502, { sourceKind: "hwpod-workspace" }); } }; } async function discoverHwpodDocuments(hwpodNodeOpsHandler, source) { const files = await listHwpodMarkdownFiles(hwpodNodeOpsHandler, source); const documents = []; for (const relativePath of files.slice(0, source.maxFiles)) { const content = (await runHwpodOp(hwpodNodeOpsHandler, source, "workspace.cat", { path: hwpodWorkspacePath(source, relativePath), maxBytes: maxContentBytes })).content; const parsed = parseMdtodoDocument(String(content ?? ""), { sourceId: source.sourceId, relativePath, projectId: source.projectId }); if (parsed.tasks.length === 0 && !/mdtodo|todo/i.test(path.basename(relativePath))) continue; documents.push(parsed); } documents.sort((a, b) => a.relativePath.localeCompare(b.relativePath)); return documents; } async function listHwpodMarkdownFiles(hwpodNodeOpsHandler, source) { const root = normalizeDirectoryRef(source.mdtodoRootRef ?? "."); const files = []; const output = await runHwpodOp(hwpodNodeOpsHandler, source, "workspace.ls", { path: root }); const entries = Array.isArray(output.entries) ? output.entries : []; entries.sort((a, b) => String(a.name ?? "").localeCompare(String(b.name ?? ""))); for (const entry of entries) { const name = String(entry.name ?? ""); if (!name || isHiddenOrSecretSegment(name)) continue; if (entry.type === "file" && name.toLowerCase().endsWith(".md")) files.push(normalizeMdtodoRelativePath(name)); if (files.length >= source.maxFiles) break; } return files; } async function runHwpodOp(hwpodNodeOpsHandler, source, op, args) { if (!hwpodNodeOpsHandler) { throw sourceError("hwpod_adapter_unconfigured", "HWPOD node-ops adapter is not configured", 503, { sourceKind: "hwpod-workspace" }); } const plan = { contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION, planId: `hwpod_plan_${randomUUID()}`, hwpodId: source.hwpodId, nodeId: source.nodeId, intent: op, ops: [{ opId: "op_01", op, args: { workspacePath: source.workspaceRootRef, ...args } }] }; const payload = await hwpodNodeOpsHandler(plan, { source }); const result = Array.isArray(payload?.results) ? payload.results[0] : null; if (!payload?.ok || !result?.ok) { const blocker = result?.blocker ?? payload?.blocker ?? payload?.error ?? {}; const otelTraceId = hwpodNodeOpsPayloadOtelTraceId(payload, blocker); const summary = String(blocker.summary ?? blocker.message ?? "HWPOD node-ops request failed"); const traceLine = otelTraceId ? `OTel traceId: ${otelTraceId}` : null; throw sourceError(blocker.code ?? "hwpod_node_ops_failed", traceLine ? `${summary}\n${traceLine}` : summary, payload?.status === "blocked" ? 503 : 502, { sourceKind: "hwpod-workspace", hwpodStatus: payload?.status ?? null, otelTraceId: otelTraceId ?? null, traceLine, diagnostic: blocker.diagnostic ?? payload?.diagnostic ?? null }); } return result.output ?? {}; } function hwpodNodeOpsPayloadOtelTraceId(payload, blocker) { const resultBlocker = Array.isArray(payload?.results) ? payload.results.find((result) => result?.blocker?.otelTraceId || result?.blocker?.diagnostic?.otelTraceId)?.blocker : null; const value = payload?.otelTraceId ?? payload?.diagnostic?.otelTraceId ?? payload?.requestMeta?.otelTraceId ?? blocker?.otelTraceId ?? blocker?.diagnostic?.otelTraceId ?? resultBlocker?.otelTraceId ?? resultBlocker?.diagnostic?.otelTraceId; const text = String(value ?? "").trim(); return text || null; } function fileContentPayload(source, relativePath, content) { const parsed = parseMdtodoDocument(content, { sourceId: source.sourceId, relativePath, projectId: source.projectId }); return { sourceId: source.sourceId, fileRef: parsed.fileRef, relativePath, content, fingerprint: parsed.fingerprint, revision: parsed.fingerprint, parserMode: parsed.parserMode, taskCount: parsed.taskCount, document: parsed.document, valuesRedacted: false }; } function linkedMarkdownPayload(source, relativePath, content) { const fingerprint = sha256(content); return { sourceId: source.sourceId, relativePath, content, fingerprint, revision: fingerprint, byteCount: Buffer.byteLength(content, "utf8"), render: "markdown", valuesRedacted: false }; } function localRootForSource(source) { return path.resolve(String(source.rootRef ?? process.cwd())); } function localFilePathForSource(source, relativePath) { const root = localRootForSource(source); const target = path.resolve(root, normalizeMdtodoRelativePath(relativePath)); if (target !== root && !target.startsWith(`${root}${path.sep}`)) throw sourceError("invalid_mdtodo_path", "MDTODO path escapes source root", 400); return target; } async function readBoundedLocalMarkdown(source, relativePath) { const target = localFilePathForSource(source, relativePath); const stats = await fs.stat(target); if (stats.size > maxContentBytes) throw sourceError("mdtodo_content_too_large", "Markdown report content is too large", 413); return fs.readFile(target, "utf8"); } function hwpodWorkspacePath(source, relativePath) { return joinRelative(normalizeDirectoryRef(source.mdtodoRootRef ?? "."), normalizeMdtodoRelativePath(relativePath)); } function normalizeMdtodoRelativePath(value) { const normalized = normalizeRelativeSegments(value, { allowDirectory: false }); if (!normalized.toLowerCase().endsWith(".md")) throw sourceError("invalid_mdtodo_path", "MDTODO file path must end with .md", 400); return normalized; } function normalizeLinkedMarkdownPath(documentRelativePath, href) { const raw = String(href ?? "").trim(); if (!raw || raw.startsWith("#") || /^[a-z][a-z0-9+.-]*:/iu.test(raw) || raw.startsWith("/") || /^[A-Za-z]:\//u.test(raw)) failPath(); const withoutFragment = raw.split("#")[0]?.trim() ?? ""; const directory = path.posix.dirname(normalizeMdtodoRelativePath(documentRelativePath)); const normalized = path.posix.normalize(path.posix.join(directory === "." ? "" : directory, withoutFragment)); return normalizeMdtodoRelativePath(normalized); } function normalizeDirectoryRef(value) { return normalizeRelativeSegments(value || ".", { allowDirectory: true }); } function normalizeRelativeSegments(value, options = {}) { const text = String(value ?? "").replace(/\\/gu, "/").trim(); if (!text || text === ".") return options.allowDirectory ? "." : failPath(); if (text.startsWith("/") || /^[A-Za-z]:\//u.test(text)) failPath(); const segments = text.split("/").filter((segment) => segment && segment !== "."); if (segments.length === 0) return options.allowDirectory ? "." : failPath(); for (const segment of segments) { if (segment === ".." || segment.includes("\0") || isHiddenOrSecretSegment(segment)) failPath(); } return segments.join("/"); } function joinRelative(root, relativePath, options = {}) { const rootRef = normalizeDirectoryRef(root); const child = normalizeRelativeSegments(relativePath, { allowDirectory: options.allowDirectory === true }); if (rootRef === ".") return child; if (child === ".") return rootRef; return `${rootRef}/${child}`; } function failPath() { throw sourceError("invalid_mdtodo_path", "MDTODO path must be a safe relative path inside the configured root", 400); } function isHiddenOrSecretSegment(segment) { return hiddenOrSecretPattern.test(String(segment ?? "")); } function normalizeSourceKind(value) { const text = String(value ?? "").trim().toLowerCase(); if (["hwpod-workspace", "hwpod"].includes(text)) return "hwpod-workspace"; if (["local-file-tree", "mdtodo-file-tree", "file-tree", "local"].includes(text)) return "local-file-tree"; throw sourceError("invalid_source_kind", "sourceKind must be local-file-tree or hwpod-workspace", 400); } function normalizeCapabilities(value, sourceKind) { const defaults = sourceKind === "hwpod-workspace" ? ["probe", "list", "read", "write", "reindex"] : ["list", "read", "write", "reindex"]; const input = Array.isArray(value) ? value : defaults; return input.map((item) => safeToken(item, null)).filter(Boolean).slice(0, 20); } function requiredOpaque(value, name) { const text = String(value ?? "").trim(); if (/^[A-Za-z0-9_.:-]{1,160}$/u.test(text)) return text; throw sourceError("invalid_source_binding", `${name} is required`, 400); } function requiredWorkspaceRoot(value) { const text = String(value ?? "").trim(); if (!text || text.length > 512 || /[\n\r\0]/u.test(text)) throw sourceError("invalid_workspace_root", "workspaceRootRef is required", 400); return text; } function stableSourceId(value) { return `src_${sha256(JSON.stringify(value)).slice(0, 16)}`; } function safeToken(value, fallback) { const text = String(value ?? "").trim(); return /^[A-Za-z0-9_.:-]{1,160}$/u.test(text) ? text : fallback; } function shortText(value, max) { const text = String(value ?? "").replace(/\s+/gu, " ").trim(); return text ? text.slice(0, max) : null; } function positiveInteger(value, fallback) { const number = Number(value); return Number.isInteger(number) && number > 0 ? Math.min(number, 500) : fallback; } function sha256(value) { return createHash("sha256").update(String(value ?? "")).digest("hex"); } function sourceError(code, message, statusCode = 400, details = {}) { const error = new Error(message); error.code = code; error.statusCode = statusCode; Object.assign(error, details); return error; }