modelfunction-cli 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/exec.js ADDED
@@ -0,0 +1,265 @@
1
+ import { spawn } from "node:child_process";
2
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const HARNESS = fileURLToPath(new URL("./runtime/harness.mjs", import.meta.url));
8
+ const HARNESS_PY = fileURLToPath(new URL("./runtime/harness.py", import.meta.url));
9
+ const MAX_OUTPUT = 1_000_000;
10
+ const ENV_KEEP = ["PATH", "SystemRoot", "SYSTEMROOT", "TEMP", "TMP", "HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA"];
11
+
12
+ export async function executeTool(options) {
13
+ const runtime = options.runtime ?? "node";
14
+ const engine = runtime === "python" ? "python-audit" : "node-permission";
15
+ const entry = resolve(options.entry);
16
+ const nodeMajor = Number(process.versions.node.split(".")[0]);
17
+ if (runtime === "node" && options.allowNet?.length && nodeMajor < 25) {
18
+ throw new Error("--allow-net 需要 Node >= 25(Node 24 的权限模型不限制网络)");
19
+ }
20
+
21
+ let tempDir = null;
22
+ let inputPath = options.inputPath ? resolve(options.inputPath) : null;
23
+ if (!inputPath) {
24
+ tempDir = await mkdtemp(join(tmpdir(), "mf-exec-"));
25
+ inputPath = join(tempDir, "input.json");
26
+ await writeFile(inputPath, options.inputJson ?? "{}", "utf8");
27
+ }
28
+
29
+ try {
30
+ let args;
31
+ let command;
32
+ let resultPath = null;
33
+ if (runtime === "python") {
34
+ tempDir = tempDir ?? (await mkdtemp(join(tmpdir(), "mf-exec-")));
35
+ resultPath = join(tempDir, "result.json");
36
+ args = [
37
+ HARNESS_PY,
38
+ "--entry",
39
+ entry,
40
+ "--input",
41
+ inputPath,
42
+ "--export",
43
+ options.exportName ?? "run",
44
+ "--result",
45
+ resultPath
46
+ ];
47
+ for (const path of options.allowRead ?? []) args.push("--allow-read", resolve(path));
48
+ for (const path of options.allowWrite ?? []) args.push("--allow-write", resolve(path));
49
+ for (const host of options.allowNet ?? []) args.push("--allow-net", host);
50
+ if (options.warmup) args.push("--warmup", String(options.warmup));
51
+ command = options.python ?? "python";
52
+ } else {
53
+ const readPaths = [
54
+ dirname(HARNESS),
55
+ dirname(entry),
56
+ dirname(inputPath),
57
+ process.cwd(),
58
+ ...(options.allowRead ?? [])
59
+ ].map((path) => resolve(path));
60
+ args = ["--permission"];
61
+ for (const path of dedupe(readPaths)) args.push(`--allow-fs-read=${path}`);
62
+ if (options.allowWrite?.length) {
63
+ for (const path of dedupe(options.allowWrite.map((item) => resolve(item)))) {
64
+ args.push(`--allow-fs-write=${path}`);
65
+ }
66
+ }
67
+ if (options.allowNet?.length) {
68
+ for (const host of options.allowNet) args.push(`--allow-net=${host}`);
69
+ }
70
+ args.push(HARNESS, "--entry", entry, "--input", inputPath, "--export", options.exportName ?? "run");
71
+ if (options.warmup) args.push("--warmup", String(options.warmup));
72
+ command = process.execPath;
73
+ }
74
+
75
+ const env = buildEnv(options.passEnv);
76
+ if (runtime === "python") {
77
+ env.PYTHONDONTWRITEBYTECODE = "1";
78
+ env.PYTHONIOENCODING = "utf-8";
79
+ }
80
+
81
+ const started = performance.now();
82
+ const run = await spawnHarness(command, args, env, options.timeoutMs ?? 10_000);
83
+ const wallMs = Math.round(performance.now() - started);
84
+
85
+ let report = null;
86
+ if (resultPath) {
87
+ try {
88
+ report = JSON.parse(await readFile(resultPath, "utf8"));
89
+ } catch {
90
+ report = null;
91
+ }
92
+ }
93
+ if (!report) {
94
+ try {
95
+ report = JSON.parse(run.payload.trim());
96
+ } catch {
97
+ const sentinel = run.stdout
98
+ .split(/\r?\n/)
99
+ .filter((line) => line.startsWith("__MF_RESULT__"))
100
+ .pop();
101
+ if (sentinel) {
102
+ try {
103
+ report = JSON.parse(sentinel.slice("__MF_RESULT__".length));
104
+ } catch {
105
+ report = null;
106
+ }
107
+ }
108
+ }
109
+ }
110
+
111
+ const permissionDenied =
112
+ report?.permissionDenied === true ||
113
+ report?.error?.code === "ERR_ACCESS_DENIED" ||
114
+ report?.error?.code === "MF_PERMISSION_DENIED" ||
115
+ /ERR_ACCESS_DENIED/.test(run.stderr);
116
+ const timedOut = run.timedOut;
117
+ const ok = Boolean(report?.ok) && run.exitCode === 0 && !timedOut;
118
+
119
+ const result = {
120
+ ok,
121
+ timedOut,
122
+ permissionDenied,
123
+ engine,
124
+ exitCode: run.exitCode,
125
+ signal: run.signal,
126
+ wallMs,
127
+ stdout: run.stdout.slice(-4000),
128
+ stderr: run.stderr.slice(-4000),
129
+ result: report?.result ?? null,
130
+ error:
131
+ report?.error ??
132
+ (report ? null : { name: "HarnessError", code: "NO_REPORT", message: "harness 没有返回结果" }),
133
+ metrics: report?.metrics ?? null
134
+ };
135
+
136
+ return {
137
+ result,
138
+ code: timedOut ? 4 : permissionDenied ? 3 : ok ? 0 : 1
139
+ };
140
+ } finally {
141
+ if (tempDir) await rm(tempDir, { recursive: true, force: true });
142
+ }
143
+ }
144
+
145
+ export async function runExec(options, io = console) {
146
+ const { result, code } = await executeTool(options);
147
+ io.log(
148
+ options.json
149
+ ? JSON.stringify(result, null, 2)
150
+ : formatExec(resolve(options.entry), result)
151
+ );
152
+ return code;
153
+ }
154
+
155
+ function spawnHarness(command, args, env, timeoutMs) {
156
+ return new Promise((resolvePromise) => {
157
+ const child = spawn(command, args, {
158
+ stdio: ["ignore", "pipe", "pipe", "pipe"],
159
+ env,
160
+ windowsHide: true
161
+ });
162
+
163
+ let stdout = "";
164
+ let stderr = "";
165
+ let payload = "";
166
+ let settled = false;
167
+ let timedOut = false;
168
+
169
+ const finish = (exitCode, signal) => {
170
+ if (settled) return;
171
+ settled = true;
172
+ clearTimeout(timer);
173
+ try {
174
+ child.kill();
175
+ } catch {
176
+ // ignore
177
+ }
178
+ resolvePromise({ exitCode, signal, timedOut, stdout, stderr, payload });
179
+ };
180
+
181
+ const timer = setTimeout(() => {
182
+ timedOut = true;
183
+ finish(null, "SIGKILL");
184
+ }, timeoutMs);
185
+
186
+ child.stdout.on("data", (chunk) => {
187
+ if (stdout.length < MAX_OUTPUT) stdout += chunk.toString("utf8");
188
+ });
189
+ child.stderr.on("data", (chunk) => {
190
+ if (stderr.length < MAX_OUTPUT) stderr += chunk.toString("utf8");
191
+ });
192
+ child.stdio[3].on("data", (chunk) => {
193
+ if (payload.length < MAX_OUTPUT) payload += chunk.toString("utf8");
194
+ });
195
+
196
+ child.on("error", (error) => {
197
+ stderr += `\nspawn error: ${error.message}`;
198
+ finish(null, null);
199
+ });
200
+ child.on("exit", (code, signal) => finish(code, signal));
201
+ });
202
+ }
203
+
204
+ function buildEnv(passEnv = []) {
205
+ const env = {};
206
+ for (const key of ENV_KEEP) {
207
+ if (process.env[key] !== undefined) env[key] = process.env[key];
208
+ }
209
+ for (const key of passEnv) {
210
+ if (process.env[key] !== undefined) env[key] = process.env[key];
211
+ }
212
+ return env;
213
+ }
214
+
215
+ function dedupe(paths) {
216
+ return [...new Set(paths)];
217
+ }
218
+
219
+ function formatExec(entry, result) {
220
+ const lines = [`exec: ${entry}`];
221
+ const status = result.timedOut
222
+ ? "timed out"
223
+ : result.permissionDenied
224
+ ? "permission denied"
225
+ : result.ok
226
+ ? "ok"
227
+ : "failed";
228
+ lines.push(` status: ${status}`);
229
+ if (result.engine) lines.push(` engine: ${result.engine}(能力限制,不是安全边界)`);
230
+ lines.push(` wall: ${result.wallMs}ms`);
231
+ if (result.metrics) {
232
+ const m = result.metrics;
233
+ lines.push(` duration: ${m.durationMs}ms(import ${m.importMs}ms)`);
234
+ if (m.cpuUserMs !== undefined) {
235
+ lines.push(` cpu: user ${m.cpuUserMs}ms / sys ${m.cpuSystemMs}ms`);
236
+ lines.push(` memory: peak ${m.peakRssMB}MB(start ${m.startRssMB}MB),heap Δ ${m.heapUsedDeltaMB}MB`);
237
+ lines.push(` fs: read ${m.fsReadDelta} / write ${m.fsWriteDelta}`);
238
+ } else {
239
+ lines.push(` cpu: ${m.cpuMs ?? "n/a"}ms`);
240
+ lines.push(
241
+ ` memory: peak ${m.peakRssMB ?? "n/a"}MB(start ${m.startRssMB ?? "n/a"}MB),heap peak ${m.heapPeakMB ?? "n/a"}MB`
242
+ );
243
+ }
244
+ }
245
+ if (result.error) {
246
+ const error = result.error;
247
+ lines.push(
248
+ ` error: ${error.name ?? "Error"}${error.code ? ` (${error.code})` : ""}: ${error.message ?? ""}`
249
+ );
250
+ if (error.stack) {
251
+ const frames = String(error.stack)
252
+ .split("\n")
253
+ .slice(1, 4)
254
+ .map((line) => ` ${line.trim()}`);
255
+ lines.push(...frames);
256
+ }
257
+ }
258
+ if (result.ok) {
259
+ const text = JSON.stringify(result.result);
260
+ lines.push(` result: ${text.length > 800 ? `${text.slice(0, 800)}…` : text}`);
261
+ }
262
+ if (result.timedOut) lines.push(" note: 已按超时终止进程");
263
+ if (result.permissionDenied) lines.push(" note: 权限模型拒绝了文件/网络访问,可用于推断沙箱需求");
264
+ return lines.join("\n");
265
+ }
@@ -0,0 +1,52 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ export function canonicalize(value) {
4
+ if (Array.isArray(value)) return `[${value.map(canonicalize).join(",")}]`;
5
+ if (value && typeof value === "object") {
6
+ const keys = Object.keys(value).sort();
7
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalize(value[key])}`).join(",")}}`;
8
+ }
9
+ if (typeof value === "string") return JSON.stringify(value.replace(/\s+/g, " ").trim());
10
+ return JSON.stringify(value ?? null);
11
+ }
12
+
13
+ export function hashOf(value) {
14
+ return createHash("sha256").update(canonicalize(value)).digest("hex").slice(0, 16);
15
+ }
16
+
17
+ export function toolFingerprint(tool) {
18
+ return hashOf({
19
+ name: tool.name,
20
+ description: tool.description ?? "",
21
+ parameters: tool.parameters ?? {},
22
+ sideEffects: tool.sideEffects ?? null
23
+ });
24
+ }
25
+
26
+ export function caseFingerprint(testCase) {
27
+ return hashOf({
28
+ input: testCase.input ?? "",
29
+ expectedTool: testCase.expectedTool ?? null,
30
+ expectedArgs: testCase.expectedArgs ?? {},
31
+ toolInput: testCase.toolInput ?? null,
32
+ expect: testCase.expect ?? null
33
+ });
34
+ }
35
+
36
+ export function hashText(text) {
37
+ return createHash("sha256").update(String(text ?? "")).digest("hex").slice(0, 16);
38
+ }
39
+
40
+ export function suiteFingerprint(suite, selectedCases = suite.cases) {
41
+ const tools = suite.tools.map((tool) => ({ name: tool.name, hash: toolFingerprint(tool) }));
42
+ const cases = selectedCases.map((testCase) => ({ id: testCase.id, hash: caseFingerprint(testCase) }));
43
+ return {
44
+ hash: hashOf({
45
+ tools: tools.map((tool) => [tool.name, tool.hash]),
46
+ cases: cases.map((testCase) => [testCase.id, testCase.hash])
47
+ }),
48
+ tools,
49
+ cases,
50
+ reviewed: selectedCases.filter((testCase) => testCase.reviewed !== false).length
51
+ };
52
+ }
package/lib/fsutil.js ADDED
@@ -0,0 +1,7 @@
1
+ import { rename, writeFile } from "node:fs/promises";
2
+
3
+ export async function writeFileAtomic(path, data) {
4
+ const temp = `${path}.${process.pid}.tmp`;
5
+ await writeFile(temp, data, "utf8");
6
+ await rename(temp, path);
7
+ }
package/lib/lint.js ADDED
@@ -0,0 +1,110 @@
1
+ import { suspectedPairs } from "./cases.js";
2
+
3
+ const GENERIC_NAMES = new Set([
4
+ "run",
5
+ "do",
6
+ "tool",
7
+ "helper",
8
+ "execute",
9
+ "action",
10
+ "process",
11
+ "handle",
12
+ "call",
13
+ "use"
14
+ ]);
15
+
16
+ const WHEN_HINT = /(用于|用来|当|如果|适用|适合|use when|use this|helps? )/i;
17
+
18
+ export function lintSuite(suite) {
19
+ const findings = [];
20
+ const add = (severity, code, tool, message, extra = {}) =>
21
+ findings.push({ severity, code, tool, message, ...extra });
22
+
23
+ for (const tool of suite.tools) {
24
+ const description = (tool.description ?? "").trim();
25
+ const template = `建议:\`${tool.name}:当用户需要____时使用。输入____,返回____。不适用于____。\``;
26
+
27
+ if (!description) {
28
+ add("error", "desc-missing", tool.name, "工具没有 description,模型无法判断何时调用", { suggestion: template });
29
+ } else {
30
+ if (description.length < 20) {
31
+ add("warn", "desc-short", tool.name, `description 只有 ${description.length} 字,信息不足`, { suggestion: template });
32
+ }
33
+ if (description.length > 600) {
34
+ add("info", "desc-long", tool.name, `description ${description.length} 字,可能挤占上下文`);
35
+ }
36
+ if (!WHEN_HINT.test(description)) {
37
+ add("warn", "desc-no-when", tool.name, "description 没说明触发时机(用于/当/适用…),模型容易误选", {
38
+ suggestion: template
39
+ });
40
+ }
41
+ }
42
+
43
+ if (!/^[a-z][a-z0-9_]*$/.test(tool.name)) {
44
+ add("warn", "name-style", tool.name, "工具名建议用小写 snake_case");
45
+ }
46
+ if (GENERIC_NAMES.has(tool.name)) {
47
+ add("warn", "name-generic", tool.name, "工具名过于通用,容易和其它工具混淆", {
48
+ suggestion: "建议用“动词_名词”,例如 search_docs、score_resume"
49
+ });
50
+ }
51
+
52
+ const parameters = tool.parameters ?? {};
53
+ const properties = parameters.properties ?? {};
54
+ const required = parameters.required ?? [];
55
+ const names = Object.keys(properties);
56
+
57
+ if (names.length === 0) {
58
+ add("info", "schema-empty", tool.name, "工具没有参数;确认确实不需要输入");
59
+ }
60
+ if (required.length > 5) {
61
+ add("warn", "required-many", tool.name, `有 ${required.length} 个必填参数,模型容易漏填或编造`, {
62
+ suggestion: "把非关键参数改为可选,或在输入里提供默认值"
63
+ });
64
+ }
65
+
66
+ for (const [name, schema] of Object.entries(properties)) {
67
+ if (!schema?.description) {
68
+ add("warn", "param-desc-missing", tool.name, `参数 ${name} 没有说明`, {
69
+ param: name,
70
+ suggestion: `建议:\`${name}:____(格式:____;示例:____)\``
71
+ });
72
+ }
73
+ if (!schema?.type) {
74
+ add("error", "param-type-missing", tool.name, `参数 ${name} 没有 type`, { param: name });
75
+ }
76
+ if (schema?.type === "object" && !schema.properties) {
77
+ add("warn", "param-object-no-props", tool.name, `参数 ${name} 是 object 但未定义 properties`, { param: name });
78
+ }
79
+ if (schema?.type === "array" && !schema.items) {
80
+ add("warn", "param-array-no-items", tool.name, `参数 ${name} 是数组但未定义 items`, { param: name });
81
+ }
82
+ }
83
+ }
84
+
85
+ for (const pair of suspectedPairs(suite.tools)) {
86
+ add("warn", "pair-overlap", pair.a, `与 ${pair.b} 的描述相似度 ${pair.similarity.toFixed(2)},容易被混淆`, {
87
+ suggestion: `在 ${pair.a} 和 ${pair.b} 的 description 里各写一句“不适用于什么”,并补一条歧义用例`
88
+ });
89
+ }
90
+
91
+ const counts = { error: 0, warn: 0, info: 0 };
92
+ for (const finding of findings) counts[finding.severity] += 1;
93
+ return { tools: suite.tools.length, counts, findings };
94
+ }
95
+
96
+ export function formatLint(report) {
97
+ const lines = [
98
+ `tools: ${report.tools} findings: ${report.findings.length}(error ${report.counts.error} / warn ${report.counts.warn} / info ${report.counts.info})`
99
+ ];
100
+ if (report.findings.length === 0) {
101
+ lines.push("没有发现问题");
102
+ return lines.join("\n");
103
+ }
104
+ for (const finding of report.findings) {
105
+ const param = finding.param ? `.${finding.param}` : "";
106
+ lines.push(`[${finding.severity}] ${finding.tool}${param} ${finding.code}: ${finding.message}`);
107
+ if (finding.suggestion) lines.push(` ${finding.suggestion}`);
108
+ }
109
+ return lines.join("\n");
110
+ }
@@ -0,0 +1,152 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ const MAX_BYTES = 1_000_000;
4
+ const MAX_STDERR = 4_000;
5
+ const CLIENT_INFO = { name: "mf", version: "0.1.0" };
6
+ const PROTOCOL_VERSION = "2025-06-18";
7
+
8
+ export async function probeStdioServer(server, { timeoutMs = 5_000, cwd } = {}) {
9
+ if (!server?.command) {
10
+ return { ok: false, error: "missing command" };
11
+ }
12
+
13
+ let child;
14
+ try {
15
+ child = spawn(server.command, server.args ?? [], {
16
+ cwd,
17
+ env: { ...process.env, ...(server.env ?? {}) },
18
+ stdio: ["pipe", "pipe", "pipe"],
19
+ shell: false,
20
+ windowsHide: true
21
+ });
22
+ } catch (error) {
23
+ return { ok: false, error: `spawn failed: ${error.message}` };
24
+ }
25
+
26
+ const pending = new Map();
27
+ let nextId = 1;
28
+ let buffer = "";
29
+ let stderr = "";
30
+ let received = 0;
31
+ let failure = null;
32
+ let settled = false;
33
+
34
+ const fail = (message) => {
35
+ if (!failure) failure = message;
36
+ };
37
+
38
+ const rejectAll = () => {
39
+ for (const [, waiter] of pending) waiter.reject(new Error(failure ?? "server closed"));
40
+ pending.clear();
41
+ };
42
+
43
+ const stop = () => {
44
+ if (!child || child.killed) return;
45
+ try {
46
+ child.kill();
47
+ } catch {
48
+ // ignore
49
+ }
50
+ };
51
+
52
+ const send = (payload) => {
53
+ if (!child.stdin.writable) throw new Error("stdin closed");
54
+ child.stdin.write(`${JSON.stringify(payload)}\n`);
55
+ };
56
+
57
+ const request = (method, params) =>
58
+ new Promise((resolve, reject) => {
59
+ const id = nextId++;
60
+ pending.set(id, { resolve, reject });
61
+ try {
62
+ send({ jsonrpc: "2.0", id, method, params });
63
+ } catch (error) {
64
+ pending.delete(id);
65
+ reject(error);
66
+ }
67
+ });
68
+
69
+ const onLine = (line) => {
70
+ if (!line.trim()) return;
71
+ let message;
72
+ try {
73
+ message = JSON.parse(line);
74
+ } catch {
75
+ return;
76
+ }
77
+ if (message.id === undefined || !pending.has(message.id)) return;
78
+ const waiter = pending.get(message.id);
79
+ pending.delete(message.id);
80
+ if (message.error) {
81
+ waiter.reject(new Error(message.error.message ?? "rpc error"));
82
+ } else {
83
+ waiter.resolve(message.result);
84
+ }
85
+ };
86
+
87
+ child.stdout.on("data", (chunk) => {
88
+ received += chunk.length;
89
+ if (received > MAX_BYTES) {
90
+ fail("response exceeded 1MB");
91
+ stop();
92
+ return;
93
+ }
94
+ buffer += chunk.toString("utf8");
95
+ let index;
96
+ while ((index = buffer.indexOf("\n")) >= 0) {
97
+ const line = buffer.slice(0, index);
98
+ buffer = buffer.slice(index + 1);
99
+ onLine(line);
100
+ }
101
+ });
102
+
103
+ child.stderr.on("data", (chunk) => {
104
+ if (stderr.length < MAX_STDERR) stderr += chunk.toString("utf8");
105
+ });
106
+
107
+ child.on("error", (error) => {
108
+ fail(`spawn failed: ${error.message}`);
109
+ rejectAll();
110
+ });
111
+
112
+ child.on("exit", (code) => {
113
+ if (settled) return;
114
+ fail(`server exited (code ${code})`);
115
+ rejectAll();
116
+ });
117
+
118
+ const timer = setTimeout(() => {
119
+ fail(`timeout after ${timeoutMs}ms`);
120
+ stop();
121
+ rejectAll();
122
+ }, timeoutMs);
123
+
124
+ try {
125
+ await request("initialize", {
126
+ protocolVersion: PROTOCOL_VERSION,
127
+ capabilities: {},
128
+ clientInfo: CLIENT_INFO
129
+ });
130
+ send({ jsonrpc: "2.0", method: "notifications/initialized" });
131
+ const result = await request("tools/list", {});
132
+ const tools = (result?.tools ?? [])
133
+ .filter((tool) => typeof tool?.name === "string" && tool.name.trim() !== "")
134
+ .map((tool) => ({
135
+ name: tool.name.trim(),
136
+ description: tool.description ?? "",
137
+ parameters: tool.inputSchema ?? { type: "object", properties: {} }
138
+ }));
139
+ return { ok: true, tools };
140
+ } catch (error) {
141
+ return {
142
+ ok: false,
143
+ error: String(error?.message ?? error),
144
+ stderr: stderr.trim().slice(0, 500)
145
+ };
146
+ } finally {
147
+ settled = true;
148
+ clearTimeout(timer);
149
+ rejectAll();
150
+ stop();
151
+ }
152
+ }