@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,8 +1,9 @@
1
1
  import { createHash } from "node:crypto";
2
- import { copyFile, lstat, mkdir, open, readFile, rename, rm } from "node:fs/promises";
3
- import { join, resolve } from "node:path";
2
+ import { constants } from "node:fs";
3
+ import { lstat, mkdir, open, readFile, rename, rm } from "node:fs/promises";
4
+ import { extname, join, resolve } from "node:path";
4
5
  import { randomUUID } from "node:crypto";
5
- import { HueApiError } from "./client.js";
6
+ import { ArtifactSizeError, HueApiError } from "./client.js";
6
7
  /** Roles the target receives. Organization templates stay with grading, as in the managed protocol. */
7
8
  export const targetFileRoles = [
8
9
  "source",
@@ -37,25 +38,153 @@ export class OutputFileError extends Error {
37
38
  this.name = "OutputFileError";
38
39
  }
39
40
  }
41
+ /**
42
+ * Thrown when a pinned file cannot be used: its downloaded bytes differ from the manifest's size
43
+ * or SHA-256 (`case_file_mismatch`), or a file meant for an agent working in a world is not
44
+ * named by one safe file name (`case_file_name_refused`). Raised before the case's execution
45
+ * starts, so no execution, world or target call is spent on it.
46
+ */
47
+ export class CaseFileError extends Error {
48
+ code;
49
+ artifactId;
50
+ constructor(
51
+ /** Stable diagnostic code. */
52
+ code,
53
+ /** Hue artifact identity of the refused file. */
54
+ artifactId, message) {
55
+ super(`${message} (${code})`);
56
+ this.code = code;
57
+ this.artifactId = artifactId;
58
+ this.name = "CaseFileError";
59
+ }
60
+ }
40
61
  const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
