@zhijiewang/openharness 2.39.0 → 2.40.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.
@@ -0,0 +1,94 @@
1
+ /**
2
+ * oh evals — run writer.
3
+ *
4
+ * Streams per-task results to disk atomically:
5
+ * - results.jsonl : append-only, one EvalsResult per line
6
+ * - predictions.json: array, rewritten on each append, SWE-bench-submittable
7
+ * - results.json : merged + aggregates, written ONLY by finalize()
8
+ *
9
+ * Crash-safety: results.jsonl + predictions.json are valid up to the last
10
+ * successful append. `oh evals run --resume <run_id>` reads results.jsonl
11
+ * to determine completed instance_ids.
12
+ */
13
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ export class RunWriter {
16
+ runDir;
17
+ header;
18
+ results = [];
19
+ constructor(runDir, header) {
20
+ this.runDir = runDir;
21
+ this.header = header;
22
+ mkdirSync(runDir, { recursive: true });
23
+ mkdirSync(join(runDir, "transcripts"), { recursive: true });
24
+ }
25
+ appendResult(result) {
26
+ this.results.push(result);
27
+ // results.jsonl — append a single line atomically.
28
+ const line = `${JSON.stringify(result)}\n`;
29
+ appendFileSync(join(this.runDir, "results.jsonl"), line);
30
+ // predictions.json — rewrite the array atomically (.tmp → rename).
31
+ const preds = this.results.map((r) => ({
32
+ instance_id: r.instance_id,
33
+ model_patch: r.model_patch,
34
+ model_name_or_path: this.header.model,
35
+ }));
36
+ const tmp = join(this.runDir, "predictions.json.tmp");
37
+ writeFileSync(tmp, JSON.stringify(preds, null, 2));
38
+ renameSync(tmp, join(this.runDir, "predictions.json"));
39
+ }
40
+ loadExistingResults() {
41
+ const path = join(this.runDir, "results.jsonl");
42
+ if (!existsSync(path))
43
+ return [];
44
+ return readFileSync(path, "utf-8")
45
+ .split("\n")
46
+ .filter((l) => l.trim().length > 0)
47
+ .map((l) => JSON.parse(l));
48
+ }
49
+ finalize(opts) {
50
+ const counts = {
51
+ resolved: 0,
52
+ failed: 0,
53
+ error: 0,
54
+ timeout: 0,
55
+ budget_exceeded: 0,
56
+ skipped: 0,
57
+ };
58
+ let totalCost = 0;
59
+ let totalDuration = 0;
60
+ for (const r of this.results) {
61
+ counts[r.status]++;
62
+ totalCost += r.cost_usd;
63
+ totalDuration += r.duration_ms;
64
+ }
65
+ const denom = counts.resolved + counts.failed + counts.error + counts.timeout;
66
+ const passRate = denom === 0 ? 0 : counts.resolved / denom;
67
+ const artifacts = {
68
+ run_id: this.header.run_id,
69
+ pack: this.header.pack,
70
+ pack_version: this.header.pack_version,
71
+ model: this.header.model,
72
+ harness_version: this.header.harness_version,
73
+ started_at: this.header.started_at,
74
+ finished_at: opts.finished_at,
75
+ total_cost_usd: totalCost,
76
+ max_cost_usd: this.header.max_cost_usd,
77
+ total_duration_ms: totalDuration,
78
+ resolved: counts.resolved,
79
+ failed: counts.failed,
80
+ error: counts.error,
81
+ timeout: counts.timeout,
82
+ budget_exceeded: counts.budget_exceeded,
83
+ skipped: counts.skipped,
84
+ pass_rate: passRate,
85
+ partial: opts.partial,
86
+ results: [...this.results],
87
+ };
88
+ const tmp = join(this.runDir, "results.json.tmp");
89
+ writeFileSync(tmp, JSON.stringify(artifacts, null, 2));
90
+ renameSync(tmp, join(this.runDir, "results.json"));
91
+ return artifacts;
92
+ }
93
+ }
94
+ //# sourceMappingURL=run-writer.js.map
@@ -0,0 +1,34 @@
1
+ /**
2
+ * oh evals — scorer.
3
+ *
4
+ * After the agent runs, we score the task by:
5
+ * (1) Running an oracle script (oracle.sh / oracle.mjs) if one exists in
6
+ * the fixture dir — exit 0 = pass.
7
+ * (2) Else running the pack's default test command and parsing the
8
+ * junit-xml output for FAIL_TO_PASS / PASS_TO_PASS test IDs.
9
+ *
10
+ * Test ID convention matches SWE-bench: "<classname>.<name>".
11
+ */
12
+ import type { EvalsTask, TestsStatus } from "./types.js";
13
+ export type TestOutcome = "pass" | "fail" | "skip";
14
+ /**
15
+ * Minimal junit-xml parser. Returns a map of "<classname>.<name>" → outcome.
16
+ *
17
+ * We don't take a full XML parser dependency; pytest's junit-xml is
18
+ * well-formed and simple enough to extract testcase elements with regex.
19
+ */
20
+ export declare function parseJunitXml(xml: string): Record<string, TestOutcome>;
21
+ export type ScoreResult = {
22
+ resolved: boolean;
23
+ tests_status: TestsStatus;
24
+ oracle_used: boolean;
25
+ error_message?: string;
26
+ };
27
+ export declare function scoreTask(args: {
28
+ task: EvalsTask;
29
+ worktreeDir: string;
30
+ fixtureDir: string;
31
+ packDefaultTestCommand: string;
32
+ testTimeoutMs: number;
33
+ }): Promise<ScoreResult>;
34
+ //# sourceMappingURL=scorer.d.ts.map
@@ -0,0 +1,127 @@
1
+ /**
2
+ * oh evals — scorer.
3
+ *
4
+ * After the agent runs, we score the task by:
5
+ * (1) Running an oracle script (oracle.sh / oracle.mjs) if one exists in
6
+ * the fixture dir — exit 0 = pass.
7
+ * (2) Else running the pack's default test command and parsing the
8
+ * junit-xml output for FAIL_TO_PASS / PASS_TO_PASS test IDs.
9
+ *
10
+ * Test ID convention matches SWE-bench: "<classname>.<name>".
11
+ */
12
+ import { spawnSync } from "node:child_process";
13
+ import { existsSync, readFileSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ /**
16
+ * Minimal junit-xml parser. Returns a map of "<classname>.<name>" → outcome.
17
+ *
18
+ * We don't take a full XML parser dependency; pytest's junit-xml is
19
+ * well-formed and simple enough to extract testcase elements with regex.
20
+ */
21
+ export function parseJunitXml(xml) {
22
+ const out = {};
23
+ const testcaseRe = /<testcase\b([^>]*?)(?:\/>|>([\s\S]*?)<\/testcase>)/g;
24
+ let match = testcaseRe.exec(xml);
25
+ while (match !== null) {
26
+ const attrs = match[1];
27
+ const inner = match[2] ?? "";
28
+ const cn = /classname="([^"]*)"/.exec(attrs)?.[1];
29
+ const name = /\bname="([^"]*)"/.exec(attrs)?.[1];
30
+ if (cn && name) {
31
+ const id = `${cn}.${name}`;
32
+ if (/<failure\b/.test(inner) || /<error\b/.test(inner)) {
33
+ out[id] = "fail";
34
+ }
35
+ else if (/<skipped\b/.test(inner)) {
36
+ out[id] = "skip";
37
+ }
38
+ else {
39
+ out[id] = "pass";
40
+ }
41
+ }
42
+ match = testcaseRe.exec(xml);
43
+ }
44
+ return out;
45
+ }
46
+ const EMPTY_TESTS_STATUS = {
47
+ FAIL_TO_PASS: { success: [], failure: [] },
48
+ PASS_TO_PASS: { success: [], failure: [] },
49
+ };
50
+ export async function scoreTask(args) {
51
+ const { task, worktreeDir, fixtureDir, packDefaultTestCommand, testTimeoutMs } = args;
52
+ // (1) Oracle escape hatch.
53
+ const oracleSh = join(fixtureDir, "oracle.sh");
54
+ const oracleMjs = join(fixtureDir, "oracle.mjs");
55
+ if (existsSync(oracleSh)) {
56
+ const r = spawnSync(oracleSh, [], {
57
+ cwd: worktreeDir,
58
+ env: {
59
+ ...process.env,
60
+ INSTANCE_ID: task.instance_id,
61
+ WORKTREE_DIR: worktreeDir,
62
+ FIXTURE_DIR: fixtureDir,
63
+ },
64
+ timeout: testTimeoutMs,
65
+ shell: process.platform === "win32",
66
+ });
67
+ return {
68
+ resolved: r.status === 0,
69
+ tests_status: EMPTY_TESTS_STATUS,
70
+ oracle_used: true,
71
+ error_message: r.status === 0 ? undefined : (r.stderr?.toString().slice(-500) ?? ""),
72
+ };
73
+ }
74
+ if (existsSync(oracleMjs)) {
75
+ const r = spawnSync(process.execPath, [oracleMjs], {
76
+ cwd: worktreeDir,
77
+ env: {
78
+ ...process.env,
79
+ INSTANCE_ID: task.instance_id,
80
+ WORKTREE_DIR: worktreeDir,
81
+ FIXTURE_DIR: fixtureDir,
82
+ },
83
+ timeout: testTimeoutMs,
84
+ });
85
+ return {
86
+ resolved: r.status === 0,
87
+ tests_status: EMPTY_TESTS_STATUS,
88
+ oracle_used: true,
89
+ error_message: r.status === 0 ? undefined : (r.stderr?.toString().slice(-500) ?? ""),
90
+ };
91
+ }
92
+ // (2) Default test command.
93
+ const r = spawnSync(packDefaultTestCommand, {
94
+ cwd: worktreeDir,
95
+ shell: true,
96
+ timeout: testTimeoutMs,
97
+ });
98
+ const xmlPath = join(worktreeDir, ".oh-evals-results.xml");
99
+ if (!existsSync(xmlPath)) {
100
+ return {
101
+ resolved: false,
102
+ tests_status: structuredClone(EMPTY_TESTS_STATUS),
103
+ oracle_used: false,
104
+ error_message: `junit-xml not produced at ${xmlPath} (test command exit ${r.status}). stderr: ${r.stderr?.toString().slice(-500) ?? ""}`,
105
+ };
106
+ }
107
+ const outcomes = parseJunitXml(readFileSync(xmlPath, "utf-8"));
108
+ const tests_status = {
109
+ FAIL_TO_PASS: { success: [], failure: [] },
110
+ PASS_TO_PASS: { success: [], failure: [] },
111
+ };
112
+ for (const id of task.FAIL_TO_PASS) {
113
+ if (outcomes[id] === "pass")
114
+ tests_status.FAIL_TO_PASS.success.push(id);
115
+ else
116
+ tests_status.FAIL_TO_PASS.failure.push(id);
117
+ }
118
+ for (const id of task.PASS_TO_PASS) {
119
+ if (outcomes[id] === "pass")
120
+ tests_status.PASS_TO_PASS.success.push(id);
121
+ else
122
+ tests_status.PASS_TO_PASS.failure.push(id);
123
+ }
124
+ const resolved = tests_status.FAIL_TO_PASS.failure.length === 0 && tests_status.PASS_TO_PASS.failure.length === 0;
125
+ return { resolved, tests_status, oracle_used: false };
126
+ }
127
+ //# sourceMappingURL=scorer.js.map
@@ -0,0 +1,74 @@
1
+ /**
2
+ * oh evals — type definitions for the eval harness.
3
+ *
4
+ * Schema mirrors SWE-bench's evaluation contract so packs of cherry-picked
5
+ * SWE-bench Lite instances drop in unmodified. Our `EvalsResult` is a
6
+ * superset of SWE-bench's `results.json` per-instance shape, with cost,
7
+ * turns, duration, and transcript-path enrichments.
8
+ */
9
+ export type EvalsTask = {
10
+ instance_id: string;
11
+ repo: string;
12
+ base_commit: string;
13
+ problem_statement: string;
14
+ FAIL_TO_PASS: string[];
15
+ PASS_TO_PASS: string[];
16
+ hints_text?: string;
17
+ };
18
+ export type EvalsPack = {
19
+ name: string;
20
+ version: string;
21
+ description: string;
22
+ language: "python" | "javascript" | "typescript" | "polyglot";
23
+ runner_requirements: string[];
24
+ default_test_command: string;
25
+ instance_count: number;
26
+ compatible_with?: string;
27
+ };
28
+ export type EvalsStatus = "resolved" | "failed" | "error" | "timeout" | "budget_exceeded" | "skipped";
29
+ export type TestsStatus = {
30
+ FAIL_TO_PASS: {
31
+ success: string[];
32
+ failure: string[];
33
+ };
34
+ PASS_TO_PASS: {
35
+ success: string[];
36
+ failure: string[];
37
+ };
38
+ };
39
+ export type EvalsResult = {
40
+ instance_id: string;
41
+ status: EvalsStatus;
42
+ resolved: boolean;
43
+ cost_usd: number;
44
+ turns_used: number;
45
+ duration_ms: number;
46
+ model_patch: string;
47
+ tests_status: TestsStatus;
48
+ transcript_path: string;
49
+ error_message?: string;
50
+ started_at: string;
51
+ finished_at: string;
52
+ };
53
+ export type RunArtifacts = {
54
+ run_id: string;
55
+ pack: string;
56
+ pack_version: string;
57
+ model: string;
58
+ harness_version: string;
59
+ started_at: string;
60
+ finished_at: string;
61
+ total_cost_usd: number;
62
+ max_cost_usd: number;
63
+ total_duration_ms: number;
64
+ resolved: number;
65
+ failed: number;
66
+ error: number;
67
+ timeout: number;
68
+ budget_exceeded: number;
69
+ skipped: number;
70
+ pass_rate: number;
71
+ partial: boolean;
72
+ results: EvalsResult[];
73
+ };
74
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1,10 @@
1
+ /**
2
+ * oh evals — type definitions for the eval harness.
3
+ *
4
+ * Schema mirrors SWE-bench's evaluation contract so packs of cherry-picked
5
+ * SWE-bench Lite instances drop in unmodified. Our `EvalsResult` is a
6
+ * superset of SWE-bench's `results.json` per-instance shape, with cost,
7
+ * turns, duration, and transcript-path enrichments.
8
+ */
9
+ export {};
10
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Sandbox — filesystem and network restrictions for tool execution.
3
+ *
4
+ * Limits what tools can access:
5
+ * - File tools: only write to allowed paths
6
+ * - Web tools: only access allowed domains
7
+ * - Bash: restricted commands (no curl/wget by default)
8
+ *
9
+ * Reduces permission prompts while maintaining security.
10
+ */
11
+ export type SandboxConfig = {
12
+ enabled: boolean;
13
+ /** Paths tools can write to (glob-style, relative to cwd) */
14
+ allowedPaths: string[];
15
+ /** Domains WebFetch/WebSearch can access */
16
+ allowedDomains: string[];
17
+ /** Block all network access */
18
+ blockNetwork: boolean;
19
+ /** Commands blocked in Bash (default: curl, wget) */
20
+ blockedCommands: string[];
21
+ };
22
+ /** Get the current sandbox config */
23
+ export declare function getSandboxConfig(): SandboxConfig;
24
+ /** Reset cached config */
25
+ export declare function invalidateSandboxCache(): void;
26
+ /** Check if a file path is allowed for writing */
27
+ export declare function isPathAllowed(filePath: string): boolean;
28
+ /** Check if a domain is allowed for network access */
29
+ export declare function isDomainAllowed(url: string): boolean;
30
+ /** Check if a bash command is allowed */
31
+ export declare function isCommandAllowed(command: string): boolean;
32
+ /** Get a human-readable sandbox status */
33
+ export declare function sandboxStatus(): string;
34
+ //# sourceMappingURL=sandbox.d.ts.map
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Sandbox — filesystem and network restrictions for tool execution.
3
+ *
4
+ * Limits what tools can access:
5
+ * - File tools: only write to allowed paths
6
+ * - Web tools: only access allowed domains
7
+ * - Bash: restricted commands (no curl/wget by default)
8
+ *
9
+ * Reduces permission prompts while maintaining security.
10
+ */
11
+ import { relative, resolve } from "node:path";
12
+ import { readOhConfig } from "./config.js";
13
+ const DEFAULT_SANDBOX = {
14
+ enabled: false,
15
+ allowedPaths: ["."], // current directory
16
+ allowedDomains: [], // empty = all allowed
17
+ blockNetwork: false,
18
+ blockedCommands: ["curl", "wget"],
19
+ };
20
+ // ── Sandbox Manager ──
21
+ let _config = null;
22
+ /** Get the current sandbox config */
23
+ export function getSandboxConfig() {
24
+ if (_config)
25
+ return _config;
26
+ const ohConfig = readOhConfig();
27
+ if (ohConfig?.sandbox) {
28
+ _config = {
29
+ ...DEFAULT_SANDBOX,
30
+ ...ohConfig.sandbox,
31
+ };
32
+ }
33
+ else {
34
+ _config = DEFAULT_SANDBOX;
35
+ }
36
+ return _config;
37
+ }
38
+ /** Reset cached config */
39
+ export function invalidateSandboxCache() {
40
+ _config = null;
41
+ }
42
+ /** Check if a file path is allowed for writing */
43
+ export function isPathAllowed(filePath) {
44
+ const config = getSandboxConfig();
45
+ if (!config.enabled)
46
+ return true;
47
+ const resolved = resolve(filePath);
48
+ const cwd = process.cwd();
49
+ for (const allowed of config.allowedPaths) {
50
+ const allowedResolved = resolve(cwd, allowed);
51
+ // Check if the file is within the allowed directory
52
+ const rel = relative(allowedResolved, resolved);
53
+ if (!rel.startsWith("..") && !rel.startsWith("/"))
54
+ return true;
55
+ }
56
+ return false;
57
+ }
58
+ /** Check if a domain is allowed for network access */
59
+ export function isDomainAllowed(url) {
60
+ const config = getSandboxConfig();
61
+ if (!config.enabled)
62
+ return true;
63
+ if (config.blockNetwork)
64
+ return false;
65
+ if (config.allowedDomains.length === 0)
66
+ return true;
67
+ try {
68
+ const hostname = new URL(url).hostname.toLowerCase();
69
+ return config.allowedDomains.some((d) => hostname === d.toLowerCase() || hostname.endsWith(`.${d.toLowerCase()}`));
70
+ }
71
+ catch {
72
+ return false;
73
+ }
74
+ }
75
+ /** Check if a bash command is allowed */
76
+ export function isCommandAllowed(command) {
77
+ const config = getSandboxConfig();
78
+ if (!config.enabled)
79
+ return true;
80
+ const firstWord = command.trim().split(/\s+/)[0]?.toLowerCase() ?? "";
81
+ return !config.blockedCommands.includes(firstWord);
82
+ }
83
+ /** Get a human-readable sandbox status */
84
+ export function sandboxStatus() {
85
+ const config = getSandboxConfig();
86
+ if (!config.enabled)
87
+ return "Sandbox: disabled";
88
+ const lines = ["Sandbox: enabled"];
89
+ lines.push(` Allowed paths: ${config.allowedPaths.join(", ") || "none"}`);
90
+ if (config.blockNetwork) {
91
+ lines.push(" Network: blocked");
92
+ }
93
+ else if (config.allowedDomains.length > 0) {
94
+ lines.push(` Allowed domains: ${config.allowedDomains.join(", ")}`);
95
+ }
96
+ else {
97
+ lines.push(" Network: unrestricted");
98
+ }
99
+ if (config.blockedCommands.length > 0) {
100
+ lines.push(` Blocked commands: ${config.blockedCommands.join(", ")}`);
101
+ }
102
+ return lines.join("\n");
103
+ }
104
+ //# sourceMappingURL=sandbox.js.map
package/dist/main.js CHANGED
@@ -15,6 +15,7 @@ import { homedir } from "node:os";
15
15
  import { join } from "node:path";
