@alisio/core 0.5.2 → 0.7.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/dist/analysis/janitor.d.ts +48 -0
- package/dist/analysis/janitor.js +170 -18
- package/dist/analysis/jobs.d.ts +11 -2
- package/dist/analysis/jobs.js +13 -0
- package/dist/analysis/oci.d.ts +11 -2
- package/dist/analysis/oci.js +20 -4
- package/dist/analysis/python/sources.js +2 -1
- package/dist/analysis/rerun.d.ts +1 -1
- package/dist/analysis/status.d.ts +3 -1
- package/dist/analysis/status.js +3 -0
- package/dist/application.d.ts +28 -2
- package/dist/application.js +189 -4
- package/dist/changelog/data.js +42 -0
- package/dist/config.d.ts +110 -2
- package/dist/config.js +188 -6
- package/dist/core/call-internals.d.ts +22 -0
- package/dist/core/call-internals.js +9 -0
- package/dist/core/contracts.d.ts +2 -2
- package/dist/core/runner.js +111 -38
- package/dist/eval/canonical.d.ts +14 -0
- package/dist/eval/canonical.js +40 -0
- package/dist/eval/cassette.d.ts +180 -0
- package/dist/eval/cassette.js +424 -0
- package/dist/eval/compare.d.ts +147 -0
- package/dist/eval/compare.js +436 -0
- package/dist/eval/engine.d.ts +144 -0
- package/dist/eval/engine.js +728 -0
- package/dist/eval/export.d.ts +40 -0
- package/dist/eval/export.js +90 -0
- package/dist/eval/fingerprint.d.ts +88 -0
- package/dist/eval/fingerprint.js +186 -0
- package/dist/eval/normalize.d.ts +68 -0
- package/dist/eval/normalize.js +138 -0
- package/dist/eval/paths.d.ts +11 -0
- package/dist/eval/paths.js +50 -0
- package/dist/eval/plan.d.ts +26 -0
- package/dist/eval/plan.js +44 -0
- package/dist/eval/report.d.ts +53 -0
- package/dist/eval/report.js +258 -0
- package/dist/eval/run-suite.d.ts +68 -0
- package/dist/eval/run-suite.js +94 -0
- package/dist/eval/secrets.d.ts +12 -0
- package/dist/eval/secrets.js +45 -0
- package/dist/eval/stats.d.ts +17 -0
- package/dist/eval/stats.js +50 -0
- package/dist/eval/suite.d.ts +20 -0
- package/dist/eval/suite.js +165 -0
- package/dist/eval/variants.d.ts +35 -0
- package/dist/eval/variants.js +137 -0
- package/dist/eval/verifiers.d.ts +49 -0
- package/dist/eval/verifiers.js +264 -0
- package/dist/forge/broker.d.ts +51 -0
- package/dist/forge/broker.js +244 -0
- package/dist/forge/budget.d.ts +27 -0
- package/dist/forge/budget.js +60 -0
- package/dist/forge/catalog-view.d.ts +24 -0
- package/dist/forge/catalog-view.js +43 -0
- package/dist/forge/errors.d.ts +26 -0
- package/dist/forge/errors.js +49 -0
- package/dist/forge/guides/javascript.d.ts +25 -0
- package/dist/forge/guides/javascript.js +90 -0
- package/dist/forge/guides/provider.d.ts +42 -0
- package/dist/forge/guides/provider.js +96 -0
- package/dist/forge/guides/python-environment.d.ts +15 -0
- package/dist/forge/guides/python-environment.js +81 -0
- package/dist/forge/guides/python.d.ts +38 -0
- package/dist/forge/guides/python.js +170 -0
- package/dist/forge/javascript/engine.d.ts +44 -0
- package/dist/forge/javascript/engine.js +229 -0
- package/dist/forge/javascript/hosts/factory.d.ts +25 -0
- package/dist/forge/javascript/hosts/factory.js +21 -0
- package/dist/forge/javascript/hosts/framing.d.ts +17 -0
- package/dist/forge/javascript/hosts/framing.js +49 -0
- package/dist/forge/javascript/hosts/isolate-main.d.ts +9 -0
- package/dist/forge/javascript/hosts/isolate-main.js +63 -0
- package/dist/forge/javascript/hosts/process-entry.d.ts +1 -0
- package/dist/forge/javascript/hosts/process-entry.js +3 -0
- package/dist/forge/javascript/hosts/process-host.d.ts +33 -0
- package/dist/forge/javascript/hosts/process-host.js +160 -0
- package/dist/forge/javascript/hosts/worker-entry.d.ts +1 -0
- package/dist/forge/javascript/hosts/worker-entry.js +3 -0
- package/dist/forge/javascript/hosts/worker-host.d.ts +27 -0
- package/dist/forge/javascript/hosts/worker-host.js +116 -0
- package/dist/forge/javascript/prelude.d.ts +2 -0
- package/dist/forge/javascript/prelude.js +333 -0
- package/dist/forge/javascript/probe.d.ts +2 -0
- package/dist/forge/javascript/probe.js +53 -0
- package/dist/forge/javascript/runtime-manifest.d.ts +14 -0
- package/dist/forge/javascript/runtime-manifest.js +14 -0
- package/dist/forge/javascript/vm-runner.d.ts +21 -0
- package/dist/forge/javascript/vm-runner.js +301 -0
- package/dist/forge/javascript/wasm-asset.d.ts +8 -0
- package/dist/forge/javascript/wasm-asset.js +8 -0
- package/dist/forge/javascript/wasm.d.ts +15 -0
- package/dist/forge/javascript/wasm.js +54 -0
- package/dist/forge/journal.d.ts +105 -0
- package/dist/forge/journal.js +174 -0
- package/dist/forge/kernel.d.ts +76 -0
- package/dist/forge/kernel.js +339 -0
- package/dist/forge/limits.d.ts +38 -0
- package/dist/forge/limits.js +73 -0
- package/dist/forge/nested.d.ts +96 -0
- package/dist/forge/nested.js +214 -0
- package/dist/forge/ports.d.ts +120 -0
- package/dist/forge/ports.js +1 -0
- package/dist/forge/progress.d.ts +24 -0
- package/dist/forge/progress.js +76 -0
- package/dist/forge/protocol.d.ts +110 -0
- package/dist/forge/protocol.js +171 -0
- package/dist/forge/python/backend-selector.d.ts +38 -0
- package/dist/forge/python/backend-selector.js +111 -0
- package/dist/forge/python/backend.d.ts +89 -0
- package/dist/forge/python/backend.js +1 -0
- package/dist/forge/python/engine.d.ts +84 -0
- package/dist/forge/python/engine.js +279 -0
- package/dist/forge/python/frame-parser.d.ts +24 -0
- package/dist/forge/python/frame-parser.js +107 -0
- package/dist/forge/python/frame-transport.d.ts +40 -0
- package/dist/forge/python/frame-transport.js +101 -0
- package/dist/forge/python/import-scan.d.ts +22 -0
- package/dist/forge/python/import-scan.js +133 -0
- package/dist/forge/python/managed-backend.d.ts +30 -0
- package/dist/forge/python/managed-backend.js +111 -0
- package/dist/forge/python/oci-backend.d.ts +27 -0
- package/dist/forge/python/oci-backend.js +84 -0
- package/dist/forge/python/pyodide-backend.d.ts +40 -0
- package/dist/forge/python/pyodide-backend.js +289 -0
- package/dist/forge/python/pyodide-install.d.ts +41 -0
- package/dist/forge/python/pyodide-install.js +167 -0
- package/dist/forge/python/pyodide-manifest.d.ts +42 -0
- package/dist/forge/python/pyodide-manifest.js +376 -0
- package/dist/forge/python/pyodide-runner-source.d.ts +2 -0
- package/dist/forge/python/pyodide-runner-source.js +3 -0
- package/dist/forge/python/rss.d.ts +2 -0
- package/dist/forge/python/rss.js +33 -0
- package/dist/forge/python/scratch-copy.d.ts +6 -0
- package/dist/forge/python/scratch-copy.js +61 -0
- package/dist/forge/python/settle.d.ts +6 -0
- package/dist/forge/python/settle.js +21 -0
- package/dist/forge/python/status.d.ts +8 -0
- package/dist/forge/python/status.js +15 -0
- package/dist/forge/python/wasm-node.d.ts +59 -0
- package/dist/forge/python/wasm-node.js +220 -0
- package/dist/forge/python/wiring.d.ts +39 -0
- package/dist/forge/python/wiring.js +47 -0
- package/dist/forge/registry.d.ts +26 -0
- package/dist/forge/registry.js +83 -0
- package/dist/forge/result.d.ts +58 -0
- package/dist/forge/result.js +86 -0
- package/dist/forge/routing-rule.d.ts +19 -0
- package/dist/forge/routing-rule.js +31 -0
- package/dist/forge/runtime.d.ts +64 -0
- package/dist/forge/runtime.js +88 -0
- package/dist/forge/scratch-files.d.ts +36 -0
- package/dist/forge/scratch-files.js +94 -0
- package/dist/forge/scratch-provider.d.ts +41 -0
- package/dist/forge/scratch-provider.js +79 -0
- package/dist/forge/scratch-quota.d.ts +8 -0
- package/dist/forge/scratch-quota.js +25 -0
- package/dist/forge/selector.d.ts +35 -0
- package/dist/forge/selector.js +51 -0
- package/dist/forge/session-dir.d.ts +59 -0
- package/dist/forge/session-dir.js +239 -0
- package/dist/forge/session-path.d.ts +31 -0
- package/dist/forge/session-path.js +61 -0
- package/dist/forge/slots.d.ts +16 -0
- package/dist/forge/slots.js +47 -0
- package/dist/forge/state-delta.d.ts +54 -0
- package/dist/forge/state-delta.js +99 -0
- package/dist/forge/state-machine.d.ts +20 -0
- package/dist/forge/state-machine.js +40 -0
- package/dist/forge/state-store.d.ts +25 -0
- package/dist/forge/state-store.js +50 -0
- package/dist/forge/task-log.d.ts +9 -0
- package/dist/forge/task-log.js +42 -0
- package/dist/forge/verdict.d.ts +20 -0
- package/dist/forge/verdict.js +28 -0
- package/dist/forge-isolate.d.ts +6 -0
- package/dist/forge-isolate.js +6 -0
- package/dist/index.d.ts +23 -1
- package/dist/index.js +23 -1
- package/dist/permissions/decide.d.ts +42 -0
- package/dist/permissions/decide.js +24 -0
- package/dist/plan/exit-plan.js +2 -1
- package/dist/resources/context.d.ts +2 -1
- package/dist/resources/context.js +2 -1
- package/dist/runtime/access.d.ts +34 -0
- package/dist/runtime/access.js +80 -0
- package/dist/runtime/ids.d.ts +21 -0
- package/dist/runtime/ids.js +41 -1
- package/dist/runtime/process.d.ts +2 -1
- package/dist/runtime/process.js +5 -5
- package/dist/runtime/store.d.ts +11 -0
- package/dist/runtime/store.js +106 -2
- package/dist/tools/analysis.d.ts +25 -2
- package/dist/tools/analysis.js +315 -267
- package/dist/tools/execute.js +6 -7
- package/dist/tools/forge-guide.d.ts +6 -0
- package/dist/tools/forge-guide.js +60 -0
- package/dist/tools/js-run.d.ts +19 -0
- package/dist/tools/js-run.js +126 -0
- package/dist/tools/standard.d.ts +7 -1
- package/dist/tools/standard.js +44 -43
- package/package.json +7 -2
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shareable export of one evaluation run (specs/010 5.4, D9): a single JSON bundle
|
|
3
|
+
* `{ schemaVersion: 1, report, runs[], markdown }` without absolute paths and without secrets.
|
|
4
|
+
* Pure: the caller reads the files and supplies the roots to hide.
|
|
5
|
+
*
|
|
6
|
+
* - Known roots (home, data directory, run directory, temp directories) become placeholders; any
|
|
7
|
+
* other absolute path that remains becomes `<path>`. Scrubbing is pattern-based: it is not a
|
|
8
|
+
* guarantee against every sensitive string a task prompt, verifier detail or tool output holds.
|
|
9
|
+
* - The cassette secret scanner runs over every string of the scrubbed bundle. A hit REFUSES the
|
|
10
|
+
* export (nothing is stripped) and names the field path, never the matched text.
|
|
11
|
+
* - A provider id that is not a plain identifier may embed an endpoint host: it is reduced to
|
|
12
|
+
* `custom-endpoint` everywhere it appears.
|
|
13
|
+
*/
|
|
14
|
+
import type { EvalReport, EvalRunRecord } from "@alisio/sdk";
|
|
15
|
+
export interface EvalExportBundle {
|
|
16
|
+
schemaVersion: 1;
|
|
17
|
+
report: EvalReport;
|
|
18
|
+
runs: EvalRunRecord[];
|
|
19
|
+
markdown: string;
|
|
20
|
+
}
|
|
21
|
+
export interface ExportContext {
|
|
22
|
+
/** Absolute directories to hide, each with the placeholder that replaces it. */
|
|
23
|
+
roots: Array<{
|
|
24
|
+
path: string;
|
|
25
|
+
placeholder: string;
|
|
26
|
+
}>;
|
|
27
|
+
/** Exact credential values to look for verbatim (profile and environment). */
|
|
28
|
+
secrets?: string[];
|
|
29
|
+
}
|
|
30
|
+
/** The export holds something that looks like a secret; `fieldPath` says where, never what. */
|
|
31
|
+
export declare class EvalExportRefusedError extends Error {
|
|
32
|
+
readonly fieldPath: string;
|
|
33
|
+
readonly rules: string[];
|
|
34
|
+
constructor(fieldPath: string, rules: string[]);
|
|
35
|
+
}
|
|
36
|
+
export declare function buildExportBundle(input: {
|
|
37
|
+
report: EvalReport;
|
|
38
|
+
runs: EvalRunRecord[];
|
|
39
|
+
markdown: string;
|
|
40
|
+
}, ctx: ExportContext): EvalExportBundle;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shareable export of one evaluation run (specs/010 5.4, D9): a single JSON bundle
|
|
3
|
+
* `{ schemaVersion: 1, report, runs[], markdown }` without absolute paths and without secrets.
|
|
4
|
+
* Pure: the caller reads the files and supplies the roots to hide.
|
|
5
|
+
*
|
|
6
|
+
* - Known roots (home, data directory, run directory, temp directories) become placeholders; any
|
|
7
|
+
* other absolute path that remains becomes `<path>`. Scrubbing is pattern-based: it is not a
|
|
8
|
+
* guarantee against every sensitive string a task prompt, verifier detail or tool output holds.
|
|
9
|
+
* - The cassette secret scanner runs over every string of the scrubbed bundle. A hit REFUSES the
|
|
10
|
+
* export (nothing is stripped) and names the field path, never the matched text.
|
|
11
|
+
* - A provider id that is not a plain identifier may embed an endpoint host: it is reduced to
|
|
12
|
+
* `custom-endpoint` everywhere it appears.
|
|
13
|
+
*/
|
|
14
|
+
import { findSecrets } from "./secrets.js";
|
|
15
|
+
/** The export holds something that looks like a secret; `fieldPath` says where, never what. */
|
|
16
|
+
export class EvalExportRefusedError extends Error {
|
|
17
|
+
fieldPath;
|
|
18
|
+
rules;
|
|
19
|
+
constructor(fieldPath, rules) {
|
|
20
|
+
super(`Refusing to export: ${fieldPath} looks like it holds a secret (${rules.join(", ")}). Nothing was written. Remove it from the run, or do not share this run.`);
|
|
21
|
+
this.fieldPath = fieldPath;
|
|
22
|
+
this.rules = rules;
|
|
23
|
+
this.name = "EvalExportRefusedError";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const SAFE_PROVIDER = /^[A-Za-z0-9_-]+$/;
|
|
27
|
+
const HOST_FREE_PROVIDER = "custom-endpoint";
|
|
28
|
+
const NAME_CHAR = "[\\w.@+~-]";
|
|
29
|
+
const escapeRegex = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
30
|
+
const trimSlash = (path) => (path.length > 1 ? path.replace(/[\\/]+$/, "") : path);
|
|
31
|
+
const POSIX_PATH = new RegExp(`(?<![\\w.:/<>~-])/(?:${NAME_CHAR}+/)+${NAME_CHAR}*`, "g");
|
|
32
|
+
const WINDOWS_PATH = /\b[A-Za-z]:[\\/][^\s"'<>|,;]+/g;
|
|
33
|
+
function scrubber(ctx, providers) {
|
|
34
|
+
const roots = ctx.roots
|
|
35
|
+
.map((r) => ({ path: trimSlash(r.path), placeholder: r.placeholder }))
|
|
36
|
+
.filter((r) => r.path.length > 1)
|
|
37
|
+
.sort((a, b) => b.path.length - a.path.length)
|
|
38
|
+
.map((r) => ({
|
|
39
|
+
pattern: new RegExp(`${escapeRegex(r.path)}(?!${NAME_CHAR})`, "g"),
|
|
40
|
+
placeholder: r.placeholder,
|
|
41
|
+
}));
|
|
42
|
+
const hosts = [...providers].sort((a, b) => b.length - a.length);
|
|
43
|
+
return (text) => {
|
|
44
|
+
let out = text;
|
|
45
|
+
for (const provider of hosts)
|
|
46
|
+
out = out.split(provider).join(HOST_FREE_PROVIDER);
|
|
47
|
+
for (const root of roots)
|
|
48
|
+
out = out.replace(root.pattern, root.placeholder);
|
|
49
|
+
return out.replace(WINDOWS_PATH, "<path>").replace(POSIX_PATH, "<path>");
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** Rebuilds `value` with `fn` applied to every string (keys included untouched). */
|
|
53
|
+
function mapStrings(value, fn) {
|
|
54
|
+
if (typeof value === "string")
|
|
55
|
+
return fn(value);
|
|
56
|
+
if (Array.isArray(value))
|
|
57
|
+
return value.map((item) => mapStrings(item, fn));
|
|
58
|
+
if (value && typeof value === "object")
|
|
59
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, mapStrings(v, fn)]));
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
/** Calls `visit` with the field path of every string in `value`. */
|
|
63
|
+
function walkStrings(value, path, visit) {
|
|
64
|
+
if (typeof value === "string")
|
|
65
|
+
visit(value, path);
|
|
66
|
+
else if (Array.isArray(value))
|
|
67
|
+
value.forEach((item, index) => walkStrings(item, `${path}[${index}]`, visit));
|
|
68
|
+
else if (value && typeof value === "object")
|
|
69
|
+
for (const [key, item] of Object.entries(value))
|
|
70
|
+
walkStrings(item, path ? `${path}.${key}` : key, visit);
|
|
71
|
+
}
|
|
72
|
+
export function buildExportBundle(input, ctx) {
|
|
73
|
+
const providers = new Set();
|
|
74
|
+
for (const value of [input.report.conditions.provider, ...input.runs.map((r) => r.provider)])
|
|
75
|
+
if (value && !SAFE_PROVIDER.test(value))
|
|
76
|
+
providers.add(value);
|
|
77
|
+
const scrub = scrubber(ctx, [...providers]);
|
|
78
|
+
const bundle = {
|
|
79
|
+
schemaVersion: 1,
|
|
80
|
+
report: mapStrings(input.report, scrub),
|
|
81
|
+
runs: mapStrings(input.runs, scrub),
|
|
82
|
+
markdown: scrub(input.markdown),
|
|
83
|
+
};
|
|
84
|
+
walkStrings(bundle, "", (text, path) => {
|
|
85
|
+
const rules = findSecrets(text, ctx.secrets ?? []);
|
|
86
|
+
if (rules.length > 0)
|
|
87
|
+
throw new EvalExportRefusedError(path, rules);
|
|
88
|
+
});
|
|
89
|
+
return bundle;
|
|
90
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The request fingerprint of the cassettes (specs/010 D10) and its structured divergence diff.
|
|
3
|
+
*
|
|
4
|
+
* What is hashed (after run-scoped normalization, see `normalize.ts`): `instructions`, every
|
|
5
|
+
* message, the tools reduced to `{name, description, inputSchema}` (sorted by name),
|
|
6
|
+
* `maxOutputTokens`, `model`, `nativeTools` and `reasoningEffort`. The spec's field list omitted
|
|
7
|
+
* the last two, but they change what the provider receives. Excluded on purpose: `signal` and
|
|
8
|
+
* `sessionId` (a per-run identity, not part of the request's meaning).
|
|
9
|
+
*
|
|
10
|
+
* The fingerprint is the hash of a PARTS record (one short hash per part), and the cassette stores
|
|
11
|
+
* those parts per call, so a divergence can be explained part by part. The texts needed to show
|
|
12
|
+
* what the recording had (instructions, tool descriptions and schemas, messages) are stored once,
|
|
13
|
+
* capped, in a `CassetteTexts` store.
|
|
14
|
+
*/
|
|
15
|
+
import type { ModelProvider } from "@alisio/sdk";
|
|
16
|
+
import type { Normalizer } from "./normalize.ts";
|
|
17
|
+
export type StreamRequest = Parameters<ModelProvider["stream"]>[0];
|
|
18
|
+
/** Characters of a stored text (message parts, tool descriptions and schemas). */
|
|
19
|
+
export declare const TEXT_CAP = 4096;
|
|
20
|
+
/** Instructions are stored longer: they are the largest single part and are stored once. */
|
|
21
|
+
export declare const INSTRUCTIONS_CAP = 49152;
|
|
22
|
+
export interface FingerprintParts {
|
|
23
|
+
instructions: string;
|
|
24
|
+
messages: string[];
|
|
25
|
+
/** Hash of the tool set (see `CassetteTexts.toolSets`). */
|
|
26
|
+
tools: string;
|
|
27
|
+
maxOutputTokens: number;
|
|
28
|
+
model: string | null;
|
|
29
|
+
reasoningEffort: string | null;
|
|
30
|
+
/** Hash of the native tools, `null` when there are none. */
|
|
31
|
+
nativeTools: string | null;
|
|
32
|
+
}
|
|
33
|
+
/** What is stored about one message so a divergence can say which of its parts changed. */
|
|
34
|
+
export interface MessageView {
|
|
35
|
+
role: string;
|
|
36
|
+
text?: string;
|
|
37
|
+
calls?: string;
|
|
38
|
+
result?: string;
|
|
39
|
+
callId?: string;
|
|
40
|
+
}
|
|
41
|
+
export interface CassetteTexts {
|
|
42
|
+
/** Capped normalized texts by part hash: instructions, tool descriptions, tool schemas, native tools. */
|
|
43
|
+
texts: Record<string, string>;
|
|
44
|
+
/** Tool sets by hash: tool name to the hashes of its description and schema. */
|
|
45
|
+
toolSets: Record<string, Record<string, {
|
|
46
|
+
description: string;
|
|
47
|
+
inputSchema: string;
|
|
48
|
+
}>>;
|
|
49
|
+
/** Message views by message hash. */
|
|
50
|
+
messages: Record<string, MessageView>;
|
|
51
|
+
}
|
|
52
|
+
export declare const emptyStore: () => CassetteTexts;
|
|
53
|
+
export interface FingerprintMaterial {
|
|
54
|
+
fingerprint: string;
|
|
55
|
+
parts: FingerprintParts;
|
|
56
|
+
texts: Record<string, string>;
|
|
57
|
+
toolSet: Record<string, {
|
|
58
|
+
description: string;
|
|
59
|
+
inputSchema: string;
|
|
60
|
+
}>;
|
|
61
|
+
messages: Array<{
|
|
62
|
+
hash: string;
|
|
63
|
+
view: MessageView;
|
|
64
|
+
}>;
|
|
65
|
+
}
|
|
66
|
+
/** Fingerprints a provider request after normalizing it with `normalizer`. */
|
|
67
|
+
export declare function computeFingerprint(request: StreamRequest, normalizer: Normalizer): FingerprintMaterial;
|
|
68
|
+
/** Adds what `material` knows to `store`; returns the message views the store did not have yet. */
|
|
69
|
+
export declare function absorb(store: CassetteTexts, material: FingerprintMaterial): Record<string, MessageView>;
|
|
70
|
+
export interface FingerprintDiffEntry {
|
|
71
|
+
call: number;
|
|
72
|
+
field: string;
|
|
73
|
+
excerpt: {
|
|
74
|
+
before: string;
|
|
75
|
+
after: string;
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
export type FingerprintDiff = FingerprintDiffEntry[];
|
|
79
|
+
/** `radius` characters around the first offset where the texts differ. */
|
|
80
|
+
export declare function excerptAround(before: string, after: string): {
|
|
81
|
+
before: string;
|
|
82
|
+
after: string;
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* What differs between the recorded parts of call `call` and the request actually made, in a fixed
|
|
86
|
+
* order (instructions, messages, tools, scalars), at most 10 entries, excerpts redacted.
|
|
87
|
+
*/
|
|
88
|
+
export declare function diffFingerprints(call: number, recorded: FingerprintParts, store: CassetteTexts, actual: FingerprintMaterial): FingerprintDiff;
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The request fingerprint of the cassettes (specs/010 D10) and its structured divergence diff.
|
|
3
|
+
*
|
|
4
|
+
* What is hashed (after run-scoped normalization, see `normalize.ts`): `instructions`, every
|
|
5
|
+
* message, the tools reduced to `{name, description, inputSchema}` (sorted by name),
|
|
6
|
+
* `maxOutputTokens`, `model`, `nativeTools` and `reasoningEffort`. The spec's field list omitted
|
|
7
|
+
* the last two, but they change what the provider receives. Excluded on purpose: `signal` and
|
|
8
|
+
* `sessionId` (a per-run identity, not part of the request's meaning).
|
|
9
|
+
*
|
|
10
|
+
* The fingerprint is the hash of a PARTS record (one short hash per part), and the cassette stores
|
|
11
|
+
* those parts per call, so a divergence can be explained part by part. The texts needed to show
|
|
12
|
+
* what the recording had (instructions, tool descriptions and schemas, messages) are stored once,
|
|
13
|
+
* capped, in a `CassetteTexts` store.
|
|
14
|
+
*/
|
|
15
|
+
import { canonicalJson, hashCanonical, sha256Hex } from "./canonical.js";
|
|
16
|
+
import { redactSecrets } from "./secrets.js";
|
|
17
|
+
/** Characters of a stored text (message parts, tool descriptions and schemas). */
|
|
18
|
+
export const TEXT_CAP = 4096;
|
|
19
|
+
/** Instructions are stored longer: they are the largest single part and are stored once. */
|
|
20
|
+
export const INSTRUCTIONS_CAP = 49_152;
|
|
21
|
+
const EXCERPT_RADIUS = 120;
|
|
22
|
+
const MAX_DIFF_ENTRIES = 10;
|
|
23
|
+
/** 64 bits of sha256: enough to tell two versions of a part apart (the fingerprint is a full hash). */
|
|
24
|
+
const shortHash = (text) => sha256Hex(text).slice(0, 16);
|
|
25
|
+
const cap = (text, limit) => (text.length > limit ? text.slice(0, limit) : text);
|
|
26
|
+
export const emptyStore = () => ({ texts: {}, toolSets: {}, messages: {} });
|
|
27
|
+
function viewOf(message) {
|
|
28
|
+
switch (message.role) {
|
|
29
|
+
case "user":
|
|
30
|
+
return { role: "user", text: cap(message.text, TEXT_CAP) };
|
|
31
|
+
case "assistant":
|
|
32
|
+
return {
|
|
33
|
+
role: "assistant",
|
|
34
|
+
text: cap(message.text, TEXT_CAP),
|
|
35
|
+
calls: cap(canonicalJson(message.calls), TEXT_CAP),
|
|
36
|
+
};
|
|
37
|
+
default:
|
|
38
|
+
return {
|
|
39
|
+
role: "tool",
|
|
40
|
+
callId: message.callId,
|
|
41
|
+
result: cap(canonicalJson(message.result), TEXT_CAP),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** Fingerprints a provider request after normalizing it with `normalizer`. */
|
|
46
|
+
export function computeFingerprint(request, normalizer) {
|
|
47
|
+
const texts = {};
|
|
48
|
+
const keep = (text, limit = TEXT_CAP) => {
|
|
49
|
+
const hash = shortHash(text);
|
|
50
|
+
texts[hash] = cap(text, limit);
|
|
51
|
+
return hash;
|
|
52
|
+
};
|
|
53
|
+
const instructionsText = normalizer.text(request.instructions);
|
|
54
|
+
const instructions = keep(instructionsText, INSTRUCTIONS_CAP);
|
|
55
|
+
const messages = request.messages.map((raw) => {
|
|
56
|
+
const message = normalizer.value(raw);
|
|
57
|
+
return { hash: shortHash(canonicalJson(message)), view: viewOf(message) };
|
|
58
|
+
});
|
|
59
|
+
const toolSet = {};
|
|
60
|
+
for (const tool of request.tools) {
|
|
61
|
+
const description = normalizer.text(tool.description ?? "");
|
|
62
|
+
const schema = canonicalJson(normalizer.value(tool.inputSchema));
|
|
63
|
+
toolSet[tool.name] = { description: keep(description), inputSchema: keep(schema) };
|
|
64
|
+
}
|
|
65
|
+
const tools = shortHash(canonicalJson(toolSet));
|
|
66
|
+
let nativeTools = null;
|
|
67
|
+
if (request.nativeTools?.length)
|
|
68
|
+
nativeTools = keep(canonicalJson(normalizer.value(request.nativeTools)));
|
|
69
|
+
const parts = {
|
|
70
|
+
instructions,
|
|
71
|
+
messages: messages.map((m) => m.hash),
|
|
72
|
+
tools,
|
|
73
|
+
maxOutputTokens: request.maxOutputTokens,
|
|
74
|
+
model: request.model ?? null,
|
|
75
|
+
reasoningEffort: request.reasoningEffort ?? null,
|
|
76
|
+
nativeTools,
|
|
77
|
+
};
|
|
78
|
+
return { fingerprint: hashCanonical(parts), parts, texts, toolSet, messages };
|
|
79
|
+
}
|
|
80
|
+
/** Adds what `material` knows to `store`; returns the message views the store did not have yet. */
|
|
81
|
+
export function absorb(store, material) {
|
|
82
|
+
Object.assign(store.texts, material.texts);
|
|
83
|
+
store.toolSets[material.parts.tools] = material.toolSet;
|
|
84
|
+
const added = {};
|
|
85
|
+
for (const { hash, view } of material.messages) {
|
|
86
|
+
if (!store.messages[hash]) {
|
|
87
|
+
store.messages[hash] = view;
|
|
88
|
+
added[hash] = view;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return added;
|
|
92
|
+
}
|
|
93
|
+
/** `radius` characters around the first offset where the texts differ. */
|
|
94
|
+
export function excerptAround(before, after) {
|
|
95
|
+
const limit = Math.min(before.length, after.length);
|
|
96
|
+
let at = 0;
|
|
97
|
+
while (at < limit && before[at] === after[at])
|
|
98
|
+
at++;
|
|
99
|
+
const start = Math.max(0, at - EXCERPT_RADIUS);
|
|
100
|
+
const cut = (text) => {
|
|
101
|
+
const end = at + EXCERPT_RADIUS;
|
|
102
|
+
return `${start > 0 ? "…" : ""}${text.slice(start, end)}${text.length > end ? "…" : ""}`;
|
|
103
|
+
};
|
|
104
|
+
return { before: cut(before), after: cut(after) };
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* What differs between the recorded parts of call `call` and the request actually made, in a fixed
|
|
108
|
+
* order (instructions, messages, tools, scalars), at most 10 entries, excerpts redacted.
|
|
109
|
+
*/
|
|
110
|
+
export function diffFingerprints(call, recorded, store, actual) {
|
|
111
|
+
const entries = [];
|
|
112
|
+
const push = (field, before, after, whole = false) => {
|
|
113
|
+
entries.push({
|
|
114
|
+
call,
|
|
115
|
+
field,
|
|
116
|
+
excerpt: whole ? { before, after } : excerptAround(before, after),
|
|
117
|
+
});
|
|
118
|
+
};
|
|
119
|
+
const text = (hash, source) => hash == null ? "" : (source[hash] ?? "(text not stored)");
|
|
120
|
+
const now = actual.parts;
|
|
121
|
+
if (recorded.instructions !== now.instructions)
|
|
122
|
+
push("instructions", text(recorded.instructions, store.texts), text(now.instructions, actual.texts));
|
|
123
|
+
const shared = Math.min(recorded.messages.length, now.messages.length);
|
|
124
|
+
for (let i = 0; i < shared; i++) {
|
|
125
|
+
const was = recorded.messages[i];
|
|
126
|
+
const is = actual.messages[i];
|
|
127
|
+
if (was === undefined || is === undefined || was === is.hash)
|
|
128
|
+
continue;
|
|
129
|
+
const before = store.messages[was];
|
|
130
|
+
const view = is.view;
|
|
131
|
+
if (!before) {
|
|
132
|
+
push(`messages[${i}].${view.role}.other`, "(message not stored)", "(changed)", true);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (before.role !== view.role) {
|
|
136
|
+
push(`messages[${i}].role`, before.role, view.role, true);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
let named = false;
|
|
140
|
+
for (const key of ["text", "calls", "result", "callId"]) {
|
|
141
|
+
if ((before[key] ?? "") !== (view[key] ?? "")) {
|
|
142
|
+
push(`messages[${i}].${view.role}.${key}`, before[key] ?? "", view[key] ?? "");
|
|
143
|
+
named = true;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (!named)
|
|
147
|
+
push(`messages[${i}].${view.role}.other`, "(a part beyond the stored prefix or outside text/calls/result changed)", "(see the request)", true);
|
|
148
|
+
}
|
|
149
|
+
if (recorded.messages.length !== now.messages.length)
|
|
150
|
+
push("messages.length", String(recorded.messages.length), String(now.messages.length), true);
|
|
151
|
+
if (recorded.tools !== now.tools) {
|
|
152
|
+
const was = store.toolSets[recorded.tools] ?? {};
|
|
153
|
+
const is = actual.toolSet;
|
|
154
|
+
const added = Object.keys(is).filter((name) => !(name in was));
|
|
155
|
+
const removed = Object.keys(was).filter((name) => !(name in is));
|
|
156
|
+
if (added.length > 0)
|
|
157
|
+
push("tools.added", "", added.join(", "), true);
|
|
158
|
+
if (removed.length > 0)
|
|
159
|
+
push("tools.removed", removed.join(", "), "", true);
|
|
160
|
+
for (const name of Object.keys(is)) {
|
|
161
|
+
const before = was[name];
|
|
162
|
+
const after = is[name];
|
|
163
|
+
if (!before || !after)
|
|
164
|
+
continue;
|
|
165
|
+
if (before.description !== after.description)
|
|
166
|
+
push(`tools[${name}].description`, text(before.description, store.texts), text(after.description, actual.texts));
|
|
167
|
+
if (before.inputSchema !== after.inputSchema)
|
|
168
|
+
push(`tools[${name}].inputSchema`, text(before.inputSchema, store.texts), text(after.inputSchema, actual.texts));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (recorded.maxOutputTokens !== now.maxOutputTokens)
|
|
172
|
+
push("maxOutputTokens", String(recorded.maxOutputTokens), String(now.maxOutputTokens), true);
|
|
173
|
+
if (recorded.model !== now.model)
|
|
174
|
+
push("model", String(recorded.model), String(now.model), true);
|
|
175
|
+
if (recorded.reasoningEffort !== now.reasoningEffort)
|
|
176
|
+
push("reasoningEffort", String(recorded.reasoningEffort), String(now.reasoningEffort), true);
|
|
177
|
+
if (recorded.nativeTools !== now.nativeTools)
|
|
178
|
+
push("nativeTools", text(recorded.nativeTools, store.texts), text(now.nativeTools, actual.texts));
|
|
179
|
+
return entries.slice(0, MAX_DIFF_ENTRIES).map((entry) => ({
|
|
180
|
+
...entry,
|
|
181
|
+
excerpt: {
|
|
182
|
+
before: redactSecrets(entry.excerpt.before),
|
|
183
|
+
after: redactSecrets(entry.excerpt.after),
|
|
184
|
+
},
|
|
185
|
+
}));
|
|
186
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run-scoped normalization of what a request carries (specs/010 D10), so the same logical request
|
|
3
|
+
* hashes the same in two fresh run directories, sessions and processes while a real regression
|
|
4
|
+
* (a relative path becoming absolute, an extra or missing id) still changes the hash.
|
|
5
|
+
*
|
|
6
|
+
* It rewrites string LEAVES only, never object keys, in three passes:
|
|
7
|
+
* 1. literal replacements computed by the engine at run start (run directory, workspace, slug,
|
|
8
|
+
* workspace key, per-user scratch folder, home, tmp) and the known session/run ids, longest
|
|
9
|
+
* literal first, each with a typed placeholder (`<WS>`, `<RUN>`, `<SESSION#1>`...);
|
|
10
|
+
* 2. generated ids found by pattern (`task_<ULID>`, `fx_<uuid>`, bare UUIDs...) become
|
|
11
|
+
* `<id:kind#n>` where n is the order of first appearance of that exact id;
|
|
12
|
+
* 3. the suite's own `replayNormalize` rules (fixture output noise), versioned with the suite.
|
|
13
|
+
*
|
|
14
|
+
* Tool-call ids and token counts are NOT normalized: the cassette supplies them verbatim.
|
|
15
|
+
*/
|
|
16
|
+
export interface ReplayNormalizeRule {
|
|
17
|
+
pattern: string;
|
|
18
|
+
replace: string;
|
|
19
|
+
}
|
|
20
|
+
export interface NormalizerOptions {
|
|
21
|
+
literals?: Array<{
|
|
22
|
+
literal: string;
|
|
23
|
+
placeholder: string;
|
|
24
|
+
}>;
|
|
25
|
+
replay?: ReplayNormalizeRule[];
|
|
26
|
+
}
|
|
27
|
+
export declare class Normalizer {
|
|
28
|
+
private literals;
|
|
29
|
+
/** One alternation of every literal (longest first): a single pass never rescans a placeholder. */
|
|
30
|
+
private literalRegex;
|
|
31
|
+
private readonly sessions;
|
|
32
|
+
private readonly runs;
|
|
33
|
+
private readonly generated;
|
|
34
|
+
private readonly counters;
|
|
35
|
+
private readonly rules;
|
|
36
|
+
constructor(options?: NormalizerOptions);
|
|
37
|
+
private addLiteral;
|
|
38
|
+
/** `<SESSION#n>`, numbered in order of registration. */
|
|
39
|
+
registerSession(id: string): string;
|
|
40
|
+
registerRun(id: string): string;
|
|
41
|
+
/** The placeholder of a REGISTERED session id (child sessions are not registered). */
|
|
42
|
+
sessionLabel(id: string | undefined): string | undefined;
|
|
43
|
+
text(input: string): string;
|
|
44
|
+
private generatedId;
|
|
45
|
+
/** A copy of `value` with every string leaf normalized (keys untouched). */
|
|
46
|
+
value<T>(value: T): T;
|
|
47
|
+
private walk;
|
|
48
|
+
}
|
|
49
|
+
export interface RunLiteralsInput {
|
|
50
|
+
/** Real path of the run directory (the engine realpaths it). */
|
|
51
|
+
runDir: string;
|
|
52
|
+
/** The workspace copy (`<runDir>/ws`). */
|
|
53
|
+
workspace: string;
|
|
54
|
+
home: string;
|
|
55
|
+
osTmp: string;
|
|
56
|
+
/** `process.getuid()`; absent where the platform has none. */
|
|
57
|
+
uid?: number;
|
|
58
|
+
/** Defaults to the runtime's `workspaceKey(workspace)`. */
|
|
59
|
+
workspaceKey?: string;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The literal replacements of one run: everything whose value depends on where or as whom the run
|
|
63
|
+
* happens. Typed placeholders keep different kinds of path apart (see the module comment).
|
|
64
|
+
*/
|
|
65
|
+
export declare function runLiterals(input: RunLiteralsInput): Array<{
|
|
66
|
+
literal: string;
|
|
67
|
+
placeholder: string;
|
|
68
|
+
}>;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run-scoped normalization of what a request carries (specs/010 D10), so the same logical request
|
|
3
|
+
* hashes the same in two fresh run directories, sessions and processes while a real regression
|
|
4
|
+
* (a relative path becoming absolute, an extra or missing id) still changes the hash.
|
|
5
|
+
*
|
|
6
|
+
* It rewrites string LEAVES only, never object keys, in three passes:
|
|
7
|
+
* 1. literal replacements computed by the engine at run start (run directory, workspace, slug,
|
|
8
|
+
* workspace key, per-user scratch folder, home, tmp) and the known session/run ids, longest
|
|
9
|
+
* literal first, each with a typed placeholder (`<WS>`, `<RUN>`, `<SESSION#1>`...);
|
|
10
|
+
* 2. generated ids found by pattern (`task_<ULID>`, `fx_<uuid>`, bare UUIDs...) become
|
|
11
|
+
* `<id:kind#n>` where n is the order of first appearance of that exact id;
|
|
12
|
+
* 3. the suite's own `replayNormalize` rules (fixture output noise), versioned with the suite.
|
|
13
|
+
*
|
|
14
|
+
* Tool-call ids and token counts are NOT normalized: the cassette supplies them verbatim.
|
|
15
|
+
*/
|
|
16
|
+
import { workspaceSlug } from "../forge/session-path.js";
|
|
17
|
+
import { workspaceKey as keyOf } from "../runtime/paths.js";
|
|
18
|
+
const ULID_PREFIXED = "[a-z][a-z0-9]{1,9}_[0-9A-HJKMNP-TV-Z]{26}";
|
|
19
|
+
const UUID = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
|
|
20
|
+
const GENERATED = new RegExp(`\\b(?:${ULID_PREFIXED}|fx_${UUID}|${UUID})\\b`, "gi");
|
|
21
|
+
/** Smallest literal worth replacing: a one- or two-character literal would corrupt unrelated text. */
|
|
22
|
+
const MIN_LITERAL = 3;
|
|
23
|
+
export class Normalizer {
|
|
24
|
+
literals = [];
|
|
25
|
+
/** One alternation of every literal (longest first): a single pass never rescans a placeholder. */
|
|
26
|
+
literalRegex;
|
|
27
|
+
sessions = new Map();
|
|
28
|
+
runs = new Map();
|
|
29
|
+
generated = new Map();
|
|
30
|
+
counters = new Map();
|
|
31
|
+
rules;
|
|
32
|
+
constructor(options = {}) {
|
|
33
|
+
for (const entry of options.literals ?? [])
|
|
34
|
+
this.addLiteral(entry.literal, entry.placeholder);
|
|
35
|
+
this.rules = (options.replay ?? []).map((rule, index) => {
|
|
36
|
+
try {
|
|
37
|
+
return { regex: new RegExp(rule.pattern, "g"), replace: rule.replace };
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
throw new Error(`replayNormalize rule ${index} has an invalid pattern "${rule.pattern}": ${error instanceof Error ? error.message : String(error)}`);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
addLiteral(literal, placeholder) {
|
|
45
|
+
if (literal.length < MIN_LITERAL)
|
|
46
|
+
return;
|
|
47
|
+
if (this.literals.some((l) => l.literal === literal))
|
|
48
|
+
return;
|
|
49
|
+
this.literals.push({ literal, placeholder });
|
|
50
|
+
this.literals.sort((a, b) => b.literal.length - a.literal.length);
|
|
51
|
+
this.literalRegex = new RegExp(this.literals.map((l) => l.literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|"), "g");
|
|
52
|
+
}
|
|
53
|
+
/** `<SESSION#n>`, numbered in order of registration. */
|
|
54
|
+
registerSession(id) {
|
|
55
|
+
let placeholder = this.sessions.get(id);
|
|
56
|
+
if (!placeholder) {
|
|
57
|
+
placeholder = `<SESSION#${this.sessions.size + 1}>`;
|
|
58
|
+
this.sessions.set(id, placeholder);
|
|
59
|
+
this.addLiteral(id, placeholder);
|
|
60
|
+
}
|
|
61
|
+
return placeholder;
|
|
62
|
+
}
|
|
63
|
+
registerRun(id) {
|
|
64
|
+
let placeholder = this.runs.get(id);
|
|
65
|
+
if (!placeholder) {
|
|
66
|
+
placeholder = `<RUN#${this.runs.size + 1}>`;
|
|
67
|
+
this.runs.set(id, placeholder);
|
|
68
|
+
this.addLiteral(id, placeholder);
|
|
69
|
+
}
|
|
70
|
+
return placeholder;
|
|
71
|
+
}
|
|
72
|
+
/** The placeholder of a REGISTERED session id (child sessions are not registered). */
|
|
73
|
+
sessionLabel(id) {
|
|
74
|
+
return id === undefined ? undefined : this.sessions.get(id);
|
|
75
|
+
}
|
|
76
|
+
text(input) {
|
|
77
|
+
let out = input;
|
|
78
|
+
if (this.literalRegex) {
|
|
79
|
+
const byLiteral = new Map(this.literals.map((l) => [l.literal, l.placeholder]));
|
|
80
|
+
out = out.replace(this.literalRegex, (match) => byLiteral.get(match) ?? match);
|
|
81
|
+
}
|
|
82
|
+
out = out.replace(GENERATED, (match) => this.generatedId(match));
|
|
83
|
+
for (const { regex, replace } of this.rules)
|
|
84
|
+
out = out.replace(regex, replace);
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
generatedId(match) {
|
|
88
|
+
const known = this.generated.get(match);
|
|
89
|
+
if (known)
|
|
90
|
+
return known;
|
|
91
|
+
const kind = match.startsWith("fx_")
|
|
92
|
+
? "fx"
|
|
93
|
+
: match.includes("-") && !match.includes("_")
|
|
94
|
+
? "uuid"
|
|
95
|
+
: match.slice(0, match.indexOf("_"));
|
|
96
|
+
const n = (this.counters.get(kind) ?? 0) + 1;
|
|
97
|
+
this.counters.set(kind, n);
|
|
98
|
+
const placeholder = `<id:${kind}#${n}>`;
|
|
99
|
+
this.generated.set(match, placeholder);
|
|
100
|
+
return placeholder;
|
|
101
|
+
}
|
|
102
|
+
/** A copy of `value` with every string leaf normalized (keys untouched). */
|
|
103
|
+
value(value) {
|
|
104
|
+
return this.walk(value);
|
|
105
|
+
}
|
|
106
|
+
walk(value) {
|
|
107
|
+
if (typeof value === "string")
|
|
108
|
+
return this.text(value);
|
|
109
|
+
if (Array.isArray(value))
|
|
110
|
+
return value.map((item) => this.walk(item));
|
|
111
|
+
if (value && typeof value === "object") {
|
|
112
|
+
const out = {};
|
|
113
|
+
for (const [key, member] of Object.entries(value))
|
|
114
|
+
out[key] = this.walk(member);
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
return value;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* The literal replacements of one run: everything whose value depends on where or as whom the run
|
|
122
|
+
* happens. Typed placeholders keep different kinds of path apart (see the module comment).
|
|
123
|
+
*/
|
|
124
|
+
export function runLiterals(input) {
|
|
125
|
+
const key = input.workspaceKey ?? keyOf(input.workspace);
|
|
126
|
+
const literals = [
|
|
127
|
+
{ literal: input.workspace, placeholder: "<WS>" },
|
|
128
|
+
{ literal: input.runDir, placeholder: "<RUN>" },
|
|
129
|
+
{ literal: workspaceSlug(input.workspace, key), placeholder: "<WS_SLUG>" },
|
|
130
|
+
{ literal: key, placeholder: "<WS_KEY>" },
|
|
131
|
+
{ literal: key.slice(0, 8), placeholder: "<WS_KEY8>" },
|
|
132
|
+
{ literal: input.home, placeholder: "<HOME>" },
|
|
133
|
+
{ literal: input.osTmp, placeholder: "<TMP>" },
|
|
134
|
+
];
|
|
135
|
+
if (input.uid !== undefined)
|
|
136
|
+
literals.push({ literal: `alisio-${input.uid}`, placeholder: "alisio-<UID>" });
|
|
137
|
+
return literals;
|
|
138
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Path confinement shared by the suite loader and the verifiers (specs/010 D1, D4). */
|
|
2
|
+
export declare class EvalPathError extends Error {
|
|
3
|
+
constructor(message: string);
|
|
4
|
+
}
|
|
5
|
+
/** Reason a path string is not a plain relative path inside a directory, or undefined if it is. */
|
|
6
|
+
export declare function unsafeRelativeReason(path: string): string | undefined;
|
|
7
|
+
/**
|
|
8
|
+
* Resolves `rel` under `root` and proves, symlinks included, that the result stays inside `root`.
|
|
9
|
+
* The target does not have to exist. Throws `EvalPathError` otherwise.
|
|
10
|
+
*/
|
|
11
|
+
export declare function resolveInside(root: string, rel: string, what?: string): string;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** Path confinement shared by the suite loader and the verifiers (specs/010 D1, D4). */
|
|
2
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
3
|
+
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
4
|
+
export class EvalPathError extends Error {
|
|
5
|
+
constructor(message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = "EvalPathError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/** Reason a path string is not a plain relative path inside a directory, or undefined if it is. */
|
|
11
|
+
export function unsafeRelativeReason(path) {
|
|
12
|
+
if (path.length === 0)
|
|
13
|
+
return "is empty";
|
|
14
|
+
if (path.includes("\0"))
|
|
15
|
+
return "contains a NUL byte";
|
|
16
|
+
if (isAbsolute(path) || /^[a-zA-Z]:/.test(path) || path.startsWith("\\\\"))
|
|
17
|
+
return "must be relative, not absolute";
|
|
18
|
+
if (path.split(/[\\/]+/).includes(".."))
|
|
19
|
+
return "must not contain '..' segments";
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
/** True when `child` is `parent` or lies below it. */
|
|
23
|
+
function within(parent, child) {
|
|
24
|
+
const rel = relative(parent, child);
|
|
25
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
26
|
+
}
|
|
27
|
+
/** Real path of the nearest existing ancestor of `path` (symlinks resolved). */
|
|
28
|
+
function realAncestor(path) {
|
|
29
|
+
let current = path;
|
|
30
|
+
while (!existsSync(current)) {
|
|
31
|
+
const parent = dirname(current);
|
|
32
|
+
if (parent === current)
|
|
33
|
+
return current;
|
|
34
|
+
current = parent;
|
|
35
|
+
}
|
|
36
|
+
return realpathSync(current);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Resolves `rel` under `root` and proves, symlinks included, that the result stays inside `root`.
|
|
40
|
+
* The target does not have to exist. Throws `EvalPathError` otherwise.
|
|
41
|
+
*/
|
|
42
|
+
export function resolveInside(root, rel, what = "path") {
|
|
43
|
+
const reason = unsafeRelativeReason(rel);
|
|
44
|
+
if (reason)
|
|
45
|
+
throw new EvalPathError(`${what} "${rel}" ${reason}.`);
|
|
46
|
+
const absolute = resolve(root, rel);
|
|
47
|
+
if (!within(realpathSync(root), realAncestor(absolute)))
|
|
48
|
+
throw new EvalPathError(`${what} "${rel}" resolves outside the directory (symlink escape).`);
|
|
49
|
+
return absolute;
|
|
50
|
+
}
|