diff --git a/server/__tests__/server-s3-lanes.test.js b/server/__tests__/server-s3-lanes.test.js index 4c18220c..85feae2b 100644 --- a/server/__tests__/server-s3-lanes.test.js +++ b/server/__tests__/server-s3-lanes.test.js @@ -173,3 +173,22 @@ describe("an analyst's workspace is theirs, like a developer's", () => { } }); }); + +describe("a member's new project folder", () => { + it("is made as the member, and nothing else is made for anyone", () => { + const { fakeSudo } = require("./fixtures/fake-sudo"); + const sudo = fakeSudo(); + runAs.__setExecImpl(sudo.exec); + const fresh = path.join(USERS, "mia", "brand-new", "sub"); + assert.equal(runAs.makeMemberFolder(fresh), true); + assert.ok(fs.statSync(fresh).isDirectory()); + const call = sudo.calls.find((c) => c.includes("/opt/agenthub/libexec/write-as")); + assert.ok(call && call.includes("ah-mia") && call.includes(fresh) && call.includes("mkdir")); + sudo.calls.length = 0; + assert.equal(runAs.makeMemberFolder(path.join(USERS, "mia")), false, "the home itself"); + assert.equal(runAs.makeMemberFolder(path.join(TMP, "host-new")), false, "not a member's"); + assert.equal(runAs.makeMemberFolder(path.join(USERS, "nobody", "x")), false, "no account"); + assert.deepEqual(sudo.calls, []); + assert.equal(fs.existsSync(path.join(TMP, "host-new")), false); + }); +}); diff --git a/server/lib/run-as.js b/server/lib/run-as.js index 033470d2..bd33f5c7 100644 --- a/server/lib/run-as.js +++ b/server/lib/run-as.js @@ -488,6 +488,26 @@ function sameOwner(...paths) { /** `mkdir -p` / `rm -rf` of a path in a member's home, AS the member. */ const mkdirAs = (acct, dir) => writeAs(acct, dir, "mkdir"); + +/** + * S3: make a missing workspace folder inside a member's own home, AS them + * (`mkdir -p`, so only where they could themselves). A new member has no + * other way to their first project: their home is no workspace (EFOLDER). + * False — nothing made — for any other folder, the home itself, or a member + * with no usable account. + */ +function makeMemberFolder(dir) { + let acct; + try { + acct = accountForPath(dir); + } catch { + return false; + } + const abs = path.resolve(dir); + if (!acct || abs === acct.home) return false; + mkdirAs(acct, abs); + return true; +} const removeAs = (acct, p) => writeAs(acct, p, "remove"); /** `[cmd, argv]` for `command args` in `cwd`: as its member, else as is. */ @@ -528,6 +548,7 @@ module.exports = { handoffWrite, writeAs, mkdirAs, + makeMemberFolder, removeAs, launchArgv, sameOwner, diff --git a/server/routes/lanes.js b/server/routes/lanes.js index cc70c291..c412b7aa 100644 --- a/server/routes/lanes.js +++ b/server/routes/lanes.js @@ -2078,7 +2078,9 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => { if (!fs.statSync(lane.cwd).isDirectory()) throw Object.assign(new Error(), { code: "ENOTDIR" }); } catch (err) { - if (err.code === "ENOENT" || err.code === "ENOTDIR") { + // A member's new project folder is made for them, as them. + const made = err.code === "ENOENT" && require("../lib/run-as").makeMemberFolder(lane.cwd); + if (!made && (err.code === "ENOENT" || err.code === "ENOTDIR")) { return res.status(409).json({ error: { code: "ENOCWD",