41
- /** Reduce a declared file name to a single printable path segment, at most 200 characters. */
62
+ /** A file name quoted for a message: at most 120 characters, controls escaped. */
63
+ const quoted = (name) => JSON.stringify([...name].slice(0, 120).join(""));
64
+ /** Names Windows reserves for devices, with or without an extension. */
65
+ const DEVICE_NAME = /^(?:con|prn|aux|nul|com[0-9¹²³]|lpt[0-9¹²³]|conin\$|conout\$)$/iu;
66
+ /** The longest file name the runner writes, in UTF-8 bytes: room is left for the temporary
67
+ * suffix of an atomic write and a copy counter within the usual 255-byte limit. */
68
+ const MAX_NAME_BYTES = 200;
69
+ /** The longest prefix of `name` that fits in `bytes` UTF-8 bytes, cut between code points. */
70
+ function truncateBytes(name, bytes) {
71
+ let kept = "";
72
+ let size = 0;
73
+ for (const character of name) {
74
+ size += Buffer.byteLength(character);
75
+ if (size > bytes)
76
+ break;
77
+ kept += character;
78
+ }
79
+ return kept;
80
+ }
81
+ /**
82
+ * Whether `name` can be used unchanged as one file name: not empty, `.` or `..`; no `/`, `\`,
83
+ * C0 or C1 control character, or character Windows reserves (`: < > " | ? *`); no Windows device
84
+ * name (`CON`, `NUL`, `COM1`, … with any extension); no trailing dot or space, which Windows
85
+ * drops; at most 200 UTF-8 bytes. An absolute path or a path with a parent step always contains
86
+ * a separator, so it is refused too.
87
+ */
88
+ export function isSafeFileName(name) {
89
+ if (typeof name !== "string" || !name || name === "." || name === "..")
90
+ return false;
91
+ // eslint-disable-next-line no-control-regex -- control characters are refused
92
+ if (/[/\\:<>"|?*\x00-\x1f\x7f-\x9f]/u.test(name))
93
+ return false;
94
+ if (name.endsWith(".") || name.endsWith(" "))
95
+ return false;
96
+ if (DEVICE_NAME.test(name.split(".")[0].trimEnd()))
97
+ return false;
98
+ return Buffer.byteLength(name) <= MAX_NAME_BYTES;
99
+ }
100
+ /** Refuse a pinned file whose name is not one safe file name; see {@link isSafeFileName}. */
101
+ export function assertSafeFileNames(files) {
102
+ for (const file of files)
103
+ if (!isSafeFileName(file.filename))
104
+ throw new CaseFileError("case_file_name_refused", file.artifactId, `Pinned file ${quoted(String(file.filename))} is not a safe file name`);
105
+ }
106
+ /**
107
+ * Reduce a declared file name to a single printable path segment, at most 200 UTF-8 bytes. A
108
+ * longer name keeps its extension, so `.pdf` stays `.pdf`, and its stem is shortened and marked
109
+ * with a short hash of the whole name, so two long names never shorten to the same one.
110
+ */
42
111
  export function safeFilename(name) {
43
112
  const cleaned = [...name]
44
113
  .filter((c) => c.charCodeAt(0) >= 32 && c.charCodeAt(0) !== 127 && c !== "/" && c !== "\\")
45
114
  .join("")
46
115
  .trim();
47
- return cleaned && cleaned !== "." && cleaned !== ".."
48
- ? [...cleaned].slice(0, 200).join("")
49
- : "file";
116
+ let kept = cleaned;
117
+ if (Buffer.byteLength(cleaned) > MAX_NAME_BYTES) {
118
+ const found = extname(cleaned);
119
+ const extension = Buffer.byteLength(found) <= 32 ? found : "";
120
+ const mark = `~${sha256(Buffer.from(cleaned)).slice(0, 8)}`;
121
+ const room = MAX_NAME_BYTES - Buffer.byteLength(extension) - mark.length;
122
+ kept = `${truncateBytes(cleaned.slice(0, cleaned.length - extension.length), room)}${mark}${extension}`;
123
+ }
124
+ return kept && kept !== "." && kept !== ".." ? kept : "file";
50
125
  }
126
+ /** Create `path` if needed and require a directory this user owns that no one else can open. */
51
127
  async function privateDirectory(path) {
52
128
  const root = resolve(path);
53
129
  await mkdir(root, { recursive: true, mode: 0o700 });
54
130
  const info = await lstat(root);
55
- if (!info.isDirectory() || info.isSymbolicLink())
56
- throw new Error("Use a private files directory (no symlink)");
131
+ const uid = process.getuid?.();
132
+ if (!info.isDirectory() ||
133
+ info.isSymbolicLink() ||
134
+ (uid !== undefined && info.uid !== uid) ||
135
+ (process.platform !== "win32" && (info.mode & 0o077) !== 0))
136
+ throw new Error("Use a private files directory (owned by this user, mode 0700, no symlink)");
57
137
  return root;
58
138
  }
139
+ // A symlink as the last path component fails to open, and a FIFO or device never blocks.
140
+ const AGENT_FILE_FLAGS = constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK;
141
+ /**
142
+ * Read a file an agent wrote without trusting its path: the last component must not be a
143
+ * symlink and the open file must be a regular file of at most `maxBytes`, checked before any byte
144
+ * is read, and, when `expected` is given, the very file a listing saw. The bytes come from that
145
+ * open file. Returns undefined for a missing file only when nothing was expected there; every
146
+ * other refusal is an `OutputFileError` naming `label`.
147
+ */
148
+ export async function readAgentFile(path, label, maxBytes, expected) {
149
+ let file;
150
+ try {
151
+ file = await open(path, AGENT_FILE_FLAGS);
152
+ }
153
+ catch (error) {
154
+ const code = error.code;
155
+ if (code === "ENOENT" && !expected)
156
+ return undefined;
157
+ if (code === "ELOOP" || code === "EMLINK")
158
+ throw new OutputFileError(`${label} is a symbolic link`);
159
+ if (code === "ENOENT")
160
+ throw new OutputFileError(`${label} changed while it was collected`);
161
+ throw new OutputFileError(`${label} could not be read`);
162
+ }
163
+ try {
164
+ const info = await file.stat();
165
+ if (!info.isFile())
166
+ throw new OutputFileError(`${label} is not a regular file`);
167
+ if (expected && (info.dev !== expected.dev || info.ino !== expected.ino))
168
+ throw new OutputFileError(`${label} changed while it was collected`);
169
+ if (info.size > maxBytes)
170
+ throw new OutputFileError(`${label} exceeds ${Math.floor(maxBytes / 1024 / 1024)} MiB`);
171
+ // One byte past the size seen: a file that is still growing is refused, not read whole.
172
+ const bytes = Buffer.allocUnsafe(info.size + 1);
173
+ let total = 0;
174
+ while (total < bytes.length) {
175
+ const { bytesRead } = await file.read(bytes, total, bytes.length - total, total);
176
+ if (!bytesRead)
177
+ break;
178
+ total += bytesRead;
179
+ }
180
+ if (total !== info.size)
181
+ throw new OutputFileError(`${label} changed while it was collected`);
182
+ return bytes.subarray(0, total);
183
+ }
184
+ finally {
185
+ await file.close();
186
+ }
187
+ }
59
188
  async function writePrivate(path, bytes) {
60
189
  const temporary = `${path}.${randomUUID()}.tmp`;
61
190
  const file = await open(temporary, "wx", 0o600);
@@ -74,6 +203,28 @@ async function verifiedBytes(path, expected) {
74
203
  throw new Error(`Saved file ${path} no longer matches its verified identity`);
75
204
  return bytes;
76
205
  }
206
+ /** Download one pinned file and check its size and SHA-256 against the manifest. The download
207
+ * stops one byte past the pinned size. */
208
+ async function downloadVerified(client, file) {
209
+ const mismatch = () => new CaseFileError("case_file_mismatch", file.artifactId, `Downloaded file ${quoted(file.filename)} does not match its pinned size and SHA-256`);
210
+ let bytes;
211
+ try {
212
+ bytes = await client.downloadArtifact(file.artifactId, { maxBytes: file.byteSize });
213
+ }
214
+ catch (error) {
215
+ if (error instanceof ArtifactSizeError)
216
+ throw mismatch();
217
+ throw error;
218
+ }
219
+ if (bytes.byteLength !== file.byteSize || sha256(bytes) !== file.sha256)
220
+ throw mismatch();
221
+ return bytes;
222
+ }
223
+ /** Check pinned files against the manifest without keeping them: nothing is written to disk. */
224
+ export async function verifyCaseFiles(client, files) {
225
+ for (const file of files)
226
+ await downloadVerified(client, file);
227
+ }
77
228
  /**
78
229
  * Save verified copies of a case's pinned input files under `directory`. A file already
79
230
  * present with the pinned identity is reused, so a resumed run does not download again.
@@ -91,12 +242,8 @@ export async function downloadCaseFiles(client, files, directory, primaryArtifac
91
242
  catch {
92
243
  present = false;
93
244
  }
94
- if (!present) {
95
- const bytes = await client.downloadArtifact(file.artifactId);
96
- if (bytes.byteLength !== file.byteSize || sha256(bytes) !== file.sha256)
97
- throw new Error(`Downloaded input ${file.filename} does not match its pinned identity`);
98
- await writePrivate(path, bytes);
99
- }
245
+ if (!present)
246
+ await writePrivate(path, await downloadVerified(client, file));
100
247
  saved.push({
101
248
  artifactId: file.artifactId,
102
249
  role: file.role,
@@ -110,6 +257,26 @@ export async function downloadCaseFiles(client, files, directory, primaryArtifac
110
257
  }
111
258
  return saved;
112
259
  }
260
+ /**
261
+ * Download a case's needed inputs under `caseDirectory`: the agent-visible roles into `inputs/`
262
+ * and evaluator-only files into `evaluator-inputs/`, so the directory the target's files live in
263
+ * never holds an evaluator's file. `all` keeps the manifest order for scorers; `target` is the
264
+ * agent-visible subset.
265
+ */
266
+ export async function downloadCaseInputs(client, files, caseDirectory) {
267
+ const visible = (file) => targetFileRoles.includes(file.role);
268
+ const agentFiles = files.filter(visible);
269
+ const evaluatorFiles = files.filter((file) => !visible(file));
270
+ const target = agentFiles.length
271
+ ? await downloadCaseFiles(client, agentFiles, join(caseDirectory, "inputs"))
272
+ : [];
273
+ const evaluator = evaluatorFiles.length
274
+ ? await downloadCaseFiles(client, evaluatorFiles, join(caseDirectory, "evaluator-inputs"))
275
+ : [];
276
+ const saved = [...target, ...evaluator];
277
+ const all = files.map((file) => saved.find((entry) => entry.artifactId === file.artifactId && entry.role === file.role));
278
+ return { all, target };
279
+ }
113
280
  /** Copy the target's generated files next to the checkpoint and record their identities. */
114
281
  export async function stageOutputFiles(files, directory) {
115
282
  if (!Array.isArray(files) || !files.length)
@@ -132,24 +299,17 @@ export async function stageOutputFiles(files, directory) {
132
299
  names.add(filename);
133
300
  if (!outputContentTypes.includes(file.contentType))
134
301
  throw new OutputFileError(`Unsupported generated file type ${String(file.contentType)}`);
135
- let bytes;
136
- try {
137
- bytes = file.bytes ?? (await readFile(file.path));
138
- }
139
- catch {
302
+ // A path is read once, as a regular file within the limit, and staged from those bytes.
303
+ const bytes = file.bytes ??
304
+ (await readAgentFile(file.path, `Generated file ${filename}`, outputFileLimits.bytes));
305
+ if (!bytes)
140
306
  throw new OutputFileError(`Generated file ${filename} could not be read`);
141
- }
142
307
  if (!bytes.byteLength)
143
308
  throw new OutputFileError(`Generated file ${filename} is empty`);
144
309
  if (bytes.byteLength > outputFileLimits.bytes)
145
310
  throw new OutputFileError(`Generated file ${filename} exceeds 25 MiB`);
146
311
  const path = join(root, filename);
147
- if (file.bytes)
148
- await writePrivate(path, bytes);
149
- else {
150
- await copyFile(file.path, path);
151
- await verifiedBytes(path, { byteSize: bytes.byteLength, sha256: sha256(bytes) });
152
- }
312
+ await writePrivate(path, bytes);
153
313
  staged.push({
154
314
  filename,
155
315
  contentType: file.contentType,
@@ -4,7 +4,7 @@ import type { EnvironmentTool } from "../environment/tools.js";
4
4
  import type { WorldHandoff } from "../environment/types.js";
5
5
  import type { ActualAgentManifestInputV2, AttemptConnectionBundleV2, RequestedAttemptProviderV2 } from "./attempt.js";
6
6
  import type { EvaluationClient } from "./client.js";
7
- import { type RunExperimentTargetContext, type RunnerReport } from "./runner.js";
7
+ import { type RunExperimentTargetContext, type RunnerReport, type TelemetryNotAccepted } from "./runner.js";
8
8
  import type { ExperimentCase, JsonValue, LocalAgentRegistration, LocalFile, LocalScorer, TargetResult } from "./types.js";
9
9
  /** Capability strings a registration declares; Hue offers only matching cases. */
10
10
  export declare const localAgentCapabilities: {
@@ -13,6 +13,10 @@ export declare const localAgentCapabilities: {
13
13
  /** Ordinary cases run directly on this machine: JSON inputs and pinned input files in,
14
14
  * JSON output and generated files out. */
15
15
  readonly direct: "direct:v1";
16
+ /** Cases pinned to a world that also carry agent-visible input files: `target` receives them
17
+ * with the world and may return generated files. Declare it with `input:<extension>` for each
18
+ * accepted file type; it requires `target`. */
19
+ readonly environmentFiles: "environment-files:v1";
16
20
  };
17
21
  /** Candidate-visible context for one queued local agent execution. */
18
22
  export interface LocalAgentTargetContext {
@@ -47,6 +51,13 @@ export interface LocalAgentTargetContext {
47
51
  /** Credential-bearing provider connections for this callback only. Hue never
48
52
  * checkpoints, logs or adds this response to parity digests. */
49
53
  connectionBundle?: AttemptConnectionBundleV2;
54
+ /** Verified copies of the case's agent-visible input files; evaluator-only files are
55
+ * withheld. Hue offers a world case with such files only to a registration declaring
56
+ * `environment-files:v1`. Empty when the case has none. */
57
+ files: LocalFile[];
58
+ /** Private directory for this case, removed after it; return generated files with
59
+ * `withFiles`. */
60
+ outputDirectory: string;
50
61
  }
51
62
  /** Candidate-visible context for one queued ordinary case run directly on this machine. */
52
63
  export interface LocalAgentDirectContext {
@@ -88,6 +99,12 @@ export interface RunLocalAgentOptions {
88
99
  scorers?: LocalScorer[];
89
100
  /** Cases in flight, between 1 and 16; defaults to 1. */
90
101
  concurrency?: number;
102
+ /** What a case does when its telemetry is not accepted; see
103
+ * `traceNotAccepted` of {@link runExperiment}. Defaults to `"stop"`. */
104
+ traceNotAccepted?: "stop" | "fail_case";
105
+ /** Called as each case is failed for its telemetry; see `onTelemetryNotAccepted` of
106
+ * {@link runExperiment}. */
107
+ onTelemetryNotAccepted?(entry: TelemetryNotAccepted): void | Promise<void>;
91
108
  /** Polling interval in milliseconds, 250–60000; defaults to 2000. */
92
109
  pollIntervalMillis?: number;
93
110
  /** Stops polling cooperatively; does not cancel an active callback. */
@@ -116,8 +133,9 @@ export interface RunLocalAgentOptions {
116
133
  surfaceKey: "google.gmail/mcp" | "slack/mcp";
117
134
  };
118
135
  /** Runs cases pinned to a hosted world (`environment:v1`): invokes the existing agent against
119
- * isolated tools and candidate-safe context. */
120
- target?(inputs: JsonValue, tools: Record<string, EnvironmentTool>, context: LocalAgentTargetContext): JsonValue | undefined | Promise<JsonValue | undefined>;
136
+ * isolated tools and candidate-safe context. Return the output, or `withFiles(output, files)`
137
+ * when it generated files. */
138
+ target?(inputs: JsonValue, tools: Record<string, EnvironmentTool>, context: LocalAgentTargetContext): JsonValue | TargetResult | undefined | Promise<JsonValue | TargetResult | undefined>;
121
139
  /** Runs ordinary cases without a world (`direct:v1`): the agent receives the case inputs
122
140
  * and verified input files and returns JSON output and/or generated files. */
123
141
  directTarget?(inputs: JsonValue, context: LocalAgentDirectContext): JsonValue | TargetResult | undefined | Promise<JsonValue | TargetResult | undefined>;
@@ -10,6 +10,10 @@ export const localAgentCapabilities = {
10
10
  /** Ordinary cases run directly on this machine: JSON inputs and pinned input files in,
11
11
  * JSON output and generated files out. */
12
12
  direct: "direct:v1",
13
+ /** Cases pinned to a world that also carry agent-visible input files: `target` receives them
14
+ * with the world and may return generated files. Declare it with `input:<extension>` for each
15
+ * accepted file type; it requires `target`. */
16
+ environmentFiles: "environment-files:v1",
13
17
  };
14
18
  /** Allowlist the candidate surface for an ordinary case; frozen expectations stay with grading. */
15
19
  export function localAgentDirectContext(context, signal) {
@@ -44,6 +48,8 @@ function localAgentTargetContext(context) {
44
48
  ...(context.connectionBundle
45
49
  ? { connectionBundle: structuredClone(context.connectionBundle) }
46
50
  : {}),
51
+ files: structuredClone(context.files),
52
+ outputDirectory: context.outputDirectory,
47
53
  };
48
54
  }
49
55
  /** The registration's capabilities: explicit values plus one per supplied callback. */
@@ -55,6 +61,8 @@ export function registeredCapabilities(options) {
55
61
  throw new TypeError("direct:v1 requires a directTarget callback");
56
62
  if (declared.includes(localAgentCapabilities.environment) && !options.target)
57
63
  throw new TypeError("environment:v1 requires a target callback");
64
+ if (declared.includes(localAgentCapabilities.environmentFiles) && !options.target)
65
+ throw new TypeError("environment-files:v1 requires a target callback");
58
66
  return [
59
67
  ...new Set([
60
68
  ...declared,
@@ -163,6 +171,12 @@ export async function runLocalAgent(options) {
163
171
  // Worker dispatch uses the case pin: directTarget never receives a world.
164
172
  environmentEvidence: options.directTarget ? "when_pinned" : "required",
165
173
  traceEvidence: { mode: "required" },
174
+ ...(options.traceNotAccepted ? { traceNotAccepted: options.traceNotAccepted } : {}),
175
+ ...(options.onTelemetryNotAccepted
176
+ ? {
177
+ onTelemetryNotAccepted: (entry) => options.onTelemetryNotAccepted(entry),
178
+ }
179
+ : {}),
166
180
  scorers: options.scorers,
167
181
  concurrency: options.concurrency,
168
182
  target: (inputs, context) => {
@@ -93,6 +93,19 @@ export interface RunExperimentOptions extends RunnerOptions {
93
93
  /** Why evidence is omitted, up to 4000 characters. */
94
94
  reason: string;
95
95
  };
96
+ /**
97
+ * What a case does when evidence is required and its trace or logs are not fully accepted.
98
+ * `"stop"` (the default) keeps the saved outcome, leaves the execution started for recovery and
99
+ * rejects. `"fail_case"` completes the execution as failed instead, with the evidence omitted
100
+ * under the reason `telemetry_not_accepted` and without its output or generated files, so no
101
+ * scorer can pass it; reports it in {@link RunnerReport.telemetryNotAccepted} and goes on with
102
+ * the other cases.
103
+ */
104
+ traceNotAccepted?: "stop" | "fail_case";
105
+ /** Called when this call completes a case as failed under `traceNotAccepted: "fail_case"`,
106
+ * before the run goes on, so the failure is known even if a later case stops the run. A call
107
+ * that resumes an already completed case lists it in the report without calling again. */
108
+ onTelemetryNotAccepted?(entry: TelemetryNotAccepted): void | Promise<void>;
96
109
  /** Runs the application for one frozen case; return the output, `withFiles(output, files)`
97
110
  * when it generated files, or `undefined` when unavailable. */
98
111
  target(inputs: JsonValue, context: RunExperimentTargetContext): JsonValue | TargetResult | undefined | Promise<JsonValue | TargetResult | undefined>;
@@ -112,7 +125,39 @@ export interface RunnerReport {
112
125
  resultIds: string[];
113
126
  /** Pins without a local implementation, left pending for their authorized executor. */
114
127
  deferredScorerVersionIds: string[];
128
+ /** Cases completed as failed because their telemetry was not accepted, under
129
+ * `traceNotAccepted: "fail_case"`; absent when there were none. */
130
+ telemetryNotAccepted?: TelemetryNotAccepted[];
115
131
  }
132
+ /** Export issue counts, never content: which signal, what happened, the HTTP status when Hue
133
+ * answered, and how many records. */
134
+ export interface TelemetryIssueCount {
135
+ /** Signal the records belong to. */
136
+ signal: "traces" | "logs";
137
+ /** `rejected` by Hue, `failed` to deliver, `dropped` from the queue, or an `invalid` record. */
138
+ kind: "rejected" | "failed" | "dropped" | "invalid";
139
+ /** HTTP status when the issue came from Hue's answer. */
140
+ status?: number;
141
+ /** Records affected. */
142
+ count: number;
143
+ }
144
+ /** A case whose trace or logs were not fully accepted, completed as failed. */
145
+ export interface TelemetryNotAccepted {
146
+ /** Experiment case (item) ID. */
147
+ caseId: string;
148
+ /** The case's caller-chosen key. */
149
+ caseKey: string;
150
+ /** Execution completed as failed. */
151
+ executionId: string;
152
+ /** Sanitized issues the transport reported while the case's telemetry was exported. */
153
+ issues: TelemetryIssueCount[];
154
+ }
155
+ /** Stable reason, and first word of the omission reason, of a case failed for its telemetry. */
156
+ export declare const TELEMETRY_NOT_ACCEPTED = "telemetry_not_accepted";
157
+ /** The issues of a failed export, summed by signal, kind and status. */
158
+ export declare function telemetryIssueCounts(error: unknown): TelemetryIssueCount[];
159
+ /** `telemetry_not_accepted`, then the issue counts, for example `traces failed 1 (HTTP 400)`. */
160
+ export declare function describeTelemetryIssues(issues: TelemetryIssueCount[]): string;
116
161
  /**
117
162
  * Runs every case of a frozen experiment through `target` on this machine, completes each
118
163
  * execution, scores it with local scorers and uploads the results, checkpointing so an interrupted