diff --git a/docs-internal/registry-parity-worklist.md b/docs-internal/registry-parity-worklist.md index a2385e2101..9ec76a2f73 100644 --- a/docs-internal/registry-parity-worklist.md +++ b/docs-internal/registry-parity-worklist.md @@ -636,6 +636,20 @@ real e2e tests that prove Linux-parity behavior — not smoke tests. Biome is not applicable for this package test path; it reported the file is ignored by config in `2026-07-08T13-35-04-0700-item12-diffutils-biome-check.log`. + - **file — DONE.** Added package-local VM e2e coverage for the staged `file` + command and fixed shebang script classification to run before generic magic + detection, producing Linux-like script descriptions instead of raw + `text/x-shellscript`. The suite proves text, JSON, script, PNG, empty-file, + directory, brief, MIME, stdin, and missing-input behavior through the + packaged WASM command. Proof: + `2026-07-08T13-39-36-0700-item12-file-toolchain-cmd-build.log`; + `2026-07-08T13-40-33-0700-item12-file-package-build-after-shebang-fix.log`; + `2026-07-08T13-41-05-0700-item12-file-package-e2e-final-after-install.log`; + `2026-07-08T13-41-05-0700-item12-file-check-types-final-after-install.log`; + `2026-07-08T13-40-47-0700-item12-file-cargo-fmt-check-final.log`. + Biome is not applicable for this package test path; it reported the file is + ignored by config in + `2026-07-08T13-41-05-0700-item12-file-biome-check-final-after-install.log`. - **jq — DONE.** Added package-local VM e2e coverage for the staged `jq` command and fixed the jaq-backed CLI wrapper to accept Linux-style file operands instead of only stdin. The suite now proves version output, diff --git a/software/file/bin/file b/software/file/bin/file index 1e32fe6064..b134518d9d 100644 Binary files a/software/file/bin/file and b/software/file/bin/file differ diff --git a/software/file/native/crates/file-cmd/src/lib.rs b/software/file/native/crates/file-cmd/src/lib.rs index 92a9faa647..38f507dd73 100644 --- a/software/file/native/crates/file-cmd/src/lib.rs +++ b/software/file/native/crates/file-cmd/src/lib.rs @@ -194,6 +194,38 @@ fn identify(data: &[u8]) -> (String, String) { return ("empty".to_string(), "inode/x-empty".to_string()); } + // Check for shebang scripts + if data.len() >= 2 && data[0] == b'#' && data[1] == b'!' { + let first_line = data + .iter() + .take(128) + .take_while(|&&b| b != b'\n') + .copied() + .collect::>(); + if let Ok(line) = std::str::from_utf8(&first_line) { + let interp = line.trim_start_matches("#!"); + let interp = interp.trim(); + // Extract interpreter name + let name = interp.split_whitespace().next().unwrap_or(interp); + let basename = match name.rfind('/') { + Some(pos) => &name[pos + 1..], + None => name, + }; + // Handle "env " pattern + if basename == "env" { + let prog = interp.split_whitespace().nth(1).unwrap_or("script"); + return ( + format!("{} script, ASCII text executable", prog), + "text/x-script".to_string(), + ); + } + return ( + format!("{} script, ASCII text executable", basename), + "text/x-script".to_string(), + ); + } + } + // Try infer crate for magic byte detection if let Some(kind) = infer::get(data) { let desc = match kind.mime_type() { @@ -231,38 +263,6 @@ fn identify(data: &[u8]) -> (String, String) { return (desc.to_string(), kind.mime_type().to_string()); } - // Check for shebang scripts - if data.len() >= 2 && data[0] == b'#' && data[1] == b'!' { - let first_line = data - .iter() - .take(128) - .take_while(|&&b| b != b'\n') - .copied() - .collect::>(); - if let Ok(line) = std::str::from_utf8(&first_line) { - let interp = line.trim_start_matches("#!"); - let interp = interp.trim(); - // Extract interpreter name - let name = interp.split_whitespace().next().unwrap_or(interp); - let basename = match name.rfind('/') { - Some(pos) => &name[pos + 1..], - None => name, - }; - // Handle "env " pattern - if basename == "env" { - let prog = interp.split_whitespace().nth(1).unwrap_or("script"); - return ( - format!("{} script, ASCII text executable", prog), - "text/x-script".to_string(), - ); - } - return ( - format!("{} script, ASCII text executable", basename), - "text/x-script".to_string(), - ); - } - } - // Check for known text patterns if is_json(data) { return ("JSON text data".to_string(), "application/json".to_string()); diff --git a/software/file/test/file.test.ts b/software/file/test/file.test.ts new file mode 100644 index 0000000000..70657e05b8 --- /dev/null +++ b/software/file/test/file.test.ts @@ -0,0 +1,152 @@ +import { existsSync } from "node:fs"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + NodeFileSystem, + createKernel, + createWasmVmRuntime, + describeIf, +} from "@agentos/test-harness"; +import type { Kernel } from "@agentos/test-harness"; +import { afterEach, expect, it } from "vitest"; + +const FILE_COMMAND_DIR = fileURLToPath(new URL("../bin", import.meta.url)); +const hasFilePackageBinary = existsSync(join(FILE_COMMAND_DIR, "file")); + +let tempRoot: string | undefined; + +async function writeFixture(path: string, contents: string | Buffer): Promise { + if (!tempRoot) throw new Error("fixture root not initialized"); + const hostPath = join(tempRoot, path.replace(/^\/+/, "")); + await mkdir(dirname(hostPath), { recursive: true }); + await writeFile(hostPath, contents); +} + +async function createTestVFS(): Promise { + tempRoot = await mkdtemp(join(tmpdir(), "agentos-file-")); + await writeFixture("/project/text.txt", "hello from AgentOS\n"); + await writeFixture("/project/data.json", '{ "ok": true }\n'); + await writeFixture("/project/script.sh", "#!/usr/bin/env bash\necho hello\n"); + await writeFixture( + "/project/image.png", + Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, + 0x0d, + ]), + ); + await writeFixture("/project/empty", ""); + await mkdir(join(tempRoot, "project/dir"), { recursive: true }); + return new NodeFileSystem({ root: tempRoot }); +} + +describeIf(hasFilePackageBinary, "file command", { timeout: 10_000 }, () => { + let kernel: Kernel | undefined; + + afterEach(async () => { + await kernel?.dispose(); + kernel = undefined; + if (tempRoot) { + await rm(tempRoot, { recursive: true, force: true }); + tempRoot = undefined; + } + }); + + async function mountFixture(): Promise { + const vfs = await createTestVFS(); + kernel = createKernel({ filesystem: vfs }); + await kernel.mount(createWasmVmRuntime({ commandDirs: [FILE_COMMAND_DIR] })); + } + + async function runFile(args: string[], stdin?: string) { + if (!kernel) throw new Error("kernel not mounted"); + let stdout = ""; + let stderr = ""; + const proc = kernel.spawn("file", args, { + streamStdin: stdin !== undefined, + onStdout: (chunk) => { + stdout += Buffer.from(chunk).toString("utf8"); + }, + onStderr: (chunk) => { + stderr += Buffer.from(chunk).toString("utf8"); + }, + }); + if (stdin !== undefined) { + proc.writeStdin(stdin); + proc.closeStdin(); + } + const exitCode = await proc.wait(); + await new Promise((resolve) => setTimeout(resolve, 0)); + return { stdout, stderr, exitCode }; + } + + it("identifies text, JSON, scripts, images, empty files, and directories", async () => { + await mountFixture(); + + const result = await runFile([ + "/project/text.txt", + "/project/data.json", + "/project/script.sh", + "/project/image.png", + "/project/empty", + "/project/dir", + ]); + + expect(result.exitCode, result.stderr || result.stdout).toBe(0); + expect(result.stdout).toContain("/project/text.txt: ASCII text"); + expect(result.stdout).toContain("/project/data.json: JSON text data"); + expect(result.stdout).toContain( + "/project/script.sh: bash script, ASCII text executable", + ); + expect(result.stdout).toContain("/project/image.png: PNG image data"); + expect(result.stdout).toContain("/project/empty: empty"); + expect(result.stdout).toContain("/project/dir: directory"); + }); + + it("prints brief descriptions with -b", async () => { + await mountFixture(); + + const result = await runFile(["-b", "/project/data.json"]); + + expect(result.exitCode, result.stderr || result.stdout).toBe(0); + expect(result.stdout.trim()).toBe("JSON text data"); + }); + + it("prints MIME types with -i", async () => { + await mountFixture(); + + const result = await runFile([ + "-i", + "/project/text.txt", + "/project/image.png", + "/project/dir", + ]); + + expect(result.exitCode, result.stderr || result.stdout).toBe(0); + expect(result.stdout).toContain("/project/text.txt: text/plain"); + expect(result.stdout).toContain("/project/image.png: image/png"); + expect(result.stdout).toContain("/project/dir: inode/directory"); + }); + + it("reads stdin when the operand is -", async () => { + await mountFixture(); + + const result = await runFile( + ["-b", "-"], + "#!/usr/bin/env node\nconsole.log('hello')\n", + ); + + expect(result.exitCode, result.stderr || result.stdout).toBe(0); + expect(result.stdout.trim()).toBe("node script, ASCII text executable"); + }); + + it("returns an error for missing inputs", async () => { + await mountFixture(); + + const result = await runFile(["/project/missing.txt"]); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("/project/missing.txt"); + }); +});