* feat(environment): extract the initial state a capture's reads observed Add the environment-state analysis view. From the parents the environment manifest accepts, it extracts the data the capture's reads observed before the first related write: entity types with an identity field (proved by a path parameter or inferred from an identity name), entities with their recorded values and provenance, documents for written non-entity paths, and a render plan per read tool that is kept only when it reproduces the tool's latest initial recordings from the state; every other read tool is a recorded view with the reason. The Evidence is always inferred. Response-body decoding moves to a domain owner shared with replay, exact JSON parsing moves to the owner of the exact-number policy, the manifest and state views share parent admission and record grouping, and projectAnalysisView takes its accumulators as one object. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(environment): bound state at redacted writes; infer identity only from items A scenario write whose URL was redacted has no request line, so it was dropped and reads after it counted as initial state. Such a successful write now joins the timeline with an empty path, whose root relates to every type, so it ends every type's state; a limitation counts them. A list whose recordings were all empty passed the "every listed item carries it" check vacuously and published an identity field nothing showed; it now needs at least one item, else the view stays recorded as no-identity. A value that differed between two request lines of one item tool, such as a query variant, was called volatile, and the gate then substituted each recording's own value; only repeats of one request line now show volatility, so differing lines go through fields-differ. List membership is compared only while a list can still be the whole collection, and merged entities are indexed by type and collection once, so paged captures no longer cost pages times entities (8,000 pages of 40,000 entities: 5.7 s to 0.23 s). Docs and the tool contract say scenario state keeps the capture's redaction markers and how redacted writes bound it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(environment): keep first sightings, one list pointer and linear field unions Entities took the order of the first plan observed, and item plans run before list plans, so a complete list read before its detail reads came out reordered and lost "all" membership; each entity now keeps its earliest recorded sighting. A list plan reused the first recording's pointer for later recordings, so a later response that was itself an entity wrapping a list was read as that list; every recording must now locate the same list. The field union scanned its ordered list for every key, quadratic in an object's width; a set now answers membership. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(environment): keep post-write values and partial lists out of the initial state A value that changed between repeats of one request line was named volatile whenever no write under the type's own root lay between, so a write under another root (for example POST /api/dashboards/db before a second GET /api/search) left its effect in the initial state. Any recorded write between the two observations now leaves every tool of the type recorded with reason fields-differ instead. A list was published with membership "all" even when a total or count envelope member exceeded the number it listed. Such a counter now makes the membership "recorded". Also drop a stale doc comment on ObservedEntity.order. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(environment): scope item-shaped and deleted written paths out of documents State documents are held only for list-shaped paths a POST, PUT or PATCH tool writes. Reads of item-shaped written paths and of paths that are only deleted stay recorded views; the guide now says so and the spec defers them to a later increment of #4933. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(environment): count writes in flight when a value changes between reads The volatility check compared write start positions with the two reads, so a write already in flight when the earlier read started was missed and its effect was named volatile. A write now counts when its interval overlaps the span from the earlier read's start to the later read's end. The check also ran once per changed field and scanned every write. It now runs once per pair of observations, answered by a binary search over the writes in timeline order and the latest end among each prefix. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(environment): catch cross-tool changes across writes; read only item totals A value that changed across a write was caught only between repeats of one tool's request line, so a list read before a write and an item read after it put the post-write value into the initial state. Changes are now classified per entity in timeline order after observation: across any recorded write, a change between repeats of a line or between two lines of the type rewrites the type (every tool recorded as fields-differ); with no write between, a repeat's change is volatile. The classification lives in environmentStateMerge.ts. Any numeric member whose name contained "total" or "count" counted as an item total, so total_pages, account_id or page_count made complete lists partial. Item totals are now members whose name tokens include total or count and none of page, pages or per; a string of digits counts as a number; and a set has_more, next or next-page token member also marks the list partial. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(environment): keep post-write observations out of initial state * fix(environment): retain field provenance and bound state inference work * feat(environment): serve tool calls from the environment state `environment-serve --state <state.json>` serves an environment from the initial state an environment-state view extracted. Read tools whose views are state-backed answer from a live state store: item views render the held entity into the recorded envelope, list views render the collection or the recorded members, and document views return the held value; a missing entity or one lacking a required field is refused, and requests the state cannot answer fall back to their recordings with the reason. The /agentenv data plane implements data/reset, data/add (a rea-state/v1 document as a data part, base64 bytes, a file:// path or an https URL, validated and replaced atomically) and data/get, and the card advertises those operations and an intake declaration. Stage 1 is read-only: write tools are not served in state mode. Without --state nothing changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(environment): record verification after merging the state view's review fixes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(environment)!: serve the environment state by default environment-serve derives the environment state at startup by running the environment-state view in-process on the capture with the manifest's template rules, so an environment comes with its state and a user supplies nothing extra. The derived Evidence is the same one inspect-analysis-view produces by hand. --state is removed; --replay-only serves exactly the stateless replay of main. When the state cannot be derived, or its catalog does not fit the discovery budget, the server starts in replay mode, logs the reason and reports it in the stop summary's `state`. Exported bundles serve the state by default too: export derives it the same way, so the verifier's tool count and checked answer match what is served, and the README describes the served mode. BREAKING CHANGE: environment-serve answers read tools from the derived state and does not serve write tools unless --replay-only is given; the stop summary reports `state` instead of `state_evidence_id`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(environment): bound data-plane replies, keep batch order, cancel intake on stop - One /agentenv response is held to 64 MiB: replies are measured as they are produced and one past the budget becomes a -32000 error stating it. - Every message of a batch enters the arrival-order queue on arrival, so another request's reset never lands between them. - Stopping the server, or a caller leaving, cancels the active download and answers queued calls `cancelled` without running them. - file:// URIs name only this host: another host or a network share is refused before anything is opened. - A loaded view value may not give an entity another identity. - List answers refuse a member lacking a field every recording carried, as item answers do. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(environment): keep bodies too deep for the state's nesting out of the state A recorded body at the JSON depth limit was accepted by extraction, then nested a few levels deeper as a document or entity value, so sealing the state Evidence threw and environment-serve and environment-export stopped instead of falling back. stateBodyJson, the one rule extraction and serving share, now treats a body nesting deeper than the limit minus 16 levels as unusable, so its tool stays a recorded view. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(environment): measure data-plane replies before building them 83fcc739 bounded one /agentenv response to 64 MiB, but it still serialized each reply whole before measuring it, so a batch of data/get calls on a large state built every refused reply in full only to drop it. Each reply is now measured in bounded JSON parts (the shared jsonParts encoder), its parts kept only while it fits; a reply past the budget is counted for its exact reply_bytes but never assembled. A test checks that a refused read is never handed to JSON.stringify whole and that its reply_bytes is exact. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(environment): release batch replies as they are encoded; judge exact numbers by value answerBatch kept every settled reply in its pending array until the batch ended, so a batch alternating data/add and data/get held each loaded state alive; each reply now leaves the queue as it is encoded. The exact-number policy judged a number by its recorded spelling, so a state loaded with 1e20 came back from data/get as 100000000000000000000, which data/add refused, breaking the promise that data/get output loads back unchanged. A number is exact only when both its recorded spelling and the one serialization writes are safe, so integers from 2^53 below 1e21 are refused in any spelling; replay keeps such bodies as recorded text. A domain test checks that every accepted number is accepted again once written back. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(environment): keep room for refusals and framing in the reply budget The response budget counted accepted replies only: a budget error that replaced a reply, and a batch's brackets and commas, were not charged, so near 64 MiB later refusals pushed the body past the advertised limit. Every reply still to come now keeps room for its own budget error, and a batch keeps room for its framing, so a refusal always fits and the body never passes the budget. Reply ids and which messages are answered come from one place shared by the replies and the reserve. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(environment): refuse a loaded state that data/get would write past the intake limit data/add measured only the document it was given, while data/get writes the state back with the members a document may omit (types, the state Evidence id, view fields and record citations). A document just under 32 MiB could therefore load and be reported larger than any intake accepts, breaking the data/get then data/add round trip. replace now measures the state as data/get writes it, part by part, and refuses it past the limit, leaving the state unchanged. The limit moves to the state document module, so the application store can read it; the intake re-exports it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(environment): keep merged export checks on the shared test graph Main's export tests installed module mocks for the whole services project, which reuses one module graph. A tarball read failure then missed its importer, and a one-shot heap mock could be taken by another file. Read those through bindings the tests replace for one case. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(environment): keep a state file whose close failed owned until it closes A file:// data/add that could not close its descriptor dropped the handle, and an admission failure whose cleanup close failed dropped its owner, so nothing could retry the close or report it. The read now runs through withOwnedRegularFile, and a failed close is retained in a server-lifetime ArtifactResourceScope. The next file read retries it first and answers -32000 while it is still open; inline and https documents still load. Closing the served environment retries it again and rejects with the unfinished close. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Co-authored-by: gensokyo-dev <gensokyo-dev@users.noreply.github.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: morluto <morluto@users.noreply.github.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
363 lines
13 KiB
JavaScript
363 lines
13 KiB
JavaScript
#!/usr/bin/env node
|
|
import assert from "node:assert/strict";
|
|
import { execFile } from "node:child_process";
|
|
import { randomUUID } from "node:crypto";
|
|
import { access, readFile, writeFile } from "node:fs/promises";
|
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
import { setTimeout as pause } from "node:timers/promises";
|
|
import { fileURLToPath } from "node:url";
|
|
import { promisify } from "node:util";
|
|
import { PrivateRuntimeRoot } from "../dist/process/PrivateRuntimeRoot.js";
|
|
import {
|
|
OwnedCommandFailure,
|
|
runOwnedCommand,
|
|
} from "../dist/process/OwnedCommand.js";
|
|
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
|
|
import { execNpm } from "./lib/verify-package-core.mjs";
|
|
|
|
const AGENTENV_VERSION = "0.9.1334";
|
|
const AGENTENV_PIN = `agentenv-framework ${AGENTENV_VERSION}`;
|
|
// The framework builds its infra images from public ECR, whose anonymous pull quota hosted
|
|
// runners share; a rate refusal earns one retry, anything else fails at once.
|
|
const PUBLIC_ECR_QUOTA =
|
|
/public\.ecr\.aws[\s\S]{0,400}?(?:429 Too Many Requests|toomanyrequests)/u;
|
|
// The anonymous data-transfer cap is counted per source IP, so a pause on the same runner
|
|
// cannot lift it; the receipt names it instead of retrying.
|
|
const PUBLIC_ECR_DATA_LIMIT = /toomanyrequests: Data limit exceeded/u;
|
|
const QUOTA_RETRY_PAUSE_MS = 90_000;
|
|
// Both bundle attempts share this budget inside the 30-minute job; a retry needs real time left.
|
|
const RUN_BUDGET_MS = 22 * 60_000;
|
|
const FIRST_ATTEMPT_MS = 15 * 60_000;
|
|
const MIN_RETRY_MS = 5 * 60_000;
|
|
const TEMPLATE_RULES = [
|
|
"after-uid-literal",
|
|
"opaque-token",
|
|
"sibling-variance",
|
|
];
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
// Both tools are caller-supplied absolute paths, as every real-* lane names its upstream.
|
|
for (const key of ["REA_AGENT_ENV_COMMAND", "REA_DOCKER_COMMAND"])
|
|
if (!isAbsolute(process.env[key] ?? ""))
|
|
throw new Error(
|
|
`verify:agentenv:environment requires absolute ${key}; see docs/testing.md. No dependencies are installed by this verifier.`,
|
|
);
|
|
const agentEnv = process.env.REA_AGENT_ENV_COMMAND;
|
|
const docker = process.env.REA_DOCKER_COMMAND;
|
|
// The framework's local image store keeps its registry:2 helper running across runs; a first
|
|
// run on a fresh host creates it, so it is reported separately rather than counted as a leak.
|
|
const IMAGE_STORE_HELPER = /^agentenv-registry(?:-\d+)?$/u;
|
|
await Promise.all([access(agentEnv), access(docker)]);
|
|
const entrypoint =
|
|
process.argv[2] ?? fileURLToPath(new URL("./rea.mjs", import.meta.url));
|
|
const checkout = resolve(dirname(entrypoint), "..");
|
|
const run = createVerifierRun();
|
|
const runtime = await PrivateRuntimeRoot.create({
|
|
prefix: "rea-agentenv-verifier-",
|
|
});
|
|
const environment = Object.fromEntries(
|
|
Object.entries(process.env).filter(([, value]) => typeof value === "string"),
|
|
);
|
|
const failures = [];
|
|
let cases = 0;
|
|
const receipt = {};
|
|
let knownContainers;
|
|
try {
|
|
const version = await execFileAsync(agentEnv, ["--version"], {
|
|
env: environment,
|
|
timeout: 10_000,
|
|
maxBuffer: 64 * 1024,
|
|
});
|
|
receipt.agent_env_version = version.stdout.trim();
|
|
assert.equal(
|
|
receipt.agent_env_version,
|
|
`agent-env ${AGENTENV_VERSION}`,
|
|
"The verifier requires the pinned framework executable; its observed version is retained in the receipt.",
|
|
);
|
|
const containersBefore = await containers();
|
|
knownContainers = new Set(containersBefore.map((entry) => entry.id));
|
|
receipt.docker_before = {
|
|
containers: containersBefore.length,
|
|
images: (await imageIds()).length,
|
|
};
|
|
// Source-owned capture: the Grafana-shaped HAR builder the view tests use.
|
|
const { environmentManifestHar } = await import(
|
|
new URL("../tests/fixtures/historicalHar.ts", import.meta.url).href
|
|
);
|
|
const harPath = join(runtime.path, "capture.har");
|
|
await writeFile(harPath, JSON.stringify(environmentManifestHar()));
|
|
const capture = await cli(
|
|
"inspect-web-network-capture",
|
|
harPath,
|
|
"har",
|
|
"--format",
|
|
"json",
|
|
);
|
|
assert.equal(capture.operation, "inspect_web_network_capture");
|
|
assert.equal(capture.normalized_result.total_records, 28);
|
|
cases++;
|
|
const viewPath = join(runtime.path, "view.json");
|
|
await writeFile(
|
|
viewPath,
|
|
JSON.stringify({
|
|
source: { kind: "inline", evidence: capture },
|
|
view: { kind: "environment-manifest", template_rules: TEMPLATE_RULES },
|
|
}),
|
|
);
|
|
const manifest = await cli(
|
|
"inspect-analysis-view",
|
|
viewPath,
|
|
"--format",
|
|
"json",
|
|
);
|
|
assert.equal(manifest.normalized_result.kind, "environment-manifest");
|
|
assert.equal(
|
|
manifest.normalized_result.parent_evidence_id,
|
|
capture.evidence_id,
|
|
);
|
|
assert.equal(manifest.normalized_result.name, "grafana-example");
|
|
const toolCount = manifest.normalized_result.tools.length;
|
|
assert.ok(toolCount > 0);
|
|
cases++;
|
|
const capturePath = join(runtime.path, "capture.json");
|
|
const manifestPath = join(runtime.path, "manifest.json");
|
|
await writeFile(capturePath, JSON.stringify(capture));
|
|
await writeFile(manifestPath, JSON.stringify(manifest));
|
|
// The lane proves this checkout, not the registry package: pack it and install the tarball in the image.
|
|
const { stdout: packed } = await execNpm(
|
|
["pack", "--ignore-scripts", "--json", "--pack-destination", runtime.path],
|
|
{ cwd: checkout, env: environment, maxBuffer: 16 * 1024 * 1024 },
|
|
);
|
|
const tarball = join(runtime.path, packedFilename(JSON.parse(packed)));
|
|
await access(tarball);
|
|
const bundle = join(runtime.path, "grafana-example");
|
|
const exported = await cli(
|
|
"environment-export",
|
|
manifestPath,
|
|
"--evidence",
|
|
capturePath,
|
|
bundle,
|
|
"--package-tarball",
|
|
tarball,
|
|
);
|
|
assert.equal(exported.status, "exported");
|
|
assert.equal(exported.name, "grafana-example");
|
|
// The bundle serves the state its server derives from the capture, which leaves the
|
|
// manifest's write tools unserved; the export reports the count the bundle serves.
|
|
assert.equal(exported.state.served, true);
|
|
const servedCount = exported.tool_count;
|
|
assert.ok(servedCount > 0 && servedCount < toolCount);
|
|
assert.equal(exported.package_source.kind, "tarball");
|
|
assert.deepEqual(exported.files.map((file) => file.path).sort(), [
|
|
"README.md",
|
|
"artifacts/check_grafana-example/verify.py",
|
|
"envs/grafana-example/Dockerfile",
|
|
"envs/grafana-example/env.toml",
|
|
"envs/grafana-example/evidence.json",
|
|
"envs/grafana-example/manifest.json",
|
|
"envs/grafana-example/rea-agents.tgz",
|
|
"tasks/smoke.json",
|
|
]);
|
|
assert.equal(
|
|
await readFile(join(bundle, "envs/grafana-example/manifest.json"), "utf8"),
|
|
await readFile(manifestPath, "utf8"),
|
|
);
|
|
const dockerfile = await readFile(
|
|
join(bundle, "envs/grafana-example/Dockerfile"),
|
|
"utf8",
|
|
);
|
|
assert.ok(dockerfile.includes("COPY rea-agents.tgz /tmp/"));
|
|
assert.ok(dockerfile.includes("/tmp/rea-agents.tgz"));
|
|
cases++;
|
|
// The agentenv runner builds the image, deploys it on the local sandbox and runs the verifier.
|
|
// One deadline for both attempts, well under the 30-minute job, so a hung or retried run ends
|
|
// with this receipt rather than the runner's kill.
|
|
const started = Date.now();
|
|
const deadline = started + RUN_BUDGET_MS;
|
|
let attempts = 0;
|
|
const runBundle = async (timeoutMs) => {
|
|
attempts += 1;
|
|
let outcome;
|
|
try {
|
|
outcome = await runOwnedCommand(
|
|
{
|
|
command: agentEnv,
|
|
arguments: ["run", bundle, "--sandbox", "local"],
|
|
cwd: runtime.path,
|
|
runId: `rea-agentenv-run-${randomUUID()}`,
|
|
hostEnvironment: environment,
|
|
},
|
|
{ timeoutMs, diagnosticBytes: 4 * 1024 * 1024 },
|
|
);
|
|
return outcome;
|
|
} catch (cause) {
|
|
outcome =
|
|
cause instanceof OwnedCommandFailure ? cause.snapshot : undefined;
|
|
throw cause;
|
|
} finally {
|
|
// Recorded before the outcome is judged, so a failed receipt still says how the last
|
|
// run ended, how many runs there were and whether the registry quota refused it.
|
|
receipt.agent_env_run = runRecord(
|
|
outcome,
|
|
Date.now() - started,
|
|
attempts,
|
|
);
|
|
}
|
|
};
|
|
let snapshot;
|
|
try {
|
|
snapshot = await runBundle(
|
|
Math.min(FIRST_ATTEMPT_MS, deadline - Date.now()),
|
|
);
|
|
} catch (cause) {
|
|
if (
|
|
receipt.agent_env_run?.public_ecr_quota !== "rate" ||
|
|
deadline - Date.now() - QUOTA_RETRY_PAUSE_MS < MIN_RETRY_MS
|
|
)
|
|
throw cause;
|
|
await pause(QUOTA_RETRY_PAUSE_MS);
|
|
snapshot = await runBundle(deadline - Date.now());
|
|
}
|
|
assert.equal(snapshot.exitCode, 0);
|
|
assert.match(snapshot.stdout.text, /tasks\/smoke\.json v\d+: passed/u);
|
|
assert.match(
|
|
snapshot.stdout.text,
|
|
new RegExp(
|
|
`serve: listed ${String(servedCount)} tools, expected ${String(servedCount)};`,
|
|
"u",
|
|
),
|
|
);
|
|
cases++;
|
|
} catch (cause) {
|
|
failures.push(cause);
|
|
} finally {
|
|
// The container inventory runs whether the bundle passed, failed or timed out, so a failed
|
|
// receipt still says what the run left behind.
|
|
if (knownContainers !== undefined)
|
|
try {
|
|
const containersAfter = await containers();
|
|
const added = containersAfter.filter(
|
|
(entry) => !knownContainers.has(entry.id),
|
|
);
|
|
const helpers = added.filter((entry) =>
|
|
IMAGE_STORE_HELPER.test(entry.name),
|
|
);
|
|
const leaked = added.filter(
|
|
(entry) => !IMAGE_STORE_HELPER.test(entry.name),
|
|
);
|
|
receipt.docker_after = {
|
|
containers: containersAfter.length,
|
|
images: (await imageIds()).length,
|
|
image_store_helpers: helpers.map((entry) => entry.name),
|
|
new_containers: leaked.map((entry) => `${entry.id} ${entry.name}`),
|
|
};
|
|
assert.deepEqual(leaked, []);
|
|
if (failures.length === 0) cases++;
|
|
} catch (cause) {
|
|
failures.push(cause);
|
|
}
|
|
try {
|
|
await runtime.close();
|
|
} catch (cause) {
|
|
failures.push(cause);
|
|
}
|
|
}
|
|
const verifier = await completeVerifierRun(run);
|
|
try {
|
|
assert.equal(verifier.process_lineage.status, "verified");
|
|
assert.deepEqual(verifier.process_lineage.descendants, []);
|
|
} catch (cause) {
|
|
failures.push(cause);
|
|
}
|
|
if (failures.length > 0) {
|
|
console.error(
|
|
JSON.stringify(
|
|
{ status: "failed", public_cases: cases, ...receipt, verifier },
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
throw new AggregateError(
|
|
failures,
|
|
"Environment bundle verification failed; export, run and cleanup failures are retained.",
|
|
);
|
|
}
|
|
console.log(
|
|
JSON.stringify(
|
|
{
|
|
status: "passed",
|
|
public_cases: cases,
|
|
upstream: AGENTENV_PIN,
|
|
runner: `${process.platform} ${process.arch}; host Docker`,
|
|
offline: false,
|
|
...receipt,
|
|
verifier,
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
|
|
async function cli(...args) {
|
|
const { stdout } = await execFileAsync(
|
|
process.execPath,
|
|
[entrypoint, ...args, "--json"],
|
|
{ env: environment, cwd: runtime.path, maxBuffer: 64 * 1024 * 1024 },
|
|
);
|
|
return JSON.parse(stdout);
|
|
}
|
|
|
|
// The receipt's view of one `agent-env run`, passed or failed; `outcome` is absent when the
|
|
// command failed before producing a snapshot.
|
|
function runRecord(outcome, elapsedMs, attempts) {
|
|
const quota = publicEcrQuota(outcome?.stderr.text ?? "");
|
|
return {
|
|
exit_code: outcome?.exitCode ?? null,
|
|
...(outcome?.signal ? { signal: outcome.signal } : {}),
|
|
seconds: Math.round(elapsedMs / 1000),
|
|
attempts,
|
|
...(attempts > 1
|
|
? { retried_for: "public ECR anonymous pull rate limit (429)" }
|
|
: {}),
|
|
...(quota === undefined ? {} : { public_ecr_quota: quota }),
|
|
stdout_tail: (outcome?.stdout.text ?? "").trim().split("\n").slice(-6),
|
|
};
|
|
}
|
|
|
|
// Which public ECR refusal ended a run: the per-IP data cap or a request-rate refusal.
|
|
function publicEcrQuota(stderr) {
|
|
if (!PUBLIC_ECR_QUOTA.test(stderr)) return undefined;
|
|
return PUBLIC_ECR_DATA_LIMIT.test(stderr) ? "data_limit" : "rate";
|
|
}
|
|
|
|
// npm 11 prints `npm pack --json` as a one-element array; npm 12 keys the same record by package name.
|
|
function packedFilename(report) {
|
|
const record = Array.isArray(report) ? report[0] : Object.values(report)[0];
|
|
assert.equal(typeof record?.filename, "string");
|
|
return record.filename;
|
|
}
|
|
|
|
// Containers are not verifier descendants, so leaks are checked by id against the pre-run set.
|
|
async function containers() {
|
|
const { stdout } = await execFileAsync(
|
|
docker,
|
|
["ps", "-a", "--format", "{{.ID}}\t{{.Names}}"],
|
|
{ env: environment },
|
|
);
|
|
return stdout
|
|
.split("\n")
|
|
.filter((line) => line.length > 0)
|
|
.map((line) => {
|
|
const [id, name = ""] = line.split("\t");
|
|
return { id, name };
|
|
})
|
|
.sort((left, right) => left.id.localeCompare(right.id));
|
|
}
|
|
|
|
async function imageIds() {
|
|
const { stdout } = await execFileAsync(docker, ["images", "-q"], {
|
|
env: environment,
|
|
});
|
|
return [...new Set(stdout.split("\n").filter((line) => line.length > 0))];
|
|
}
|