@hue-run/sdk 0.11.2 → 0.11.3
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 +32 -10
- package/README.md +4 -2
- package/dist/cli/eval.js +10 -6
- package/dist/cli/listen.js +21 -2
- package/dist/cli/mcp.js +239 -21
- package/dist/evals/checkpoint.d.ts +5 -0
- package/dist/evals/checkpoint.js +17 -1
- package/dist/evals/local-worker.js +9 -5
- package/dist/evals/simulation.js +7 -5
- package/dist/provider-tools.d.ts +3 -1
- package/dist/provider-tools.js +4 -2
- package/dist/tool-definitions.d.ts +14 -3
- package/dist/tool-definitions.js +635 -53
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/CLI.md
CHANGED
|
@@ -42,7 +42,7 @@ project manifest are refused because managers can update ancestor locks; Python
|
|
|
42
42
|
The generated `hue.setup.mjs` or `hue_setup.py` always selects `captureContent: false` /
|
|
43
43
|
`capture_content=False`. For a supported application, setup installs the dependency and adds the
|
|
44
44
|
managed import and middleware registration to the existing entrypoint; an unreferenced helper is
|
|
45
|
-
not a completed integration. TypeScript uses `@hue-run/sdk@0.11.
|
|
45
|
+
not a completed integration. TypeScript uses `@hue-run/sdk@0.11.3`, `@opentelemetry/api@1.9.1` and
|
|
46
46
|
`@opentelemetry/context-async-hooks@2.11.0`; Python setup uses its separately tested package pin.
|
|
47
47
|
Content capture requires an ordinary account-managed key and a later explicit application decision.
|
|
48
48
|
|
|
@@ -255,11 +255,32 @@ again with `--auth key`.
|
|
|
255
255
|
JSON files are parsed and merged: other servers, inputs and top-level fields are kept, only the
|
|
256
256
|
selected server entry is replaced (`hue`, or `hue-<value>` with `--project`), and invalid JSON
|
|
257
257
|
(including comments) is refused together with the snippet
|
|
258
|
-
to add by hand. Files are written
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
258
|
+
to add by hand. Files are written through a temporary file and an atomic rename. A new file is
|
|
259
|
+
owner-only (mode `0600`, narrowed further by the umask), because other servers' literal tokens often
|
|
260
|
+
end up in it; a replaced file keeps its read, write and execute permissions, so a file kept at
|
|
261
|
+
`0600` is never widened, except that other accounts lose write access (they could add a command for
|
|
262
|
+
the client to run). It keeps its group, and its owner when root runs the command (if root cannot
|
|
263
|
+
give the file back, the write fails and the old file stays); when the group cannot be kept, the
|
|
264
|
+
group and other accounts get only the access both had. Setuid, setgid and sticky bits are dropped,
|
|
265
|
+
and an access control list is not kept: the replacement has only the mode bits, whose group bits are
|
|
266
|
+
the list's mask, plus any default list the directory gives new files, whose entries then get up to
|
|
267
|
+
the group's access. Restore a list afterwards if you use one. A symlinked file, or a symlinked
|
|
268
|
+
`.cursor` or `.vscode` directory, is refused; the current directory itself may be reached through a
|
|
269
|
+
symlink. The directory is checked before the file is read and again before the rename, and a
|
|
270
|
+
different file found in place of the one read is refused, but not atomically: an account that can
|
|
271
|
+
write the project directory and races the command can still swap in a link between the last check
|
|
272
|
+
and the rename. `--dry-run` prints the resulting file content or commands without writing or
|
|
273
|
+
running. In printed file content, every header and `env` value, and each other credential it
|
|
274
|
+
recognizes (a known token prefix, the value of an option such as `--api-key VALUE`, `-H VALUE`,
|
|
275
|
+
`-e NAME=VALUE` or `--token=VALUE`, a field or assignment such as `clientSecret`, `key` or
|
|
276
|
+
`STRIPE_KEY=`, a token-like URL path segment, a URL's query values, which read `%5Bredacted%5D`), is
|
|
277
|
+
shown as `[redacted]` unless the whole value is a variable or input reference such as
|
|
278
|
+
`Bearer ${HUE_MCP_KEY}`; the file keeps the values. Hue's own URL shows its `toolsets`, `project`
|
|
279
|
+
and `read_only` selections, with every other query value and any token-like path segment as
|
|
280
|
+
`[redacted]`, there and in the lines that report what the command did; a command printed for you to
|
|
281
|
+
run keeps `--url` as given. `--print` prints only the snippet. Client CLIs run without a shell, so
|
|
282
|
+
the `${HUE_MCP_KEY}` reference reaches them literally; the printed commands use single quotes for
|
|
283
|
+
the same reason, and quote a URL with a query such as `?read_only=true`, whose `?` is a zsh glob.
|
|
263
284
|
|
|
264
285
|
After a key installation the command reminds you to export `HUE_MCP_KEY` in the shell that starts
|
|
265
286
|
the client (VS Code prompts for the key instead). A desktop app started from the Dock or a launcher
|
|
@@ -300,8 +321,9 @@ a later one is a timeout. A failing answer with `x-slack-no-retry: 1` is reporte
|
|
|
300
321
|
retrying it. Only a successful URL verification answer's body (at most 4 KiB, for its challenge) is
|
|
301
322
|
sent to Hue; any other answer body stays on this machine. Each pull waits for at most 20 seconds and
|
|
302
323
|
leases up to `--max` deliveries (1 to 10, default 10), which are forwarded concurrently; the next
|
|
303
|
-
pull starts once they are acknowledged, and a delivery beyond `--max` is never forwarded. A
|
|
304
|
-
that
|
|
324
|
+
pull starts once they are acknowledged, and a delivery beyond `--max` is never forwarded. A waiting
|
|
325
|
+
pull that forwarded something is not repeated until 50 ms after it began, and one that answers at
|
|
326
|
+
once with nothing new to forward is not repeated for a second. A provider retry of
|
|
305
327
|
an event is a new delivery and is forwarded again with its `X-Slack-Retry-Num` and
|
|
306
328
|
`X-Slack-Retry-Reason`, so the bot deduplicates by `event_id` as it does with Slack. A delivery Hue
|
|
307
329
|
hands out a second time is not sent again; its recorded answer is repeated. An acknowledgement Hue
|
|
@@ -432,7 +454,7 @@ node packages/sdk-typescript/scripts/verify-package.mjs --artifacts-dir .artifac
|
|
|
432
454
|
# Set project to an existing supported fixture; use the same directory on resume.
|
|
433
455
|
project=/absolute/path/to/supported-fixture
|
|
434
456
|
node packages/sdk-typescript/scripts/verify-setup-live.mjs \
|
|
435
|
-
--archive .artifacts/typescript/hue-run-sdk-0.11.
|
|
457
|
+
--archive .artifacts/typescript/hue-run-sdk-0.11.3.tgz \
|
|
436
458
|
--origin https://STAGING_ORIGIN \
|
|
437
459
|
--project "$project" --command setup \
|
|
438
460
|
--evidence .context/setup-staging-before-claim.json
|
|
@@ -443,7 +465,7 @@ the private local handoff and finish the real browser claim, then reconcile the
|
|
|
443
465
|
|
|
444
466
|
```sh
|
|
445
467
|
node packages/sdk-typescript/scripts/verify-setup-live.mjs \
|
|
446
|
-
--archive .artifacts/typescript/hue-run-sdk-0.11.
|
|
468
|
+
--archive .artifacts/typescript/hue-run-sdk-0.11.3.tgz \
|
|
447
469
|
--origin https://STAGING_ORIGIN \
|
|
448
470
|
--project "$project" --command claim \
|
|
449
471
|
--evidence .context/setup-staging-after-claim.json
|
package/README.md
CHANGED
|
@@ -165,8 +165,10 @@ call carries `error.type` (`mcp_error`, the provider's status or error code) and
|
|
|
165
165
|
text, credentials scrubbed (URL userinfo, query values and fragments, or the whole URL for a scheme
|
|
166
166
|
other than `http(s)`, `ws(s)` and `ftp`; tokens with a known credential prefix such as `hue_sk_`,
|
|
167
167
|
`sk-` or `xoxb-`; authorization-scheme credentials and an `Authorization` header's whole value;
|
|
168
|
-
the value of a credential-named `key=value` or `key
|
|
169
|
-
|
|
168
|
+
the value of a credential-named `key=value`, `key: value` or `key => value` pair, quoted, bare or
|
|
169
|
+
a whole `[…]` or `{…}` (to the end of the text when it does not close), a quote that does not close
|
|
170
|
+
on its line running to its end; a JSON or `%` escape such as `\n` or `%20` ends a word) and cut to
|
|
171
|
+
1,024 characters and a `…`. An `mcp_list_tools`
|
|
170
172
|
item becomes a `tools/list` child span with that server's `gen_ai.tool.definitions`; with
|
|
171
173
|
`captureContent: false` it carries only `hue.tool.names` and `hue.tool.definitions.sha256`, the
|
|
172
174
|
summary described below. Pass the request so each server's host is recorded as `server.address`
|
package/dist/cli/eval.js
CHANGED
|
@@ -11,7 +11,7 @@ import { createEnvironmentClient } from "../environment/client.js";
|
|
|
11
11
|
import { agentEnvironment, isHueControlPlaneCredential, stripHueControlPlaneCredentials, writeMcpConfig, } from "../environment/world.js";
|
|
12
12
|
import { EvaluationClient, HueApiError } from "../evals/client.js";
|
|
13
13
|
import { TargetResult } from "../evals/types.js";
|
|
14
|
-
import { CheckpointIdentityError, CheckpointStore } from "../evals/checkpoint.js";
|
|
14
|
+
import { CheckpointIdentityError, CheckpointStore, checkpointPath } from "../evals/checkpoint.js";
|
|
15
15
|
import { onForcedExit, runForcedExitCleanups } from "../evals/exit-cleanup.js";
|
|
16
16
|
import { safeFilename } from "../evals/files.js";
|
|
17
17
|
import { digest } from "../evals/json.js";
|
|
@@ -810,9 +810,13 @@ export function explain(error) {
|
|
|
810
810
|
}
|
|
811
811
|
/** `.hue/eval/<agent-key>/<project>/<leaf>` unless overridden; the SDK binds each store to one
|
|
812
812
|
* project and origin, so switching keys or deployments must not collide. */
|
|
813
|
-
async function prepareCheckpointDirectory(explicit, agentKey, projectId, leaf) {
|
|
813
|
+
async function prepareCheckpointDirectory(explicit, agentKey, projectId, ...leaf) {
|
|
814
|
+
const segments = [
|
|
815
|
+
[projectId, "project id"],
|
|
816
|
+
...leaf.map((part) => [part, "checkpoint kind"]),
|
|
817
|
+
];
|
|
814
818
|
if (explicit)
|
|
815
|
-
return
|
|
819
|
+
return checkpointPath(explicit, ...segments);
|
|
816
820
|
const root = resolve(".hue", "eval");
|
|
817
821
|
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
818
822
|
const ignore = join(root, ".gitignore");
|
|
@@ -822,7 +826,7 @@ async function prepareCheckpointDirectory(explicit, agentKey, projectId, leaf) {
|
|
|
822
826
|
catch {
|
|
823
827
|
await writeFile(ignore, "*\n", { flag: "wx", mode: 0o600 }).catch(() => undefined);
|
|
824
828
|
}
|
|
825
|
-
return join(root, agentKey,
|
|
829
|
+
return checkpointPath(join(root, agentKey), ...segments);
|
|
826
830
|
}
|
|
827
831
|
/** Worker-side client that reports registration and claims without changing the worker. */
|
|
828
832
|
class ObservedClient extends EvaluationClient {
|
|
@@ -1210,7 +1214,7 @@ async function runDirect(run, connection) {
|
|
|
1210
1214
|
client,
|
|
1211
1215
|
hue: run.hue,
|
|
1212
1216
|
experimentId,
|
|
1213
|
-
checkpointDirectory:
|
|
1217
|
+
checkpointDirectory: checkpointPath(store.directory, [experimentId, "experiment id"]),
|
|
1214
1218
|
// Outputs, error messages and explanations are stored unless opted out; --content governs
|
|
1215
1219
|
// only the telemetry.
|
|
1216
1220
|
persistResultContent: !values["no-output"],
|
|
@@ -1268,7 +1272,7 @@ async function runWorker(values, connection, adapter, agent, hue, output, signal
|
|
|
1268
1272
|
? undefined
|
|
1269
1273
|
: integer("max-runs", values["max-runs"], 1, 1, 1_000_000);
|
|
1270
1274
|
const project = await client.checkConnection();
|
|
1271
|
-
const checkpointDirectory = await prepareCheckpointDirectory(values["checkpoint-dir"], agent.key, project.id,
|
|
1275
|
+
const checkpointDirectory = await prepareCheckpointDirectory(values["checkpoint-dir"], agent.key, project.id, "worker", slug(agent.revision) || "dev");
|
|
1272
1276
|
let current;
|
|
1273
1277
|
client.onRegistered = (registered) => {
|
|
1274
1278
|
output.log(`Registered agent ${registered.key} (revision ${registered.revision}) with ${connection.baseUrl}; waiting for runs launched from Hue${maxRuns ? ` (stops after ${maxRuns})` : " (Ctrl+C to stop)"}`);
|
package/dist/cli/listen.js
CHANGED
|
@@ -35,6 +35,12 @@ const MAX_RETRY_AFTER_MS = 60_000;
|
|
|
35
35
|
const REMEMBERED_DELIVERIES = 1_024;
|
|
36
36
|
/** The least time between pulls that forwarded nothing new, so repeats cannot spin. */
|
|
37
37
|
const IDLE_PULL_FLOOR_MS = 1_000;
|
|
38
|
+
/** The least time between the starts of pulls that forwarded something, so a Hue that answers
|
|
39
|
+
* every pull at once with new deliveries is pulled at most 20 times a second. */
|
|
40
|
+
const BUSY_PULL_FLOOR_MS = 50;
|
|
41
|
+
/** The most of a text from elsewhere (an error message, a URL) that is scrubbed and printed, in
|
|
42
|
+
* code points, as of a provider's error text; a longer one is cut there and `…` marks the cut. */
|
|
43
|
+
const MAX_FOREIGN_TEXT = 16_384;
|
|
38
44
|
/** A stop request this soon after the first is the same one: under `npx`, npm forwards the
|
|
39
45
|
* terminal's SIGINT to a process group that already received it. */
|
|
40
46
|
const REPEATED_STOP_MS = 1_000;
|
|
@@ -491,7 +497,16 @@ export async function runListenCommand(argv, io = {}) {
|
|
|
491
497
|
out = out.replaceAll(secret, "[redacted]");
|
|
492
498
|
return out.replace(/[^\P{Cc}\n\t]|\p{Cf}/gu, " ");
|
|
493
499
|
};
|
|
494
|
-
const foreign = (text) =>
|
|
500
|
+
const foreign = (text) => {
|
|
501
|
+
let kept = "";
|
|
502
|
+
let seen = 0;
|
|
503
|
+
for (const character of text) {
|
|
504
|
+
if (seen++ === MAX_FOREIGN_TEXT)
|
|
505
|
+
return `${scrubCredentialText(kept, true)}…`;
|
|
506
|
+
kept += character;
|
|
507
|
+
}
|
|
508
|
+
return scrubCredentialText(text);
|
|
509
|
+
};
|
|
495
510
|
const out = (line) => void stdout.write(`${clean(line)}\n`);
|
|
496
511
|
const warn = (line) => void stderr.write(`${clean(line)}\n`);
|
|
497
512
|
let subscription;
|
|
@@ -809,8 +824,12 @@ export async function runListenCommand(argv, io = {}) {
|
|
|
809
824
|
// A pull that could wait but answered at once with nothing new to forward (none due, only
|
|
810
825
|
// deliveries already answered, or ones this client skips) is not repeated at once, so a
|
|
811
826
|
// Hue that hands them out again or refuses their acknowledgements is not pulled in a loop.
|
|
812
|
-
|
|
827
|
+
// One that forwarded something is repeated no sooner than BUSY_PULL_FLOOR_MS after it began.
|
|
828
|
+
const elapsed = performance.now() - pulledAt;
|
|
829
|
+
if (waited && !forwarded.includes(true) && elapsed < IDLE_PULL_FLOOR_MS)
|
|
813
830
|
await sleep(IDLE_PULL_FLOOR_MS, stopping.signal);
|
|
831
|
+
else if (waited && elapsed < BUSY_PULL_FLOOR_MS)
|
|
832
|
+
await sleep(BUSY_PULL_FLOOR_MS - elapsed, stopping.signal);
|
|
814
833
|
}
|
|
815
834
|
}
|
|
816
835
|
finally {
|
package/dist/cli/mcp.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { constants } from "node:fs";
|
|
4
|
-
import { access,
|
|
5
|
-
import { basename, delimiter, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
4
|
+
import { access, lstat, mkdir, open, readFile, rename, stat, unlink } from "node:fs/promises";
|
|
5
|
+
import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
6
6
|
import { parseArgs } from "node:util";
|
|
7
7
|
import { isLoopbackHost } from "../config.js";
|
|
8
|
+
import { isCredentialKey, scrubCredentialText } from "../tool-definitions.js";
|
|
8
9
|
export const DEFAULT_MCP_URL = "https://mcp.hue.run/mcp";
|
|
9
10
|
const SERVER_NAME = "hue";
|
|
10
11
|
const ENV_VAR = "HUE_MCP_KEY";
|
|
@@ -306,6 +307,7 @@ function mergeVscodeInput(root, input, display) {
|
|
|
306
307
|
const others = (inputs ?? []).filter((item) => !(isRecord(item) && item.id === INPUT_ID));
|
|
307
308
|
return { ...root, inputs: [...others, input] };
|
|
308
309
|
}
|
|
310
|
+
/** The parsed file, if any, and the file it was read from, so the write can replace that one. */
|
|
309
311
|
async function readJsonConfig(path, display) {
|
|
310
312
|
let info;
|
|
311
313
|
try {
|
|
@@ -313,7 +315,7 @@ async function readJsonConfig(path, display) {
|
|
|
313
315
|
}
|
|
314
316
|
catch (error) {
|
|
315
317
|
if (error.code === "ENOENT")
|
|
316
|
-
return undefined;
|
|
318
|
+
return { config: undefined };
|
|
317
319
|
throw new ConfigError(`Cannot read ${display}: ${error.message}`);
|
|
318
320
|
}
|
|
319
321
|
if (info.isSymbolicLink())
|
|
@@ -324,40 +326,104 @@ async function readJsonConfig(path, display) {
|
|
|
324
326
|
throw new ConfigError(`Refusing to use ${display}: it is larger than 1 MiB.`);
|
|
325
327
|
const text = await readFile(path, "utf8");
|
|
326
328
|
if (!text.trim())
|
|
327
|
-
return undefined;
|
|
329
|
+
return { config: undefined, file: info };
|
|
328
330
|
try {
|
|
329
|
-
return JSON.parse(text);
|
|
331
|
+
return { config: JSON.parse(text), file: info };
|
|
330
332
|
}
|
|
331
333
|
catch {
|
|
332
334
|
throw new ConfigError(`${display} is not valid JSON (comments are not supported); fix it or add the snippet by hand.`);
|
|
333
335
|
}
|
|
334
336
|
}
|
|
337
|
+
/**
|
|
338
|
+
* Refuses a symbolic link, or anything but a directory, between the working directory and the file
|
|
339
|
+
* (`.cursor` or `.vscode`), as the file itself is refused. The working directory and its ancestors
|
|
340
|
+
* are not checked: on macOS `/tmp` and `/var` are links, and a home directory can be one. This runs
|
|
341
|
+
* before the file is read and again before the rename. Node has no `openat`, so it cannot pin the
|
|
342
|
+
* directory against an account that can write the project and swaps a link in after the last check.
|
|
343
|
+
*/
|
|
344
|
+
async function rejectLinkedParents(cwd, path, display) {
|
|
345
|
+
let current = cwd;
|
|
346
|
+
for (const part of relative(cwd, dirname(path)).split(sep).filter(Boolean)) {
|
|
347
|
+
current = join(current, part);
|
|
348
|
+
let info;
|
|
349
|
+
try {
|
|
350
|
+
info = await lstat(current);
|
|
351
|
+
}
|
|
352
|
+
catch (error) {
|
|
353
|
+
if (error.code === "ENOENT")
|
|
354
|
+
return;
|
|
355
|
+
throw error;
|
|
356
|
+
}
|
|
357
|
+
const shown = displayPath(cwd, current);
|
|
358
|
+
if (info.isSymbolicLink())
|
|
359
|
+
throw new ConfigError(`Refusing to use ${display}: ${shown} is a symbolic link.`);
|
|
360
|
+
if (!info.isDirectory())
|
|
361
|
+
throw new ConfigError(`Refusing to use ${display}: ${shown} is not a directory.`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
/** The file's status, or undefined when there is none; a symbolic link is refused. */
|
|
335
365
|
async function rejectSymlink(path, display) {
|
|
366
|
+
let info;
|
|
336
367
|
try {
|
|
337
|
-
|
|
338
|
-
throw new ConfigError(`Refusing to write ${display}: it is a symbolic link.`);
|
|
368
|
+
info = await lstat(path);
|
|
339
369
|
}
|
|
340
370
|
catch (error) {
|
|
341
371
|
if (error.code === "ENOENT")
|
|
342
|
-
return;
|
|
372
|
+
return undefined;
|
|
343
373
|
throw error;
|
|
344
374
|
}
|
|
375
|
+
if (info.isSymbolicLink())
|
|
376
|
+
throw new ConfigError(`Refusing to write ${display}: it is a symbolic link.`);
|
|
377
|
+
return info;
|
|
345
378
|
}
|
|
346
|
-
/**
|
|
347
|
-
|
|
379
|
+
/**
|
|
380
|
+
* Atomic write: temporary file, fsync, rename. A file it replaces keeps its permission bits, so one
|
|
381
|
+
* kept at 0600 because it holds other servers' tokens is never widened, except that other accounts
|
|
382
|
+
* lose write access; it keeps its group (or, when the group cannot be kept, its group and other
|
|
383
|
+
* accounts get only the access both had), and its owner when root runs the command. A new file is
|
|
384
|
+
* created owner-only, 0600 narrowed by the umask: people and clients add literal tokens to these
|
|
385
|
+
* files.
|
|
386
|
+
*/
|
|
387
|
+
async function writeConfigFile(cwd, path, text, display, read) {
|
|
348
388
|
await mkdir(dirname(path), { recursive: true });
|
|
349
|
-
await rejectSymlink(path, display);
|
|
389
|
+
const existing = await rejectSymlink(path, display);
|
|
390
|
+
// The file replaced must be the one read: the content merged it, and its mode and group are kept.
|
|
391
|
+
// Another file in its place, such as one in a directory swapped in meanwhile, is refused.
|
|
392
|
+
if (existing?.dev !== read?.dev || existing?.ino !== read?.ino)
|
|
393
|
+
throw new ConfigError(`Refusing to write ${display}: it changed while the command ran.`);
|
|
350
394
|
const temporary = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`);
|
|
351
395
|
try {
|
|
352
|
-
const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
|
|
396
|
+
const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
353
397
|
try {
|
|
354
398
|
await handle.writeFile(text, "utf8");
|
|
399
|
+
if (existing) {
|
|
400
|
+
// Other accounts never keep write access: they could add a server command to run.
|
|
401
|
+
let mode = existing.mode & 0o775;
|
|
402
|
+
// The new file has the process's group (the directory's on macOS), which may differ. When
|
|
403
|
+
// the group cannot be kept, its members and everyone else get only what both had.
|
|
404
|
+
const created = await handle.stat();
|
|
405
|
+
if (created.gid !== existing.gid) {
|
|
406
|
+
try {
|
|
407
|
+
await handle.chown(-1, existing.gid);
|
|
408
|
+
}
|
|
409
|
+
catch {
|
|
410
|
+
const shared = (mode >> 3) & mode & 0o007;
|
|
411
|
+
mode = (mode & 0o700) | (shared << 3) | shared;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
await handle.chmod(mode);
|
|
415
|
+
// Root also gives the file back to its owner, last: changing the mode of another account's
|
|
416
|
+
// file would need CAP_FOWNER. If it cannot, the write fails and the old file stays in place,
|
|
417
|
+
// rather than leaving one its owner cannot read.
|
|
418
|
+
if (process.geteuid?.() === 0 && created.uid !== existing.uid)
|
|
419
|
+
await handle.chown(existing.uid, -1);
|
|
420
|
+
}
|
|
355
421
|
await handle.sync();
|
|
356
422
|
}
|
|
357
423
|
finally {
|
|
358
424
|
await handle.close();
|
|
359
425
|
}
|
|
360
|
-
await
|
|
426
|
+
await rejectLinkedParents(cwd, path, display);
|
|
361
427
|
await rejectSymlink(path, display);
|
|
362
428
|
await rename(temporary, path);
|
|
363
429
|
}
|
|
@@ -366,6 +432,147 @@ async function writeConfigFile(path, text, display) {
|
|
|
366
432
|
throw error;
|
|
367
433
|
}
|
|
368
434
|
}
|
|
435
|
+
const REDACTED = "[redacted]";
|
|
436
|
+
/**
|
|
437
|
+
* Only references a client resolves (`${NAME}`, `${env:NAME}`, `${input:id}`), after an optional
|
|
438
|
+
* `Bearer`, `Basic` or `Token` scheme: no credential. A reference with a default does not match.
|
|
439
|
+
*/
|
|
440
|
+
const REFERENCE_ONLY = /^(?:(?:bearer|basic|token)[ \t]+)?(?:\$\{(?:input:[\w.-]+|(?:env:)?[A-Za-z_]\w*)\})+$/iu;
|
|
441
|
+
/** Names `isCredentialKey` leaves out that configurations use for a credential field or option. */
|
|
442
|
+
const CREDENTIAL_NAMES = new Set(["auth", "pat", "bearer", "env"]);
|
|
443
|
+
/** `isCredentialKey`, `CREDENTIAL_NAMES`, or a name ending in `key`, `keys`, `header` or `headers`. */
|
|
444
|
+
function isCredentialName(name) {
|
|
445
|
+
const normalized = name.toLowerCase().replace(/[-_]/g, "");
|
|
446
|
+
return (isCredentialKey(name) ||
|
|
447
|
+
CREDENTIAL_NAMES.has(normalized) ||
|
|
448
|
+
/(?:keys?|headers?)$/u.test(normalized));
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* An `args` option whose next item is its value and names a credential: `--api-key`, `--header`,
|
|
452
|
+
* or `-H`, the header option of curl and `mcp-remote`.
|
|
453
|
+
*/
|
|
454
|
+
function isCredentialOption(item) {
|
|
455
|
+
if (item === "-H")
|
|
456
|
+
return true;
|
|
457
|
+
const name = typeof item === "string" ? /^--?([A-Za-z][\w-]*)$/u.exec(item)?.[1] : undefined;
|
|
458
|
+
return name !== undefined && isCredentialName(name);
|
|
459
|
+
}
|
|
460
|
+
/** `--name=value`, or `NAME=value` as `env` takes it, as an `args` item. */
|
|
461
|
+
const OPTION_VALUE = /^(-{0,2}([A-Za-z][\w-]*)=)(.*)$/su;
|
|
462
|
+
/**
|
|
463
|
+
* The item after Docker's `-e` or `--env`, as an environment value is shown: a variable name alone
|
|
464
|
+
* (passed through from the environment) as is, `NAME=VALUE` with its value redacted. Anything else,
|
|
465
|
+
* such as the script after `node -e`, can hold a literal credential in any shape and is redacted.
|
|
466
|
+
*/
|
|
467
|
+
function redactEnvironmentItem(item) {
|
|
468
|
+
if (/^[A-Za-z_]\w*$/u.test(item))
|
|
469
|
+
return item;
|
|
470
|
+
const assignment = /^([A-Za-z_]\w*)=(.*)$/su.exec(item);
|
|
471
|
+
if (!assignment)
|
|
472
|
+
return REDACTED;
|
|
473
|
+
return REFERENCE_ONLY.test(assignment[2]) ? item : `${assignment[1]}=${REDACTED}`;
|
|
474
|
+
}
|
|
475
|
+
/** A URL path segment long and mixed enough to be a token, as some servers put their key there. */
|
|
476
|
+
const TOKEN_SEGMENT = /^(?=[^/]*[A-Za-z])(?=[^/]*\d)[^/]{16,}$/u;
|
|
477
|
+
/** Whether a URL path segment is token-like, or a credential `scrubCredentialText` knows. */
|
|
478
|
+
function isTokenSegment(segment) {
|
|
479
|
+
return TOKEN_SEGMENT.test(segment) || scrubCredentialText(segment) !== segment;
|
|
480
|
+
}
|
|
481
|
+
/** A URL with each token-like path segment replaced; any other string is returned as is. */
|
|
482
|
+
function redactUrlPath(value) {
|
|
483
|
+
if (!/^(?:https?|wss?):\/\//iu.test(value))
|
|
484
|
+
return value;
|
|
485
|
+
let parsed;
|
|
486
|
+
try {
|
|
487
|
+
parsed = new URL(value);
|
|
488
|
+
}
|
|
489
|
+
catch {
|
|
490
|
+
return value;
|
|
491
|
+
}
|
|
492
|
+
const segments = parsed.pathname.split("/");
|
|
493
|
+
if (!segments.some(isTokenSegment))
|
|
494
|
+
return value;
|
|
495
|
+
parsed.pathname = segments
|
|
496
|
+
.map((segment) => (isTokenSegment(segment) ? REDACTED : segment))
|
|
497
|
+
.join("/");
|
|
498
|
+
return parsed.href;
|
|
499
|
+
}
|
|
500
|
+
/** A query parameter name shown before its redacted value; any other becomes `[redacted]` whole. */
|
|
501
|
+
const PARAMETER_NAME = /^[A-Za-z_][\w.-]{0,31}$/u;
|
|
502
|
+
/**
|
|
503
|
+
* The URL this command writes, as its output shows it: the selections it validated (`toolsets`,
|
|
504
|
+
* `project`, and `read_only` when true or false) are kept; any other query value, and a token-like
|
|
505
|
+
* path segment, becomes `[redacted]`. `parseMcpUrl` has already refused userinfo and a fragment.
|
|
506
|
+
* Commands printed for a person to run keep the URL as given.
|
|
507
|
+
*/
|
|
508
|
+
function displayMcpUrl(url) {
|
|
509
|
+
const parsed = new URL(url);
|
|
510
|
+
const query = parsed.search
|
|
511
|
+
.slice(1)
|
|
512
|
+
.split("&")
|
|
513
|
+
.filter(Boolean)
|
|
514
|
+
.map((pair) => {
|
|
515
|
+
const [name = "", value = ""] = pair.split("=", 2);
|
|
516
|
+
const selection = name === "toolsets" ||
|
|
517
|
+
name === "project" ||
|
|
518
|
+
(name === "read_only" && /^(?:true|false)$/u.test(value));
|
|
519
|
+
if (selection)
|
|
520
|
+
return pair;
|
|
521
|
+
return PARAMETER_NAME.test(name) &&
|
|
522
|
+
!TOKEN_SEGMENT.test(name) &&
|
|
523
|
+
scrubCredentialText(name) === name
|
|
524
|
+
? `${name}=${REDACTED}`
|
|
525
|
+
: REDACTED;
|
|
526
|
+
});
|
|
527
|
+
return redactUrlPath(`${parsed.origin}${parsed.pathname}${query.length ? `?${query.join("&")}` : ""}`);
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* A merged configuration as `--dry-run` prints it. Header and `env` values, strings and numbers
|
|
531
|
+
* under a key naming a credential, the value of a credential option in `args` (`--api-key VALUE`,
|
|
532
|
+
* `--key=VALUE`) and token-like URL path segments become `[redacted]` unless they only reference a
|
|
533
|
+
* variable or input; other strings lose what `scrubCredentialText` finds (a known token prefix, a
|
|
534
|
+
* `token=` pair, a URL's userinfo and query values). The server names under `serversKey` are not
|
|
535
|
+
* read as field names. `url`, the address this command writes, is shown as `shownUrl`.
|
|
536
|
+
*/
|
|
537
|
+
function redactConfig(root, serversKey, url, shownUrl) {
|
|
538
|
+
const redact = (value, secret, depth, names) => {
|
|
539
|
+
if (depth > 256)
|
|
540
|
+
return REDACTED;
|
|
541
|
+
if (typeof value === "number")
|
|
542
|
+
return secret ? REDACTED : value;
|
|
543
|
+
if (typeof value === "string") {
|
|
544
|
+
if (value === url)
|
|
545
|
+
return shownUrl;
|
|
546
|
+
if (REFERENCE_ONLY.test(value))
|
|
547
|
+
return value;
|
|
548
|
+
if (secret)
|
|
549
|
+
return REDACTED;
|
|
550
|
+
const option = OPTION_VALUE.exec(value);
|
|
551
|
+
if (!option)
|
|
552
|
+
return scrubCredentialText(redactUrlPath(value));
|
|
553
|
+
if (REFERENCE_ONLY.test(option[3]))
|
|
554
|
+
return value;
|
|
555
|
+
const shown = isCredentialName(option[2])
|
|
556
|
+
? REDACTED
|
|
557
|
+
: scrubCredentialText(redactUrlPath(option[3]));
|
|
558
|
+
return `${option[1]}${shown}`;
|
|
559
|
+
}
|
|
560
|
+
if (Array.isArray(value))
|
|
561
|
+
return value.map((item, index) => {
|
|
562
|
+
const previous = value[index - 1];
|
|
563
|
+
if (!secret && typeof item === "string" && (previous === "-e" || previous === "--env"))
|
|
564
|
+
return redactEnvironmentItem(item);
|
|
565
|
+
return redact(item, secret || isCredentialOption(previous), depth + 1, false);
|
|
566
|
+
});
|
|
567
|
+
if (!isRecord(value))
|
|
568
|
+
return value;
|
|
569
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [
|
|
570
|
+
key,
|
|
571
|
+
redact(item, secret || (!(names && isRecord(item)) && isCredentialName(key)), depth + 1, depth === 0 && key === serversKey),
|
|
572
|
+
]));
|
|
573
|
+
};
|
|
574
|
+
return redact(root, false, 0, false);
|
|
575
|
+
}
|
|
369
576
|
async function findExecutable(name, env) {
|
|
370
577
|
const searchPath = env.PATH ?? env.Path ?? "";
|
|
371
578
|
const extensions = process.platform === "win32" ? (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";") : [""];
|
|
@@ -653,6 +860,9 @@ export async function runMcpCommand(argv, io = {}) {
|
|
|
653
860
|
toolsets = selected.toolsets;
|
|
654
861
|
}
|
|
655
862
|
url = toolsetsMcpUrl(url, toolsets);
|
|
863
|
+
const shownUrl = displayMcpUrl(url);
|
|
864
|
+
// What the command reports doing; a command a person is to run is printed as given.
|
|
865
|
+
const shownCommand = (command) => command.display.replaceAll(shellWord(url), () => shellWord(shownUrl));
|
|
656
866
|
const plan = planFor(clientId, auth, scope, url, serverName);
|
|
657
867
|
if (parsed.values.print) {
|
|
658
868
|
stdout.write(plan.snippet);
|
|
@@ -669,7 +879,7 @@ export async function runMcpCommand(argv, io = {}) {
|
|
|
669
879
|
if (plan.kind === "cli") {
|
|
670
880
|
if (parsed.values["dry-run"]) {
|
|
671
881
|
for (const command of plan.commands)
|
|
672
|
-
out(`Would run: ${command
|
|
882
|
+
out(`Would run: ${shownCommand(command)}`);
|
|
673
883
|
return 0;
|
|
674
884
|
}
|
|
675
885
|
let failed = false;
|
|
@@ -681,10 +891,10 @@ export async function runMcpCommand(argv, io = {}) {
|
|
|
681
891
|
out(command.display);
|
|
682
892
|
continue;
|
|
683
893
|
}
|
|
684
|
-
out(`Running: ${command
|
|
894
|
+
out(`Running: ${shownCommand(command)}`);
|
|
685
895
|
const code = await runClientCli(executable, command.args, { cwd, env, stdout, stderr });
|
|
686
896
|
if (code === 0) {
|
|
687
|
-
out(`Registered the "${serverName}" MCP server (${
|
|
897
|
+
out(`Registered the "${serverName}" MCP server (${shownUrl}) with ${command.label}.`);
|
|
688
898
|
continue;
|
|
689
899
|
}
|
|
690
900
|
failed = true;
|
|
@@ -698,10 +908,14 @@ export async function runMcpCommand(argv, io = {}) {
|
|
|
698
908
|
}
|
|
699
909
|
const path = resolve(cwd, plan.file);
|
|
700
910
|
const display = displayPath(cwd, path);
|
|
911
|
+
let merged;
|
|
701
912
|
let content;
|
|
913
|
+
let read;
|
|
702
914
|
try {
|
|
915
|
+
await rejectLinkedParents(cwd, path, display);
|
|
703
916
|
const existing = await readJsonConfig(path, display);
|
|
704
|
-
|
|
917
|
+
read = existing.file;
|
|
918
|
+
merged = mergeServerEntry(existing.config, plan.key, plan.entry, display, serverName);
|
|
705
919
|
if (plan.input)
|
|
706
920
|
merged = mergeVscodeInput(merged, plan.input, display);
|
|
707
921
|
content = json(merged);
|
|
@@ -712,17 +926,21 @@ export async function runMcpCommand(argv, io = {}) {
|
|
|
712
926
|
return fail(`Could not read ${display}: ${error.message}`);
|
|
713
927
|
}
|
|
714
928
|
if (parsed.values["dry-run"]) {
|
|
715
|
-
|
|
716
|
-
|
|
929
|
+
// Other servers' entries can hold literal tokens; the file keeps them, the output does not.
|
|
930
|
+
const shown = json(redactConfig(merged, plan.key, url, shownUrl));
|
|
931
|
+
out(shown === content
|
|
932
|
+
? `Would write ${display}:`
|
|
933
|
+
: `Would write ${display} (credential values shown as ${REDACTED}):`);
|
|
934
|
+
stdout.write(shown);
|
|
717
935
|
return 0;
|
|
718
936
|
}
|
|
719
937
|
try {
|
|
720
|
-
await writeConfigFile(path, content, display);
|
|
938
|
+
await writeConfigFile(cwd, path, content, display, read);
|
|
721
939
|
}
|
|
722
940
|
catch (error) {
|
|
723
941
|
return fail(`Could not write ${display}: ${error.message}`);
|
|
724
942
|
}
|
|
725
|
-
out(`Wrote ${display} with the "${serverName}" MCP server (${
|
|
943
|
+
out(`Wrote ${display} with the "${serverName}" MCP server (${shownUrl}).`);
|
|
726
944
|
for (const line of steps)
|
|
727
945
|
out(line);
|
|
728
946
|
return 0;
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
/** A server- or user-provided identifier that is safe as a single checkpoint path component:
|
|
2
|
+
* no separators, no `.`/`..`, and it stays directly inside the directory it is joined onto. */
|
|
3
|
+
export declare function checkpointSegment(value: string, label: string): string;
|
|
4
|
+
/** `join(directory, ...segments)` after validating every segment. */
|
|
5
|
+
export declare function checkpointPath(directory: string, ...segments: [string, string][]): string;
|
|
1
6
|
/** The checkpoint belongs to a run started with a different identity. When only its content
|
|
2
7
|
* policy differs, `startedWith` holds the policy the unfinished run was started with. */
|
|
3
8
|
export declare class CheckpointIdentityError extends Error {
|
package/dist/evals/checkpoint.js
CHANGED
|
@@ -1,10 +1,26 @@
|
|
|
1
1
|
import { constants, rmSync } from "node:fs";
|
|
2
2
|
import { lstat, mkdir, open, rename, rm } from "node:fs/promises";
|
|
3
|
-
import { join, resolve } from "node:path";
|
|
3
|
+
import { join, resolve, sep } from "node:path";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
5
|
import { onForcedExit } from "./exit-cleanup.js";
|
|
6
6
|
import { digest } from "./json.js";
|
|
7
7
|
const CONTENT_POLICY = ["persistResultContent", "captureContent"];
|
|
8
|
+
const SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
9
|
+
/** A server- or user-provided identifier that is safe as a single checkpoint path component:
|
|
10
|
+
* no separators, no `.`/`..`, and it stays directly inside the directory it is joined onto. */
|
|
11
|
+
export function checkpointSegment(value, label) {
|
|
12
|
+
if (typeof value !== "string" || !SEGMENT.test(value))
|
|
13
|
+
throw new Error(`Refusing to use ${label} ${JSON.stringify(value)} in a checkpoint path`);
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
/** `join(directory, ...segments)` after validating every segment. */
|
|
17
|
+
export function checkpointPath(directory, ...segments) {
|
|
18
|
+
const root = resolve(directory);
|
|
19
|
+
const path = resolve(root, ...segments.map(([value, label]) => checkpointSegment(value, label)));
|
|
20
|
+
if (path !== root && !path.startsWith(root.endsWith(sep) ? root : root + sep))
|
|
21
|
+
throw new Error("Checkpoint path escapes its directory");
|
|
22
|
+
return path;
|
|
23
|
+
}
|
|
8
24
|
/** The checkpoint belongs to a run started with a different identity. When only its content
|
|
9
25
|
* policy differs, `startedWith` holds the policy the unfinished run was started with. */
|
|
10
26
|
export class CheckpointIdentityError extends Error {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { CheckpointStore } from "./checkpoint.js";
|
|
3
|
+
import { CheckpointStore, checkpointPath, checkpointSegment } from "./checkpoint.js";
|
|
4
4
|
import { pinRequestedAttemptV2, requestedAttemptV2, runEnvironmentTarget, } from "./environment-target.js";
|
|
5
5
|
import { runExperiment, OutcomeSerializationError, TargetOutcomeUncertainError, UncertainExecutionError, } from "./runner.js";
|
|
6
6
|
/** Capability strings a registration declares; Hue offers only matching cases. */
|
|
@@ -159,14 +159,18 @@ export async function runLocalAgent(options) {
|
|
|
159
159
|
}, Math.min(15_000, Math.max(1_000, interval)));
|
|
160
160
|
let experimentFinished = false;
|
|
161
161
|
try {
|
|
162
|
+
const experimentId = checkpointSegment(claim.experimentId, "experiment id");
|
|
162
163
|
const requested = requestedConfiguration
|
|
163
|
-
? pinRequestedAttemptV2(requestedConfiguration, (await options.client.getExperiment(
|
|
164
|
+
? pinRequestedAttemptV2(requestedConfiguration, (await options.client.getExperiment(experimentId)).config)
|
|
164
165
|
: undefined;
|
|
165
166
|
const report = await runExperiment({
|
|
166
167
|
client: options.client,
|
|
167
168
|
hue: options.hue,
|
|
168
|
-
experimentId
|
|
169
|
-
checkpointDirectory:
|
|
169
|
+
experimentId,
|
|
170
|
+
checkpointDirectory: checkpointPath(directory, [
|
|
171
|
+
`experiment-${experimentId}`,
|
|
172
|
+
"experiment id",
|
|
173
|
+
]),
|
|
170
174
|
persistResultContent: true,
|
|
171
175
|
// Worker dispatch uses the case pin: directTarget never receives a world.
|
|
172
176
|
environmentEvidence: options.directTarget ? "when_pinned" : "required",
|