mirror of
https://github.com/morluto/rea.git
synced 2026-11-06 14:56:32 +00:00
chore: reconcile author environment export followups
This commit is contained in:
commit
9868f2573c
8 files changed
+96
-10
No files matched your search
+4
-2
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user