@hue-run/sdk 0.8.1 → 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.
- package/CLI.md +100 -29
- package/ENVIRONMENTS.md +73 -8
- package/EVALUATIONS.md +39 -19
- package/README.md +51 -22
- package/dist/cli/eval-direct.d.ts +12 -1
- package/dist/cli/eval-direct.js +145 -33
- package/dist/cli/eval.d.ts +28 -1
- package/dist/cli/eval.js +469 -72
- package/dist/client.js +10 -20
- package/dist/environment/client.d.ts +10 -2
- package/dist/environment/client.js +43 -12
- package/dist/environment/types.d.ts +30 -10
- package/dist/environment.d.ts +1 -1
- package/dist/environment.js +1 -1
- package/dist/evals/checkpoint.d.ts +15 -1
- package/dist/evals/checkpoint.js +34 -3
- package/dist/evals/client.d.ts +19 -3
- package/dist/evals/client.js +82 -34
- package/dist/evals/environment-target.d.ts +23 -3
- package/dist/evals/environment-target.js +73 -10
- package/dist/evals/exit-cleanup.d.ts +4 -0
- package/dist/evals/exit-cleanup.js +26 -0
- package/dist/evals/files.d.ts +59 -1
- package/dist/evals/files.js +187 -27
- package/dist/evals/local-worker.d.ts +21 -3
- package/dist/evals/local-worker.js +14 -0
- package/dist/evals/runner.d.ts +45 -0
- package/dist/evals/runner.js +164 -13
- package/dist/evals/scenarios.d.ts +8 -6
- package/dist/evals/scenarios.js +75 -15
- package/dist/evals/simulation.d.ts +17 -4
- package/dist/evals/simulation.js +8 -0
- package/dist/evals/types.d.ts +10 -1
- package/dist/evals/verdicts.d.ts +15 -2
- package/dist/evals/verdicts.js +43 -9
- package/dist/evals.d.ts +4 -3
- package/dist/evals.js +2 -2
- package/dist/inline-files.d.ts +4 -0
- package/dist/inline-files.js +40 -17
- package/dist/provider-tools.js +30 -3
- package/dist/snapshot.d.ts +2 -2
- package/dist/snapshot.js +47 -12
- package/dist/transport.js +2 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -5,7 +5,7 @@ import type { EnvironmentRun, WorldHandoff } from "../environment/types.js";
|
|
|
5
5
|
import type { HueSpan } from "../types.js";
|
|
6
6
|
import { type ActualAgentManifestInputV2, type AttemptBaselineV2, type AttemptConnectionBundleV2, type RequestedAttemptProviderV2, type SurfaceBindingV2 } from "./attempt.js";
|
|
7
7
|
import type { EvaluationClient } from "./client.js";
|
|
8
|
-
import type { ExperimentCase, JsonValue, SimulationMcpCapability } from "./types.js";
|
|
8
|
+
import type { ExperimentCase, JsonValue, LocalFile, SimulationMcpCapability, TargetResult } from "./types.js";
|
|
9
9
|
export type McpSurfaceKeyV2 = Extract<SurfaceBindingV2["surfaceKey"], `${string}/mcp`>;
|
|
10
10
|
export type ActualAgentManifestResolverV2 = ActualAgentManifestInputV2 | ((context: {
|
|
11
11
|
config: JsonValue;
|
|
@@ -64,6 +64,11 @@ export interface EnvironmentTargetContext {
|
|
|
64
64
|
* gateway world has no MCP surface. */
|
|
65
65
|
mcp?: SimulationMcpCapability;
|
|
66
66
|
connectionBundle?: AttemptConnectionBundleV2;
|
|
67
|
+
/** Verified copies of the case's agent-visible input files; evaluator-only files are never
|
|
68
|
+
* among them. Empty when the case has none. */
|
|
69
|
+
files: LocalFile[];
|
|
70
|
+
/** Private directory for this case; return generated files with `withFiles`. */
|
|
71
|
+
outputDirectory: string;
|
|
67
72
|
signal?: AbortSignal;
|
|
68
73
|
}
|
|
69
74
|
interface RunnerTargetContext {
|
|
@@ -71,6 +76,8 @@ interface RunnerTargetContext {
|
|
|
71
76
|
item: ExperimentCase;
|
|
72
77
|
executionId: string;
|
|
73
78
|
span: HueSpan;
|
|
79
|
+
files: LocalFile[];
|
|
80
|
+
outputDirectory: string;
|
|
74
81
|
}
|
|
75
82
|
export interface RunEnvironmentTargetOptions {
|
|
76
83
|
client: EvaluationClient;
|
|
@@ -88,8 +95,21 @@ export interface RunEnvironmentTargetOptions {
|
|
|
88
95
|
* the deployment serves, such as `hue eval`, turns it off. */
|
|
89
96
|
deprecationWarnings?: boolean;
|
|
90
97
|
signal?: AbortSignal;
|
|
98
|
+
/** Test-only: shortens the seal wait. */
|
|
99
|
+
sealTiming?: Partial<SealTiming>;
|
|
91
100
|
onProgress?(event: EnvironmentTargetProgress): void | Promise<void>;
|
|
92
|
-
target(inputs: JsonValue, context: EnvironmentTargetContext): JsonValue | undefined | Promise<JsonValue | undefined>;
|
|
101
|
+
target(inputs: JsonValue, context: EnvironmentTargetContext): JsonValue | TargetResult | undefined | Promise<JsonValue | TargetResult | undefined>;
|
|
102
|
+
}
|
|
103
|
+
/** The completion grace is five seconds; cap an unexpectedly distant timestamp and let reads
|
|
104
|
+
* force the seal after the grace. */
|
|
105
|
+
export declare const MAX_GRACE_WAIT_MS = 10000;
|
|
106
|
+
export declare const SEAL_POLL_MS = 250;
|
|
107
|
+
export declare const SEAL_WAIT_MS = 30000;
|
|
108
|
+
/** The seal wait's bounds. Only tests shorten them; the helpers always use the defaults. */
|
|
109
|
+
export interface SealTiming {
|
|
110
|
+
maxGraceWaitMs: number;
|
|
111
|
+
pollMs: number;
|
|
112
|
+
waitMs: number;
|
|
93
113
|
}
|
|
94
114
|
/** The W3C context of the case span, sent on create so the world span parents on it. The
|
|
95
115
|
* flags are the span's own: an unsampled case span is not exported, and the World API must not
|
|
@@ -131,5 +151,5 @@ export declare function createWorldForExecution(client: Pick<EnvironmentClient,
|
|
|
131
151
|
* outbound local workers. Credential-bearing connections stay in this call frame and
|
|
132
152
|
* are never returned to either runner's checkpoint state.
|
|
133
153
|
*/
|
|
134
|
-
export declare function runEnvironmentTarget(options: RunEnvironmentTargetOptions): Promise<JsonValue | undefined>;
|
|
154
|
+
export declare function runEnvironmentTarget(options: RunEnvironmentTargetOptions): Promise<JsonValue | TargetResult | undefined>;
|
|
135
155
|
export {};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { HueEnvironmentError } from "../environment/client.js";
|
|
2
|
+
import { HueEnvironmentError, isTransientEnvironmentError, } from "../environment/client.js";
|
|
3
3
|
import { bindEnvironmentTools } from "../environment/tools.js";
|
|
4
4
|
import { legacyMcpCapability, worldHandoff } from "../environment/world.js";
|
|
5
5
|
import { actualAgentManifestV2, attemptBaselineV2, projectMcpConnectionV2, requestedAttemptProvidersV2, validateAttemptConnectionBundleV2, } from "./attempt.js";
|
|
@@ -42,22 +42,82 @@ export function pinRequestedAttemptV2(requested, config) {
|
|
|
42
42
|
}
|
|
43
43
|
return { ...requested, expectedAgentManifestDigest: baseline.data.expectedAgentManifestDigest };
|
|
44
44
|
}
|
|
45
|
-
/**
|
|
46
|
-
|
|
45
|
+
/** The completion grace is five seconds; cap an unexpectedly distant timestamp and let reads
|
|
46
|
+
* force the seal after the grace. */
|
|
47
|
+
export const MAX_GRACE_WAIT_MS = 10_000;
|
|
48
|
+
export const SEAL_POLL_MS = 250;
|
|
49
|
+
export const SEAL_WAIT_MS = 30_000;
|
|
50
|
+
const SEAL_TIMING = {
|
|
51
|
+
maxGraceWaitMs: MAX_GRACE_WAIT_MS,
|
|
52
|
+
pollMs: SEAL_POLL_MS,
|
|
53
|
+
waitMs: SEAL_WAIT_MS,
|
|
54
|
+
};
|
|
55
|
+
/** Finish, then wait for the authoritative run to leave open. */
|
|
56
|
+
async function seal(client, runId, executionId, status, timing) {
|
|
57
|
+
let completingUntil;
|
|
47
58
|
try {
|
|
48
|
-
await client.finishRun(runId, {
|
|
59
|
+
const finished = await client.finishRun(runId, {
|
|
49
60
|
idempotencyKey: `execution:${executionId}:${status}`,
|
|
50
61
|
status,
|
|
51
62
|
});
|
|
63
|
+
if (finished.lifecycle === "completing")
|
|
64
|
+
completingUntil = finished.completingUntil ?? null;
|
|
52
65
|
}
|
|
53
66
|
catch (error) {
|
|
54
|
-
// A gateway world answers 409 once it is completing, sealed or expired
|
|
55
|
-
// outcome the caller wanted or the one it can no longer change.
|
|
67
|
+
// A gateway world answers 409 once it is completing, sealed or expired.
|
|
56
68
|
const recovered = await client.getRun(runId).catch(() => undefined);
|
|
57
69
|
if (recovered?.status !== status &&
|
|
58
70
|
recovered?.status !== "expired" &&
|
|
59
71
|
!(recovered?.status === "open" && recovered.lifecycle === "completing"))
|
|
60
72
|
throw new TargetOutcomeUncertainError(executionId, { cause: error });
|
|
73
|
+
if (recovered?.status === "open")
|
|
74
|
+
completingUntil = recovered.completingUntil ?? null;
|
|
75
|
+
}
|
|
76
|
+
if (completingUntil !== undefined) {
|
|
77
|
+
try {
|
|
78
|
+
await awaitSeal(client, runId, completingUntil, timing);
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
throw new TargetOutcomeUncertainError(executionId, { cause: error });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** Wait out a completing world's grace, then read until the server seals it. */
|
|
86
|
+
async function awaitSeal(client, runId, completingUntil, timing) {
|
|
87
|
+
const graceEnd = Date.parse(completingUntil ?? "");
|
|
88
|
+
let wait = Number.isFinite(graceEnd)
|
|
89
|
+
? Math.min(Math.max(0, graceEnd - Date.now()), timing.maxGraceWaitMs)
|
|
90
|
+
: 0;
|
|
91
|
+
const deadline = performance.now() + wait + timing.waitMs;
|
|
92
|
+
for (;;) {
|
|
93
|
+
await new Promise((resolve) => setTimeout(resolve, wait));
|
|
94
|
+
const remaining = deadline - performance.now();
|
|
95
|
+
if (remaining <= 0)
|
|
96
|
+
throw new Error(`World ${runId} was not sealed after its completion grace`);
|
|
97
|
+
// The deadline ends the read and the client's retries inside it.
|
|
98
|
+
const controller = new AbortController();
|
|
99
|
+
const timer = setTimeout(() => controller.abort(), remaining);
|
|
100
|
+
wait = timing.pollMs;
|
|
101
|
+
try {
|
|
102
|
+
if ((await client.getRun(runId, { signal: controller.signal })).status !== "open")
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
if (!isTransientEnvironmentError(error))
|
|
107
|
+
throw error;
|
|
108
|
+
if (performance.now() >= deadline)
|
|
109
|
+
throw new Error(`World ${runId} was not sealed after its completion grace`, {
|
|
110
|
+
cause: error,
|
|
111
|
+
});
|
|
112
|
+
// A 429 or 503 that outlasted the client's retries still says how long to wait.
|
|
113
|
+
wait = Math.max(wait, error.retryAfterMs ?? 0);
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
clearTimeout(timer);
|
|
117
|
+
}
|
|
118
|
+
if (performance.now() >= deadline)
|
|
119
|
+
throw new Error(`World ${runId} was not sealed after its completion grace`);
|
|
120
|
+
wait = Math.min(wait, deadline - performance.now());
|
|
61
121
|
}
|
|
62
122
|
}
|
|
63
123
|
const deprecations = new Set();
|
|
@@ -157,6 +217,7 @@ export async function runEnvironmentTarget(options) {
|
|
|
157
217
|
agentRevision: options.agentRevision,
|
|
158
218
|
});
|
|
159
219
|
const world = worldHandoff(run);
|
|
220
|
+
const timing = { ...SEAL_TIMING, ...options.sealTiming };
|
|
160
221
|
const progress = (event) => options.onProgress?.(event);
|
|
161
222
|
let finalized = false;
|
|
162
223
|
try {
|
|
@@ -217,7 +278,7 @@ export async function runEnvironmentTarget(options) {
|
|
|
217
278
|
: {}),
|
|
218
279
|
});
|
|
219
280
|
if (prepared.status === "environment_incomplete") {
|
|
220
|
-
await seal(options.environmentClient, run.id, context.executionId, "completed");
|
|
281
|
+
await seal(options.environmentClient, run.id, context.executionId, "completed", timing);
|
|
221
282
|
finalized = true;
|
|
222
283
|
await Promise.resolve(progress({ type: "world_sealed", environmentRunId: run.id })).catch(() => undefined);
|
|
223
284
|
return undefined;
|
|
@@ -256,9 +317,11 @@ export async function runEnvironmentTarget(options) {
|
|
|
256
317
|
...(world ? { world } : {}),
|
|
257
318
|
...(mcp ? { mcp } : {}),
|
|
258
319
|
...(connectionBundle ? { connectionBundle } : {}),
|
|
320
|
+
files: structuredClone(context.files),
|
|
321
|
+
outputDirectory: context.outputDirectory,
|
|
259
322
|
signal: options.signal,
|
|
260
323
|
});
|
|
261
|
-
await seal(options.environmentClient, run.id, context.executionId, "completed");
|
|
324
|
+
await seal(options.environmentClient, run.id, context.executionId, "completed", timing);
|
|
262
325
|
finalized = true;
|
|
263
326
|
await Promise.resolve(progress({ type: "world_sealed", environmentRunId: run.id })).catch(() => undefined);
|
|
264
327
|
return output;
|
|
@@ -278,7 +341,7 @@ export async function runEnvironmentTarget(options) {
|
|
|
278
341
|
}
|
|
279
342
|
if (environmentIncomplete) {
|
|
280
343
|
try {
|
|
281
|
-
await seal(options.environmentClient, run.id, context.executionId, "completed");
|
|
344
|
+
await seal(options.environmentClient, run.id, context.executionId, "completed", timing);
|
|
282
345
|
}
|
|
283
346
|
catch (finalizationError) {
|
|
284
347
|
throw new TargetOutcomeUncertainError(context.executionId, {
|
|
@@ -290,7 +353,7 @@ export async function runEnvironmentTarget(options) {
|
|
|
290
353
|
return undefined;
|
|
291
354
|
}
|
|
292
355
|
try {
|
|
293
|
-
await seal(options.environmentClient, run.id, context.executionId, "abandoned");
|
|
356
|
+
await seal(options.environmentClient, run.id, context.executionId, "abandoned", timing);
|
|
294
357
|
}
|
|
295
358
|
catch (finalizationError) {
|
|
296
359
|
throw new TargetOutcomeUncertainError(context.executionId, {
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Register a synchronous removal; the returned function unregisters it. */
|
|
2
|
+
export declare function onForcedExit(cleanup: () => void): () => void;
|
|
3
|
+
/** Run every registered removal, newest first: files before the lock that guards them. */
|
|
4
|
+
export declare function runForcedExitCleanups(): void;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What this process holds on disk while it runs — a world case's files, a checkpoint lock, a
|
|
3
|
+
* token-bearing MCP configuration — each with a synchronous removal. Normal paths remove them
|
|
4
|
+
* themselves and unregister; a forced exit (a second Ctrl+C in `hue eval`) cannot wait for them,
|
|
5
|
+
* so it runs whatever is still registered, newest first, before `process.exit`.
|
|
6
|
+
*/
|
|
7
|
+
const cleanups = new Set();
|
|
8
|
+
/** Register a synchronous removal; the returned function unregisters it. */
|
|
9
|
+
export function onForcedExit(cleanup) {
|
|
10
|
+
cleanups.add(cleanup);
|
|
11
|
+
return () => {
|
|
12
|
+
cleanups.delete(cleanup);
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/** Run every registered removal, newest first: files before the lock that guards them. */
|
|
16
|
+
export function runForcedExitCleanups() {
|
|
17
|
+
for (const cleanup of [...cleanups].reverse()) {
|
|
18
|
+
try {
|
|
19
|
+
cleanup();
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// Best effort on the way out; the rest still run.
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
cleanups.clear();
|
|
26
|
+
}
|
package/dist/evals/files.d.ts
CHANGED
|
@@ -32,13 +32,71 @@ export interface StagedOutputFile {
|
|
|
32
32
|
export declare class OutputFileError extends Error {
|
|
33
33
|
constructor(message: string);
|
|
34
34
|
}
|
|
35
|
-
/**
|
|
35
|
+
/** Stable codes of a pinned file the runner refuses to hand over. */
|
|
36
|
+
export type CaseFileErrorCode = "case_file_mismatch" | "case_file_name_refused";
|
|
37
|
+
/**
|
|
38
|
+
* Thrown when a pinned file cannot be used: its downloaded bytes differ from the manifest's size
|
|
39
|
+
* or SHA-256 (`case_file_mismatch`), or a file meant for an agent working in a world is not
|
|
40
|
+
* named by one safe file name (`case_file_name_refused`). Raised before the case's execution
|
|
41
|
+
* starts, so no execution, world or target call is spent on it.
|
|
42
|
+
*/
|
|
43
|
+
export declare class CaseFileError extends Error {
|
|
44
|
+
/** Stable diagnostic code. */
|
|
45
|
+
readonly code: CaseFileErrorCode;
|
|
46
|
+
/** Hue artifact identity of the refused file. */
|
|
47
|
+
readonly artifactId: string;
|
|
48
|
+
constructor(
|
|
49
|
+
/** Stable diagnostic code. */
|
|
50
|
+
code: CaseFileErrorCode,
|
|
51
|
+
/** Hue artifact identity of the refused file. */
|
|
52
|
+
artifactId: string, message: string);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Whether `name` can be used unchanged as one file name: not empty, `.` or `..`; no `/`, `\`,
|
|
56
|
+
* C0 or C1 control character, or character Windows reserves (`: < > " | ? *`); no Windows device
|
|
57
|
+
* name (`CON`, `NUL`, `COM1`, … with any extension); no trailing dot or space, which Windows
|
|
58
|
+
* drops; at most 200 UTF-8 bytes. An absolute path or a path with a parent step always contains
|
|
59
|
+
* a separator, so it is refused too.
|
|
60
|
+
*/
|
|
61
|
+
export declare function isSafeFileName(name: string): boolean;
|
|
62
|
+
/** Refuse a pinned file whose name is not one safe file name; see {@link isSafeFileName}. */
|
|
63
|
+
export declare function assertSafeFileNames(files: readonly Pick<CaseFile, "artifactId" | "filename">[]): void;
|
|
64
|
+
/**
|
|
65
|
+
* Reduce a declared file name to a single printable path segment, at most 200 UTF-8 bytes. A
|
|
66
|
+
* longer name keeps its extension, so `.pdf` stays `.pdf`, and its stem is shortened and marked
|
|
67
|
+
* with a short hash of the whole name, so two long names never shorten to the same one.
|
|
68
|
+
*/
|
|
36
69
|
export declare function safeFilename(name: string): string;
|
|
70
|
+
/** The device and inode a listing saw for a file, compared with the file actually opened. */
|
|
71
|
+
export interface FileIdentity {
|
|
72
|
+
dev: number;
|
|
73
|
+
ino: number;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Read a file an agent wrote without trusting its path: the last component must not be a
|
|
77
|
+
* symlink and the open file must be a regular file of at most `maxBytes`, checked before any byte
|
|
78
|
+
* is read, and, when `expected` is given, the very file a listing saw. The bytes come from that
|
|
79
|
+
* open file. Returns undefined for a missing file only when nothing was expected there; every
|
|
80
|
+
* other refusal is an `OutputFileError` naming `label`.
|
|
81
|
+
*/
|
|
82
|
+
export declare function readAgentFile(path: string, label: string, maxBytes: number, expected?: FileIdentity): Promise<Uint8Array | undefined>;
|
|
83
|
+
/** Check pinned files against the manifest without keeping them: nothing is written to disk. */
|
|
84
|
+
export declare function verifyCaseFiles(client: EvaluationClient, files: readonly SubjectFile[]): Promise<void>;
|
|
37
85
|
/**
|
|
38
86
|
* Save verified copies of a case's pinned input files under `directory`. A file already
|
|
39
87
|
* present with the pinned identity is reused, so a resumed run does not download again.
|
|
40
88
|
*/
|
|
41
89
|
export declare function downloadCaseFiles(client: EvaluationClient, files: SubjectFile[], directory: string, primaryArtifactId?: string | null): Promise<LocalFile[]>;
|
|
90
|
+
/**
|
|
91
|
+
* Download a case's needed inputs under `caseDirectory`: the agent-visible roles into `inputs/`
|
|
92
|
+
* and evaluator-only files into `evaluator-inputs/`, so the directory the target's files live in
|
|
93
|
+
* never holds an evaluator's file. `all` keeps the manifest order for scorers; `target` is the
|
|
94
|
+
* agent-visible subset.
|
|
95
|
+
*/
|
|
96
|
+
export declare function downloadCaseInputs(client: EvaluationClient, files: CaseFile[], caseDirectory: string): Promise<{
|
|
97
|
+
all: LocalFile[];
|
|
98
|
+
target: LocalFile[];
|
|
99
|
+
}>;
|
|
42
100
|
/** Copy the target's generated files next to the checkpoint and record their identities. */
|
|
43
101
|
export declare function stageOutputFiles(files: OutputFile[], directory: string): Promise<StagedOutputFile[]>;
|
|
44
102
|
/**
|
package/dist/evals/files.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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
|
-
/**
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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>;
|