fix(drop): stream every dropped file to disk; no local size cap

- POST /api/run/drop-file pipes the body to its file as it arrives in every
  case — a member's (S3) through `write-as … replace` with the bytes on
  stdin — instead of buffering up to 20 MB in memory for a remote browser.
- Remote limit (server mode, a LAN browser) 20 MB → 500 MB, as a Files upload.
- A loopback browser in local mode has no cap any more, only the data dir's
  free space less a 1 GB reserve; ETOOBIG's max_bytes is what still fits, so
  the UI's "at most N MB" stays true.
- drop-image keeps its 20 MB in-memory limit, now checked on its own.
- fake-sudo gains a spawn for run-as.__setSpawnImpl.
This commit is contained in:
2026-10-26 16:36:29 +07:00
parent a0cd808456
commit dce32b96b7
6 changed files with 98 additions and 73 deletions
+12 -10
View File
@@ -1,32 +1,34 @@
/**
* @file fake-sudo.js
* @description Test helper for the S3 suites: an `execImpl` for
* `run-as.__setExecImpl` that plays `sudo -n -u <login> [VAR=v…] <bin> …`
* without root — it records the argv (so a test can assert nothing secret
* `run-as.__setExecImpl` (and a `spawn` for `__setSpawnImpl`) that plays
* `sudo -n -u <login> [VAR=v…] <bin> …` without root — it records the argv (so a test can assert nothing secret
* crossed sudo) and runs the real `scripts/server/write-as.sh` for
* `/opt/agenthub/libexec/write-as`, as this account.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const { execFileSync, spawn: spawnChild } = require("node:child_process");
const WRITE_AS = path.join(__dirname, "..", "..", "..", "scripts", "server", "write-as.sh");
/** `{ exec, calls }`: pass `exec` to `runAs.__setExecImpl`; `calls` = every sudo argv. */
/** `{ exec, spawn, calls }`: pass `exec` to `runAs.__setExecImpl` and `spawn`
* to `runAs.__setSpawnImpl`; `calls` = every sudo argv. */
function fakeSudo() {
const calls = [];
const exec = (cmd, argv, opts = {}) => {
// The command sudo would run, as [file, args].
const unwrap = (cmd, argv) => {
calls.push([cmd, ...argv]);
if (cmd !== "sudo") return execFileSync(cmd, argv, opts);
if (cmd !== "sudo") return [cmd, argv];
let i = argv.indexOf("-u") + 2; // past the login
while (/^[A-Z_][A-Z0-9_]*=/.test(argv[i])) i++;
const [bin, ...rest] = argv.slice(i);
if (bin === "/opt/agenthub/libexec/write-as")
return execFileSync("sh", [WRITE_AS, ...rest], opts);
return execFileSync(bin, rest, opts);
return bin === "/opt/agenthub/libexec/write-as" ? ["sh", [WRITE_AS, ...rest]] : [bin, rest];
};
return { exec, calls };
const exec = (cmd, argv, opts = {}) => execFileSync(...unwrap(cmd, argv), opts);
const spawn = (cmd, argv, opts = {}) => spawnChild(...unwrap(cmd, argv), opts);
return { exec, spawn, calls };
}
module.exports = { fakeSudo };
+6 -4
View File
@@ -334,12 +334,12 @@ describe("POST /api/run/drop-image", () => {
});
it("refuses a file declared over the limit before its body arrives", async () => {
// 4 GB announced, 1 KB sent: the answer must come without the rest.
// A petabyte announced, 1 KB sent: the answer must come without the rest.
const { status, body } = await new Promise((resolve, reject) => {
const url = new URL("/api/run/drop-file?name=big.mp4", BASE);
const req = http.request(url, {
method: "POST",
headers: { "Content-Type": "video/mp4", "Content-Length": 4 * 1024 ** 3 },
headers: { "Content-Type": "video/mp4", "Content-Length": 1024 ** 5 },
});
req.on("response", (res) => {
let text = "";
@@ -354,8 +354,10 @@ describe("POST /api/run/drop-image", () => {
});
assert.equal(status, 413);
assert.equal(body.error.code, "ETOOBIG");
// The UI shows the server's limit: a loopback browser's, here.
assert.equal(body.error.max_bytes, 2 * 1024 ** 3);
// A loopback browser has no cap, only the disk's free space (less a
// reserve) — and that is the limit the UI shows.
const st = fs.statfsSync(process.env.DASHBOARD_DATA_DIR);
assert.ok(body.error.max_bytes <= st.bavail * st.bsize, String(body.error.max_bytes));
});
it("streams a loopback drop over the remote limit to disk", async () => {
+5 -1
View File
@@ -97,7 +97,9 @@ before(async () => {
);
linux.__resetConfCache();
linux.__setRunImpl(() => "ok");
runAs.__setExecImpl(fakeSudo().exec);
const sudo = fakeSudo();
runAs.__setExecImpl(sudo.exec);
runAs.__setSpawnImpl(sudo.spawn);
tmux.__setExecImpl(fakeTmux);
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
@@ -708,6 +710,8 @@ describe("a dropped file", () => {
byAdmin.body.path.startsWith(path.join(MIA, ".agenthub", "handoff", "drops") + path.sep),
byAdmin.body.path
);
// Streamed to the member's file through write-as, not held whole.
assert.equal(fs.readFileSync(byAdmin.body.path, "utf8"), "hello");
// A host workspace's pane runs as the host: the host's own 0700 drops dir.
const hostLane = addLane(path.join(TMP, "hostdir", "d"), "boss");
const host = await drop("boss", hostLane);