From 1e8ed1a6663f3eec1214189c53a71ea3efce23ac Mon Sep 17 00:00:00 2001 From: Kamil Jopek Date: Thu, 10 Sep 2026 00:28:36 -0500 Subject: [PATCH] feat(safe-bash): expose curl response body intent --- .../scripts/integration-inputs.test.mjs | 1 + .../safe-bash/src/commands/network/curl.ts | 2 +- .../safe-bash/src/commands/network/types.ts | 4 + .../network/response-body-mode.test.ts | 113 ++++++++++++++++++ scripts/fixtures/safe-packages-browser.mjs | 1 + .../safe-packages-response-body-mode.mjs | 70 +++++++++++ scripts/fixtures/safe-packages-smoke.mjs | 1 + 7 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 packages/safe-bash/tests/commands/network/response-body-mode.test.ts create mode 100644 scripts/fixtures/safe-packages-response-body-mode.mjs diff --git a/packages/safe-bash/scripts/integration-inputs.test.mjs b/packages/safe-bash/scripts/integration-inputs.test.mjs index e326512232..891260c88d 100644 --- a/packages/safe-bash/scripts/integration-inputs.test.mjs +++ b/packages/safe-bash/scripts/integration-inputs.test.mjs @@ -386,6 +386,7 @@ function assertSource7Discovery(files) { "tests/commands/input.test.ts", "tests/commands/network/mounted-output.test.ts", "tests/commands/network/aggregate-deadline.test.ts", + "tests/commands/network/response-body-mode.test.ts", "tests/contracts/value.test.ts", "tests/shell/value-state.test.ts", "tests/shell/byte-values.test.ts", diff --git a/packages/safe-bash/src/commands/network/curl.ts b/packages/safe-bash/src/commands/network/curl.ts index b9e474fd51..7ebcd879f0 100644 --- a/packages/safe-bash/src/commands/network/curl.ts +++ b/packages/safe-bash/src/commands/network/curl.ts @@ -232,7 +232,7 @@ async function transfer(context: CommandContext, args: CurlArguments, input: str })(); try { response = await operation.acquire(async () => { - const acquired = await transport({ url: current.href, method, headers, signal, + const acquired = await transport({ url: current.href, method, headers, signal, responseBodyMode: args.head ? "omit" : args.fail ? "omit-on-http-error" : "read", registerCleanup: operation.registerCleanup, ...policy, ...(upload ? { body: upload } : {}) }); let cleanup: Promise | undefined; return { ...acquired, dispose() { cleanup ??= Promise.resolve().then(() => acquired.dispose()); return cleanup; } }; diff --git a/packages/safe-bash/src/commands/network/types.ts b/packages/safe-bash/src/commands/network/types.ts index adc05b6532..fefbce96cd 100644 --- a/packages/safe-bash/src/commands/network/types.ts +++ b/packages/safe-bash/src/commands/network/types.ts @@ -7,6 +7,10 @@ export interface HttpRequest { readonly method: string; readonly headers: HttpHeaders; readonly body?: ByteSource; + /** Consumer body intent, independent of method. Omitted means "read". + * "omit" permits an empty body; "omit-on-http-error" permits it only for + * status >= 400. Neither changes the HTTP request or response status/headers. */ + readonly responseBodyMode?: "omit" | "omit-on-http-error" | "read"; readonly signal: AbortSignal; readonly registerCleanup?: (cleanup: InvocationCleanup) => void; readonly denyPrivateNetworks?: true; diff --git a/packages/safe-bash/tests/commands/network/response-body-mode.test.ts b/packages/safe-bash/tests/commands/network/response-body-mode.test.ts new file mode 100644 index 0000000000..549c25d525 --- /dev/null +++ b/packages/safe-bash/tests/commands/network/response-body-mode.test.ts @@ -0,0 +1,113 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { Shell, MemoryFileSystem, toByteSource } from "../../../src/index.js"; +import { networkCommands, type HttpRequest } from "../../../src/commands/network/index.js"; + +for (const profile of [ + { args: "", method: "GET", mode: "read" }, + { args: "-i", method: "GET", mode: "read" }, + { args: "-I", method: "HEAD", mode: "omit" }, + { args: "-I -X GET", method: "GET", mode: "omit" }, + { args: "-X HEAD", method: "HEAD", mode: "read" }, + { args: "-f", method: "GET", mode: "omit-on-http-error" }, + { args: "--fail-with-body", method: "GET", mode: "read" }, + { args: "-I -f", method: "HEAD", mode: "omit" }, +] as const) { + for (const path of ["direct", "redirect", "retry"] as const) { + test(`curl ${profile.args || "GET"} preserves response body intent across ${path}`, async () => { + const requests: HttpRequest[] = []; + const disposed: number[] = []; + let reads = 0; + const shell = new Shell({ fs: new MemoryFileSystem() }).use(networkCommands({ + authorize: () => true, + async transport(request) { + requests.push(request); + const index = requests.length - 1; + const status = index === 0 && path !== "direct" ? path === "redirect" ? 302 : 503 : 200; + return { + status, statusText: "Fixture", + headers: status === 302 ? [["Location", "/next"]] : [], + body: (async function* () { reads++; yield* toByteSource("payload"); })(), + async dispose() { disposed.push(index); }, + }; + }, + })); + try { + const flags = path === "redirect" ? "-L" : path === "retry" ? "--retry 1 --retry-delay 0.001" : ""; + const result = await shell.exec(`curl ${profile.args} ${flags} https://offline.invalid/start`); + assert.equal(result.exitCode, 0, result.stderr.toString()); + assert.deepEqual(requests.map(({ method, responseBodyMode }) => ({ method, responseBodyMode })), + Array.from({ length: path === "direct" ? 1 : 2 }, () => ({ method: profile.method, responseBodyMode: profile.mode }))); + assert.deepEqual(disposed, requests.map((_, index) => index)); + assert.equal(reads, profile.mode === "omit" ? 0 : path === "retry" && profile.mode === "read" ? 2 : 1); + assert.equal(result.stdout.toString().includes("payload"), profile.mode !== "omit"); + } finally { await shell.dispose(); } + }); + } +} + +for (const profile of [ + { flags: "-f", method: "GET", mode: "omit-on-http-error", reads: 0, output: true }, + { flags: "--fail-with-body", method: "GET", mode: "read", reads: 1, output: true }, + { flags: "-I -f", method: "HEAD", mode: "omit", reads: 0, output: false }, + { flags: "-I -X GET -f", method: "GET", mode: "omit", reads: 0, output: false }, + { flags: "-I --fail-with-body", method: "HEAD", mode: "omit", reads: 0, output: false }, +] as const) { + for (const status of [399, 400, 404, 500]) test(`curl ${profile.flags} handles HTTP ${status} with the requested body intent`, async () => { + const fs = new MemoryFileSystem(); + await fs.writeFile("/out", Buffer.from("existing")); + let writes = 0; + let reads = 0; + const writeFile = fs.writeFile.bind(fs); + const writeStream = fs.writeStream.bind(fs); + fs.writeFile = async (...args) => { writes++; await writeFile(...args); }; + fs.writeStream = async (...args) => { writes++; await writeStream(...args); }; + const requests: HttpRequest[] = []; + const shell = new Shell({ fs }).use(networkCommands({ + authorize: () => true, + async transport(request) { + requests.push(request); + return { + status, statusText: "Fixture", headers: [], + body: (async function* () { reads++; yield* toByteSource("payload"); })(), + async dispose() {}, + }; + }, + })); + try { + const result = await shell.exec(`curl ${profile.flags} ${profile.output ? "-o /out" : ""} https://offline.invalid/missing`); + const expectedReads = status < 400 && profile.mode === "omit-on-http-error" ? 1 : profile.reads; + assert.equal(result.exitCode, status >= 400 ? 22 : 0, result.stderr.toString()); + assert.deepEqual(requests.map(({ method, responseBodyMode }) => ({ method, responseBodyMode })), + [{ method: profile.method, responseBodyMode: profile.mode }]); + assert.equal(reads, expectedReads); + assert.equal(writes > 0, profile.output && expectedReads > 0); + assert.equal(Buffer.from(await fs.readFile("/out")).toString(), profile.output && expectedReads > 0 ? "payload" : "existing"); + } finally { await shell.dispose(); } + }); +} + +for (const flags of ["-f --fail-with-body", "--fail-with-body -f", "-I -f --fail-with-body", "-I --fail-with-body -f"]) { + test(`curl rejects mutually exclusive ${flags} before authorization or file access`, async () => { + const fs = new MemoryFileSystem(); + await fs.writeFile("/out", Buffer.from("existing")); + let authorizations = 0; + let requests = 0; + let writes = 0; + const writeFile = fs.writeFile.bind(fs); + const writeStream = fs.writeStream.bind(fs); + fs.writeFile = async (...args) => { writes++; await writeFile(...args); }; + fs.writeStream = async (...args) => { writes++; await writeStream(...args); }; + const shell = new Shell({ fs }).use(networkCommands({ + authorize: () => { authorizations++; return true; }, + async transport() { requests++; throw new Error("unexpected transport"); }, + })); + try { + const result = await shell.exec(`curl ${flags} -o /out https://offline.invalid/missing`); + assert.equal(result.exitCode, 2); + assert.ok(result.stderr.toString().includes("mutually exclusive")); + assert.deepEqual({ authorizations, requests, writes }, { authorizations: 0, requests: 0, writes: 0 }); + assert.equal(Buffer.from(await fs.readFile("/out")).toString(), "existing"); + } finally { await shell.dispose(); } + }); +} diff --git a/scripts/fixtures/safe-packages-browser.mjs b/scripts/fixtures/safe-packages-browser.mjs index c033d161f6..d55cd40573 100644 --- a/scripts/fixtures/safe-packages-browser.mjs +++ b/scripts/fixtures/safe-packages-browser.mjs @@ -2,6 +2,7 @@ import { Shell, agentCommands, createAgentCommands, createMemoryFileSystem, eval import { checksumWorkflows, expectedAgentCommandNames, nullDeviceWorkflows, runNestedCommands, verifyCmpCommands, verifyFmtCommands, verifyShufCommands, verifyNumfmtCommands, verifyTruncateCommands, verifyZipCommands, verifyCsplitCommands, verifyNullDeviceView } from "./safe-packages-mixed-entry-runtime.mjs"; import { FsError as CoreFsError, createDeviceFileSystem } from "@poe-platform/safe-fs/core"; import { FsError as CompatibilityFsError } from "@poe-platform/safe-js/fs/core"; +import "./safe-packages-response-body-mode.mjs"; if (FsError !== CoreFsError) throw new Error("Browser filesystem identity diverged"); if (FsError !== CompatibilityFsError) throw new Error("Compatibility filesystem identity diverged"); diff --git a/scripts/fixtures/safe-packages-response-body-mode.mjs b/scripts/fixtures/safe-packages-response-body-mode.mjs new file mode 100644 index 0000000000..cf60210cd6 --- /dev/null +++ b/scripts/fixtures/safe-packages-response-body-mode.mjs @@ -0,0 +1,70 @@ +import { Shell, createMemoryFileSystem } from "@poe-platform/safe-bash"; +import { networkCommands } from "@poe-platform/safe-bash/commands/network"; + +for (const [flags, method, mode, errorExit] of [ + ["", "GET", "read", 0], ["-I", "HEAD", "omit", 0], + ["-I -X GET", "GET", "omit", 0], ["-X HEAD", "HEAD", "read", 0], + ["-f", "GET", "omit-on-http-error", 22], ["--fail-with-body", "GET", "read", 22], + ["-I -f", "HEAD", "omit", 22], ["-I --fail-with-body", "HEAD", "omit", 22], +]) { + for (const scenario of ["direct", "redirect", "non-error", "error"]) { + let calls = 0; + let reads = 0; + let disposed = 0; + let writes = 0; + const fs = createMemoryFileSystem(); + await fs.writeFile("/out", new Uint8Array([9])); + const writeFile = fs.writeFile.bind(fs); + const writeStream = fs.writeStream.bind(fs); + fs.writeFile = async (...args) => { writes++; await writeFile(...args); }; + fs.writeStream = async (...args) => { writes++; await writeStream(...args); }; + const output = scenario === "error" && mode !== "omit"; + const expectedReads = mode === "omit" || (scenario === "error" && mode === "omit-on-http-error") ? 0 : 1; + const shell = new Shell({ fs }).use(networkCommands({ + authorize: () => true, + async transport(request) { + if (request.method !== method || request.responseBodyMode !== mode) { + throw new Error(`Unexpected body intent: ${JSON.stringify({ method: request.method, mode: request.responseBodyMode })}`); + } + calls++; + const status = scenario === "error" ? 400 : scenario === "non-error" ? 399 : scenario === "redirect" && calls === 1 ? 302 : 200; + return { + status, statusText: "Fixture", headers: status === 302 ? [["Location", "/next"]] : [], + body: (async function* () { reads++; yield new Uint8Array([1, 2, 255]); })(), + async dispose() { disposed++; }, + }; + }, + })); + try { + const result = await shell.exec(`curl ${flags} ${scenario === "redirect" ? "-L" : ""} ${output ? "-o /out" : ""} https://offline.invalid/start`); + if (result.exitCode !== (scenario === "error" ? errorExit : 0) || calls !== (scenario === "redirect" ? 2 : 1) || disposed !== calls || reads !== expectedReads) { + throw new Error(`Packed curl body intent failed: ${JSON.stringify({ flags, scenario, exitCode: result.exitCode, calls, reads, disposed })}`); + } + if ((writes > 0) !== (output && expectedReads > 0)) throw new Error("Unexpected output-file acquisition"); + const bytes = [...await fs.readFile("/out")].join(","); + if (bytes !== (output && expectedReads > 0 ? "1,2,255" : "9")) throw new Error("Unexpected output-file bytes"); + } finally { await shell.dispose(); } + } +} + +for (const flags of ["-f --fail-with-body", "--fail-with-body -f", "-I -f --fail-with-body", "-I --fail-with-body -f"]) { + const fs = createMemoryFileSystem(); + await fs.writeFile("/out", new Uint8Array([9])); + let effects = 0; + const writeFile = fs.writeFile.bind(fs); + const writeStream = fs.writeStream.bind(fs); + fs.writeFile = async (...args) => { effects++; await writeFile(...args); }; + fs.writeStream = async (...args) => { effects++; await writeStream(...args); }; + const shell = new Shell({ fs }).use(networkCommands({ + authorize: () => { effects++; return true; }, + async transport() { effects++; throw new Error("Unexpected transport"); }, + })); + try { + const result = await shell.exec(`curl ${flags} -o /out https://offline.invalid/missing`); + if (result.exitCode !== 2 || effects !== 0 || [...await fs.readFile("/out")].join(",") !== "9") { + throw new Error("Conflicting failure flags must reject before network or file effects"); + } + } finally { await shell.dispose(); } +} + +console.log("Packed curl preserves body intent, HTTP-error omission and conflicting-flag admission"); diff --git a/scripts/fixtures/safe-packages-smoke.mjs b/scripts/fixtures/safe-packages-smoke.mjs index 954ec24620..a3ba69bf5f 100644 --- a/scripts/fixtures/safe-packages-smoke.mjs +++ b/scripts/fixtures/safe-packages-smoke.mjs @@ -16,6 +16,7 @@ import "./safe-packages-named-mutations.mjs"; import "./safe-packages-console-override.mjs"; import "./safe-packages-callback-phases.mjs"; import "./safe-packages-curl-output.mjs"; +import "./safe-packages-response-body-mode.mjs"; import { Budget, run } from "@poe-platform/safe-js"; import { FsError, createDeviceFileSystem, createMemoryFileSystem, createReadOnlyFileSystem } from "@poe-platform/safe-fs"; import { FsError as CompatibilityFsError } from "@poe-platform/safe-js/fs";