diff --git a/docs/cli.md b/docs/cli.md index dd557703a6..efed8a8bea 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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 diff --git a/docs/specs/agent-environments/environment-export/spec.md b/docs/specs/agent-environments/environment-export/spec.md index 90a7feb5f7..f150f86112 100644 --- a/docs/specs/agent-environments/environment-export/spec.md +++ b/docs/specs/agent-environments/environment-export/spec.md @@ -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_/verify.py` and `tasks/smoke.json`, where `` 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 diff --git a/src/application/JsonInputFile.ts b/src/application/JsonInputFile.ts index 29ac051ffc..2e93d0a679 100644 --- a/src/application/JsonInputFile.ts +++ b/src/application/JsonInputFile.ts @@ -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. */ diff --git a/src/application/environment/environmentBundle.test.ts b/src/application/environment/environmentBundle.test.ts index c8a12a3493..20ca942502 100644 --- a/src/application/environment/environmentBundle.test.ts +++ b/src/application/environment/environmentBundle.test.ts @@ -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(); diff --git a/src/application/environment/environmentBundle.ts b/src/application/environment/environmentBundle.ts index 0295d4f6cf..b82227aaf6 100644 --- a/src/application/environment/environmentBundle.ts +++ b/src/application/environment/environmentBundle.ts @@ -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, diff --git a/src/application/environment/environmentExport.test.ts b/src/application/environment/environmentExport.test.ts index deba925f8f..889bea962d 100644 --- a/src/application/environment/environmentExport.test.ts +++ b/src/application/environment/environmentExport.test.ts @@ -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), + }, + }); + }); +}); diff --git a/src/application/environment/environmentExport.ts b/src/application/environment/environmentExport.ts index df896a8358..7847f63f9b 100644 --- a/src/application/environment/environmentExport.ts +++ b/src/application/environment/environmentExport.ts @@ -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( diff --git a/src/cli/environmentCommands.ts b/src/cli/environmentCommands.ts index b8550e8916..aae3ebc14b 100644 --- a/src/cli/environmentCommands.ts +++ b/src/cli/environmentCommands.ts @@ -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,