16
16
  import { Command, Option } from "commander";
17
17
  import { render } from "ink";
18
+ import { registerEvalsCommand } from "./evals/cli.js";
18
19
  import { parseSettingSources, readOhConfig } from "./harness/config.js";
19
20
  import { emitHook, setHookDecisionObserver } from "./harness/hooks.js";
20
21
  import { languageToPrompt } from "./harness/language.js";
@@ -1318,6 +1319,8 @@ program
1318
1319
  console.log(result.message);
1319
1320
  console.log();
1320
1321
  });
1322
+ // ── evals (oh evals run/list-packs/show) ──
1323
+ registerEvalsCommand(program);
1321
1324
  // ── sessions ──
1322
1325
  program
1323
1326
  .command("sessions")
@@ -20,6 +20,7 @@ declare const inputSchema: z.ZodObject<{
20
20
  path?: string | undefined;
21
21
  type?: string | undefined;
22
22
  "-i"?: boolean | undefined;
23
+ "-C"?: number | undefined;
23
24
  context?: number | undefined;
24
25
  glob?: string | undefined;
25
26
  offset?: number | undefined;
@@ -28,13 +29,13 @@ declare const inputSchema: z.ZodObject<{
28
29
  multiline?: boolean | undefined;
29
30
  "-A"?: number | undefined;
30
31
  "-B"?: number | undefined;
31
- "-C"?: number | undefined;
32
32
  "-n"?: boolean | undefined;
33
33
  }, {
34
34
  pattern: string;
35
35
  path?: string | undefined;
36
36
  type?: string | undefined;
37
37
  "-i"?: boolean | undefined;
38
+ "-C"?: number | undefined;
38
39
  context?: number | undefined;
39
40
  glob?: string | undefined;
40
41
  offset?: number | undefined;
@@ -43,7 +44,6 @@ declare const inputSchema: z.ZodObject<{
43
44
  multiline?: boolean | undefined;
44
45
  "-A"?: number | undefined;
45
46
  "-B"?: number | undefined;
46
- "-C"?: number | undefined;
47
47
  "-n"?: boolean | undefined;
48
48
  }>;
49
49
  export declare const GrepTool: Tool<typeof inputSchema>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhijiewang/openharness",
3
- "version": "2.39.0",
3
+ "version": "2.40.0",
4
4
  "description": "Open-source terminal coding agent. Works with any LLM.",
5
5
  "type": "module",
6
6
  "bin": {