@hue-run/sdk 0.9.0 → 0.10.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.
@@ -1,6 +1,7 @@
1
- import { copyFile, mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
1
+ import { constants } from "node:fs";
2
+ import { copyFile, lstat, mkdir, opendir, writeFile } from "node:fs/promises";
2
3
  import { basename, extname, join } from "node:path";
3
- import { outputContentTypes } from "../evals/files.js";
4
+ import { outputContentTypes, outputFileLimits, readAgentFile, safeFilename, } from "../evals/files.js";
4
5
  import { withFiles } from "../evals/types.js";
5
6
  /**
6
7
  * Direct (file) cases for `hue eval`: a case is a task plus pinned input files, and the agent's
@@ -43,6 +44,11 @@ for (const type of Object.values(outputExtensions))
43
44
  throw new Error(`outputExtensions maps to a content type Hue does not accept: ${type}`);
44
45
  const HELPER_FILES = new Set(["manifest.json", "result.json", "output.json"]);
45
46
  const SUMMARY_FILES = ["summary.txt", "summary.md", "final.txt", "resumen.txt"];
47
+ /** The most a helper (`manifest.json`, `result.json`, `summary.txt`, …) may hold: the same bound
48
+ * as the command's stdout. */
49
+ const HELPER_BYTES = 4 * 1024 * 1024;
50
+ /** Entries a collection visits at most, hidden and skipped ones included. */
51
+ const MAX_ENTRIES = 1024;
46
52
  /** Writes the case directory: inputs, descriptor, verified file copies and an empty output folder. */
47
53
  export async function stageDirectCase(root, input) {
48
54
  const caseDirectory = join(root, "case");
@@ -50,21 +56,25 @@ export async function stageDirectCase(root, input) {
50
56
  await mkdir(outputDirectory, { recursive: true, mode: 0o700 });
51
57
  const inputsPath = join(caseDirectory, "inputs.json");
52
58
  await writeFile(inputsPath, JSON.stringify(input.inputs, null, 2), { mode: 0o600 });
59
+ // Names that differ only in case or Unicode normalization are the same file on macOS and
60
+ // Windows filesystems; keep both inputs under distinct names, and never overwrite one.
53
61
  const used = new Set();
62
+ const key = (folder, name) => `${folder}/${name.normalize("NFC").toLowerCase()}`;
54
63
  const files = [];
55
64
  for (const file of input.files) {
56
65
  const folder = join(caseDirectory, "files", file.role);
57
66
  await mkdir(folder, { recursive: true, mode: 0o700 });
58
67
  // Two inputs may share a filename (two incident reports, say); keep both.
59
- const name = basename(file.filename);
68
+ const name = safeFilename(basename(file.filename));
60
69
  const extension = extname(name);
61
70
  const stem = name.slice(0, name.length - extension.length);
62
- let target = join(folder, name);
63
- for (let copy = 2; used.has(target); copy++)
64
- target = join(folder, `${stem} (${copy})${extension}`);
65
- used.add(target);
66
- await copyFile(file.path, target);
67
- files.push({ role: file.role, filename: basename(target), path: target });
71
+ let target = name;
72
+ for (let copy = 2; used.has(key(folder, target)); copy++)
73
+ target = `${stem} (${copy})${extension}`;
74
+ used.add(key(folder, target));
75
+ const path = join(folder, target);
76
+ await copyFile(file.path, path, constants.COPYFILE_EXCL);
77
+ files.push({ role: file.role, filename: target, path });
68
78
  }
69
79
  await writeFile(join(caseDirectory, "case.json"), JSON.stringify({
70
80
  id: input.item.id,
@@ -76,33 +86,119 @@ export async function stageDirectCase(root, input) {
76
86
  }, null, 2), { mode: 0o600 });
77
87
  return { caseDirectory, inputsPath, outputDirectory, files };
78
88
  }
79
- async function readJsonFile(path) {
89
+ const identityOf = (info) => ({
90
+ dev: info.dev,
91
+ ino: info.ino,
92
+ });
93
+ const sameIdentity = (a, b) => a.dev === b.dev && a.ino === b.ino;
94
+ /** A real directory (not a symlink) and its identity, or undefined when `path` is absent. A
95
+ * parent that is no longer a directory (ENOTDIR) is a change, never an absent directory: a case
96
+ * directory replaced by a file must not read as "wrote nothing". */
97
+ async function directoryIdentity(path, label) {
98
+ let info;
80
99
  try {
81
- return JSON.parse(await readFile(path, "utf8"));
100
+ info = await lstat(path);
82
101
  }
83
102
  catch (error) {
84
103
  if (error.code === "ENOENT")
85
104
  return undefined;
86
- throw new Error(`${basename(path)} in the output directory is not valid JSON`);
105
+ throw unexamined(label, error);
87
106
  }
107
+ if (info.isSymbolicLink() || !info.isDirectory())
108
+ throw new Error(`${label} is not a directory; a symbolic link or file is never collected`);
109
+ return identityOf(info);
88
110
  }
89
- /** Regular files anywhere under the output directory, named by their `/`-joined relative path.
90
- * Hidden and lock entries (dot names, `~$…`) and symlinks are skipped at every level. */
91
- async function listOutputFiles(directory, prefix = "") {
92
- const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
111
+ /** The error for an entry that vanished or changed kind while it was being collected, or could
112
+ * not be examined at all. */
113
+ function unexamined(label, error) {
114
+ const code = error.code;
115
+ return new Error(code === "ENOENT" || code === "ENOTDIR" || code === "ELOOP"
116
+ ? `${label} changed while it was collected`
117
+ : `${label} could not be read`);
118
+ }
119
+ /**
120
+ * Regular files anywhere under the output directory, named by their `/`-joined relative path,
121
+ * with the device and inode each had when listed. Hidden and lock entries (dot names, `~$…`),
122
+ * symlinks and anything that is neither a regular file nor a directory are skipped at every
123
+ * level, except that a top-level helper name must be a regular file when present. The walk stops
124
+ * past 32 documents or 1024 entries, and an entry that vanished or a directory that changed while
125
+ * it was listed is refused.
126
+ */
127
+ async function listOutputFiles(root, rootIdentity, seams) {
93
128
  const found = [];
94
- for (const entry of entries) {
95
- if (entry.name.startsWith(".") || entry.name.startsWith("~$"))
96
- continue;
97
- const path = join(directory, entry.name);
98
- const name = `${prefix}${entry.name}`;
99
- if (entry.isDirectory())
100
- found.push(...(await listOutputFiles(path, `${name}/`)));
101
- else if (entry.isFile())
102
- found.push({ name, path });
129
+ const directories = [
130
+ { path: root, label: "output/", identity: rootIdentity },
131
+ ];
132
+ let visited = 0;
133
+ let documents = 0;
134
+ for (let index = 0; index < directories.length; index++) {
135
+ const directory = directories[index];
136
+ const prefix = index === 0 ? "" : `${directory.label.slice("output/".length)}`;
137
+ let entries;
138
+ try {
139
+ entries = await opendir(directory.path);
140
+ }
141
+ catch (error) {
142
+ throw unexamined(directory.label, error);
143
+ }
144
+ for await (const entry of entries) {
145
+ if (++visited > MAX_ENTRIES)
146
+ throw new Error(`The output directory holds more than ${MAX_ENTRIES} entries`);
147
+ const name = `${prefix}${entry.name}`;
148
+ const helper = index === 0 && (HELPER_FILES.has(name) || SUMMARY_FILES.includes(name));
149
+ if (!helper && (entry.name.startsWith(".") || entry.name.startsWith("~$")))
150
+ continue;
151
+ const path = join(directory.path, entry.name);
152
+ if (helper && !entry.isFile())
153
+ throw new Error(`output/${name} is not a regular file; the agent must write it itself`);
154
+ if (entry.isDirectory() || entry.isFile())
155
+ await seams.beforeEntry?.(name);
156
+ if (entry.isDirectory()) {
157
+ const identity = await directoryIdentity(path, `output/${name}`);
158
+ if (!identity)
159
+ throw new Error(`output/${name} changed while it was collected`);
160
+ directories.push({ path, label: `output/${name}/`, identity });
161
+ }
162
+ else if (entry.isFile()) {
163
+ let info;
164
+ try {
165
+ info = await lstat(path);
166
+ }
167
+ catch (error) {
168
+ throw unexamined(`output/${name}`, error);
169
+ }
170
+ if (!info.isFile())
171
+ throw new Error(`output/${name} changed while it was collected`);
172
+ found.push({ name, path, identity: identityOf(info) });
173
+ if (!helper && ++documents > outputFileLimits.count)
174
+ throw new Error(`The agent wrote more than ${outputFileLimits.count} files`);
175
+ }
176
+ }
177
+ }
178
+ // Every directory must still be the one listed: none was swapped for a link meanwhile.
179
+ for (const directory of directories) {
180
+ const now = await directoryIdentity(directory.path, directory.label);
181
+ if (!now || !sameIdentity(now, directory.identity))
182
+ throw new Error(`${directory.label} changed while it was collected`);
103
183
  }
104
184
  return found;
105
185
  }
186
+ async function readHelper(listed) {
187
+ if (!listed)
188
+ return undefined;
189
+ const bytes = await readAgentFile(listed.path, `output/${listed.name}`, HELPER_BYTES, listed.identity);
190
+ return Buffer.from(bytes).toString("utf8");
191
+ }
192
+ function parseHelper(listed, text) {
193
+ if (text === undefined)
194
+ return undefined;
195
+ try {
196
+ return JSON.parse(text);
197
+ }
198
+ catch {
199
+ throw new Error(`${listed.name} in the output directory is not valid JSON`);
200
+ }
201
+ }
106
202
  /** Artifact filenames cannot contain path separators or controls. Encode only those forbidden
107
203
  * characters plus `%`, then represent `/` as `%2F`; distinct output paths stay distinct. */
108
204
  function artifactFilename(relativePath) {
@@ -120,9 +216,18 @@ function artifactFilename(relativePath) {
120
216
  * becomes a generated file; an unsupported extension is the agent's error rather than a silently
121
217
  * dropped document. `fallbackOutput` (for example the command's stdout) is used when no JSON
122
218
  * output or summary file was written.
219
+ *
220
+ * Nothing here trusts a path the agent could have changed: the output directory must be a real
221
+ * directory, every file is opened without following a final symlink or blocking on a FIFO, must
222
+ * be the regular file the listing saw and within its size limit, and the result carries the bytes
223
+ * read from that open file. `seams` are for tests only.
123
224
  */
124
- export async function collectDirectOutputs(outputDirectory, fallbackOutput) {
125
- const manifest = await readJsonFile(join(outputDirectory, "manifest.json"));
225
+ export async function collectDirectOutputs(outputDirectory, fallbackOutput, seams = {}) {
226
+ const rootIdentity = await directoryIdentity(outputDirectory, "The output directory");
227
+ const listed = rootIdentity ? await listOutputFiles(outputDirectory, rootIdentity, seams) : [];
228
+ await seams.afterListing?.();
229
+ const top = (name) => listed.find((entry) => entry.name === name);
230
+ const manifest = parseHelper(top("manifest.json"), await readHelper(top("manifest.json")));
126
231
  const declaredPrimary = manifest && typeof manifest === "object" && !Array.isArray(manifest)
127
232
  ? manifest.primary
128
233
  : undefined;
@@ -130,11 +235,11 @@ export async function collectDirectOutputs(outputDirectory, fallbackOutput) {
130
235
  ? manifest.output
131
236
  : undefined;
132
237
  let output = declaredOutput ??
133
- (await readJsonFile(join(outputDirectory, "result.json"))) ??
134
- (await readJsonFile(join(outputDirectory, "output.json")));
238
+ parseHelper(top("result.json"), await readHelper(top("result.json"))) ??
239
+ parseHelper(top("output.json"), await readHelper(top("output.json")));
135
240
  if (output === undefined)
136
241
  for (const name of SUMMARY_FILES) {
137
- const text = await readFile(join(outputDirectory, name), "utf8").catch(() => undefined);
242
+ const text = await readHelper(top(name));
138
243
  if (text !== undefined) {
139
244
  output = { summary: text };
140
245
  break;
@@ -143,7 +248,7 @@ export async function collectDirectOutputs(outputDirectory, fallbackOutput) {
143
248
  if (output === undefined)
144
249
  output = fallbackOutput;
145
250
  // Helper and summary names are reserved at the top level only; a nested one is a document.
146
- const entries = (await listOutputFiles(outputDirectory)).filter((entry) => !HELPER_FILES.has(entry.name) && !SUMMARY_FILES.includes(entry.name));
251
+ const entries = listed.filter((entry) => !HELPER_FILES.has(entry.name) && !SUMMARY_FILES.includes(entry.name));
147
252
  // Directory order differs between filesystems; keep uploads stable.
148
253
  entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
149
254
  const unsupported = entries.filter((entry) => !outputExtensions[extname(entry.name).toLowerCase()]);
@@ -151,10 +256,11 @@ export async function collectDirectOutputs(outputDirectory, fallbackOutput) {
151
256
  throw new Error(`The agent wrote files Hue does not accept as generated documents: ${unsupported.map((entry) => entry.name).join(", ")} (accepted: ${Object.keys(outputExtensions).join(" ")})`);
152
257
  const files = [];
153
258
  for (const entry of entries) {
154
- if ((await stat(entry.path)).size === 0)
259
+ const bytes = (await readAgentFile(entry.path, `Generated file ${entry.name}`, outputFileLimits.bytes, entry.identity));
260
+ if (!bytes.byteLength)
155
261
  throw new Error(`The agent wrote an empty file: ${entry.name}`);
156
262
  files.push({
157
- path: entry.path,
263
+ bytes,
158
264
  filename: artifactFilename(entry.name),
159
265
  contentType: outputExtensions[extname(entry.name).toLowerCase()],
160
266
  });
@@ -167,5 +273,11 @@ export async function collectDirectOutputs(outputDirectory, fallbackOutput) {
167
273
  }
168
274
  else if (files.length === 1)
169
275
  files[0].primary = true;
276
+ // The directory must still be the one listed when every byte has been read.
277
+ if (rootIdentity) {
278
+ const now = await directoryIdentity(outputDirectory, "The output directory");
279
+ if (!now || !sameIdentity(now, rootIdentity))
280
+ throw new Error("The output directory changed while it was collected");
281
+ }
170
282
  return withFiles(output, files);
171
283
  }
@@ -1,9 +1,10 @@
1
1
  import type { ExperimentCase, LocalFile } from "../evals/types.js";
2
2
  import { TargetResult } from "../evals/types.js";
3
3
  import { type SimulationTargetContext } from "../evals/simulation.js";
4
+ import { type ExperimentVerdicts } from "../evals/verdicts.js";
4
5
  import type { JsonValue } from "../types.js";
5
6
  /** Adapter contract: the module's `default` or `runMyAgent` export. */
6
- export type EvalAdapter = (inputs: JsonValue, context: SimulationTargetContext) => JsonValue | undefined | Promise<JsonValue | undefined>;
7
+ export type EvalAdapter = (inputs: JsonValue, context: SimulationTargetContext) => JsonValue | TargetResult | undefined | Promise<JsonValue | TargetResult | undefined>;
7
8
  /**
8
9
  * Context of a direct (file) case: pinned input files in, generated documents out. The same
9
10
  * adapter module serves both modes; `mode` tells it which context it received.
@@ -20,6 +21,32 @@ export interface DirectTargetContext {
20
21
  signal?: AbortSignal;
21
22
  }
22
23
  export type DirectEvalAdapter = (inputs: JsonValue, context: DirectTargetContext) => JsonValue | TargetResult | undefined | Promise<JsonValue | TargetResult | undefined>;
24
+ export interface Output {
25
+ /** Human progress lines: stdout normally, stderr with --json. */
26
+ log(line: string): void;
27
+ /** Diagnostics; always stderr. */
28
+ error(line: string): void;
29
+ }
30
+ /**
31
+ * Spawns the agent command once in its own process group and returns its trimmed stdout. A
32
+ * timeout or Ctrl+C stops the agent the shell started, not only the shell: a survivor would still
33
+ * hold a world token and could write after Hue recorded the case as failed. The group is signalled
34
+ * even once the shell has exited, since a compound command's agent can outlive it, and a stopped
35
+ * command settles only when nothing in the group is left. A command that exits normally but
36
+ * leaves processes behind has them stopped the same way before it settles, so none can touch its
37
+ * outputs while they are collected. Once the group is found empty it is never signalled again,
38
+ * since its ID could be reused. Windows has no process group to signal, so the child alone is
39
+ * stopped there.
40
+ */
41
+ export declare function spawnAgentCommand(command: string, options: {
42
+ cwd?: string;
43
+ env: Record<string, string | undefined>;
44
+ stdin?: string;
45
+ timeoutSeconds: number;
46
+ signal?: AbortSignal;
47
+ }): Promise<string>;
48
+ export declare function renderTable(verdicts: ExperimentVerdicts, output: Output): void;
49
+ export declare function explain(error: unknown): string;
23
50
  /**
24
51
  * `hue eval`: runs a local adapter or command against a Scenario or eval set through
25
52
  * `runSimulation`, or registers it as an outbound worker through `runLocalAgent`.