chore: reconcile author environment export followups

This commit is contained in:
gensokyo-dev committed 2026-10-19 19:47:37 +00:00
commit 9868f2573c
8 files changed
+96 -10

No files matched your search

+4 -2
View File
@@ -383,7 +383,8 @@ readable by their owner only, since they hold captured content) with
smoke task `tasks/smoke.json`. `--package-tarball` copies a packed `rea-agents`
tarball into the env folder and installs it instead of the registry package,
which is how the hosted lane proves a checkout rather than a release. `--name`
replaces the manifest's kebab-case name as the env folder and env id. The
replaces the manifest's kebab-case name as the env folder and env id; a device
name Windows reserves (`con`, `nul`, `com1` and the like) is refused. The
command prints one JSON document listing the written files with their sizes
and the pinned package source. See
[agent environments](agent-environments.md#export-a-bundle) for the folder
@@ -438,7 +439,8 @@ error and exit `1`, as does a listener that fails after binding.
`rea environment-export` is one-shot: it prints the written files and exits
`0`, or prints a structured `invalid_request` error and exits `1` for the same
pair refusals, a non-empty or file-occupied directory, a missing package
tarball or a name that is not a kebab-case label, writing nothing in each case.
tarball or a name that is not a kebab-case label or is a Windows device name,
writing nothing in each case.
A write that fails after the first file (a permission or disk-space failure
on a later path) propagates as the host's failure and leaves the partial
folder in place for inspection; remove it before retrying, since the next
@@ -33,7 +33,9 @@ previous increment.
copies of the inputs (mode `0600`, so captured content stays readable by its
owner only whatever the umask), `artifacts/check_<name>/verify.py` and
`tasks/smoke.json`, where `<name>` defaults to the manifest's kebab-case name
and `--name` must be a kebab-case label of at most 64 characters.
and `--name` must be a kebab-case label of at most 64 characters that is not a
device name Windows reserves (`con`, `prn`, `aux`, `nul`, `com1`–`com9`,
`lpt1`–`lpt9`), since the env folder could not be created there.
- The Dockerfile reads `FROM node:24.18.0-bookworm-slim`, sets
`MCP_HOST=0.0.0.0` and `MCP_PORT=18765`, installs Debian's `python3` for the
framework's readiness probe, installs
+6 -3
View File
@@ -35,16 +35,19 @@ const PACKED_TOKEN_PART_CODE_UNITS = 64 * 1024;
const NATIVE_PARSE_READ_BUDGET = 8 * 1024 * 1024;
/** Heap a whole-file native JSON.parse may need: the assembler's 64x container expansion plus byte and text copies. */
export const nativeJsonParseRequiredBytes = (inputBytes: number): number =>
export const nativeJsonParseRequiredBytes = (
inputBytes: number,
snapshot = false,
): number =>
// The assembler estimate reserves up to 128 bytes per two-byte container.
// Allow that 64x expansion plus byte/text copies before native JSON.parse,
// whose individual container allocations cannot be intercepted.
inputBytes * 72 + READ_CHUNK_BYTES;
(inputBytes * 72 + READ_CHUNK_BYTES) * (snapshot ? 2 : 1);
/** Snapshot mode also reserves one projection for cooperative sealing bookkeeping. */
const nativeParseFits = (inputBytes: number, snapshot = false): boolean =>
inputBytes <= NATIVE_PARSE_READ_BUDGET &&
nativeJsonParseRequiredBytes(inputBytes) * (snapshot ? 2 : 1) <=
nativeJsonParseRequiredBytes(inputBytes, snapshot) <=
getHeapStatistics().total_available_size;
/** One selected root member; repeated keys begin again with last-value semantics. */
@@ -186,6 +186,32 @@ describe("planEnvironmentBundle", () => {
});
});
describe("planEnvironmentBundle portable names", () => {
it("refuses device names Windows reserves for files and folders, before planning any file", () => {
for (const name of ["con", "prn", "aux", "nul", "com1", "lpt9"]) {
const refused = planEnvironmentBundle(environmentServeSource(), {
name,
packageSource: registryPackageSource(),
});
if (refused.ok) throw new Error(`Expected ${name} to be refused`);
expect(projectAnalysisError(refused.error)).toMatchObject({
code: "invalid_request",
details: {
operation: "environment-export",
issues: [{ path: ["name"], reason: "invalid_value" }],
},
});
}
for (const name of ["console", "com10", "nul-check"])
expect(
planEnvironmentBundle(environmentServeSource(), {
name,
packageSource: registryPackageSource(),
}).ok,
).toBe(true);
});
});
describe("planEnvironmentBundle replay check", () => {
it("keeps the first record's unknown status instead of borrowing another record's", () => {
const source = environmentServeSource();
@@ -21,9 +21,10 @@ export const BUNDLE_MCP_PORT = 18765;
export const BUNDLE_TARBALL_NAME = "rea-agents.tgz";
/** Folder and env id rule: a kebab-case label the agentenv framework accepts as a local id. */
const BUNDLE_NAME =
/^(?!(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$)[a-z0-9]+(?:-[a-z0-9]+)*$/u;
const BUNDLE_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
const BUNDLE_NAME_MAX_LENGTH = 64;
/** Win32 reserves these device names for files and folders, so the env folder can never take one. */
const WINDOWS_RESERVED_NAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/u;
/** Where the Dockerfile installs the CLI from. */
export type EnvironmentPackageSource =
@@ -88,6 +89,22 @@ const nameError = (name: string): AnalysisInputError =>
],
);
const reservedNameError = (name: string): AnalysisInputError =>
new AnalysisInputError(
ENVIRONMENT_EXPORT_OPERATION,
{
userMessage: `The bundle name ${JSON.stringify(name)} is a device name Windows reserves, so it cannot name the env folder; pass --name with another label.`,
},
[
{
path: ["name"],
reason: "invalid_value",
message:
"con, prn, aux, nul, com1-com9 and lpt1-lpt9 are reserved file names on Windows.",
},
],
);
const installLine = (source: EnvironmentPackageSource): string =>
source.kind === "registry"
? `RUN npm install -g --ignore-scripts --no-fund --no-audit ${source.specifier}`
@@ -277,6 +294,7 @@ export const planEnvironmentBundle = (
const name = options.name ?? source.manifest.name;
if (!BUNDLE_NAME.test(name) || name.length > BUNDLE_NAME_MAX_LENGTH)
return err(nameError(name));
if (WINDOWS_RESERVED_NAME.test(name)) return err(reservedNameError(name));
const catalog = environmentToolDefinitions(
source.manifest,
source.evidenceId,
@@ -12,6 +12,7 @@ import {
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import { PACKAGE_METADATA } from "../../generatedPackageMetadata.js";
import { nativeJsonParseRequiredBytes } from "../JsonInputFile.js";
import {
exportEnvironmentBundle,
readEnvironmentExportInputs,
@@ -270,3 +271,34 @@ describe("exportEnvironmentBundle file modes", () => {
},
);
});
describe("readEnvironmentExportInputs heap admission", () => {
it("reserves headroom for snapshot sealing after the native parse", async () => {
const root = await createTestTempDirectory(
"rea-environment-export-copies-",
);
const files = await writeEnvironmentServeFiles(
root,
environmentServeFixture(),
);
const { size } = await stat(files.manifestPath);
// Room for parsing, but not for the owned snapshot traversal that follows it.
vi.mocked(v8.getHeapStatistics).mockReturnValueOnce({
...v8.getHeapStatistics(),
total_available_size: nativeJsonParseRequiredBytes(size) * 1.5,
});
const refused = await readEnvironmentExportInputs(
files.manifestPath,
files.capturePath,
);
expect(refused.ok).toBe(false);
if (refused.ok) return;
expect(refused.error).toMatchObject({
_tag: "AnalysisResourceConstraintError",
reportedLimits: {
input_path: files.manifestPath,
required_heap_bytes: nativeJsonParseRequiredBytes(size, true),
},
});
});
});
@@ -178,13 +178,13 @@ const NOT_A_FILE_CODES = new Set([
"ENAMETOOLONG",
]);
/** Refuse, as a resource constraint, a file whose whole read and native parse would not fit live heap headroom. */
/** Refuse a whole read, native parse and owned snapshot traversal that cannot fit live heap headroom. */
const headroomRefusal = (
path: string,
inputBytes: number,
): AnalysisResourceConstraintError | undefined => {
const heap = getHeapStatistics();
const requiredBytes = nativeJsonParseRequiredBytes(inputBytes) * 2;
const requiredBytes = nativeJsonParseRequiredBytes(inputBytes, true);
return requiredBytes <= heap.total_available_size
? undefined
: new AnalysisResourceConstraintError(
+3
View File
@@ -190,6 +190,9 @@ const exportEnvironmentCommand = async (
input.evidence,
);
if (!inputs.ok) return cliError(inputs.error);
// Loaded here, as for environment-serve, so registering the command catalog never loads it.
const { loadEnvironmentSource } =
await import("../application/environment/environmentSource.js");
const source = loadEnvironmentSource(
inputs.value.manifest.value,
inputs.value.evidence.value,