diff --git a/docs/API.md b/docs/API.md index 2af794ca..295b4ab6 100644 --- a/docs/API.md +++ b/docs/API.md @@ -197,9 +197,11 @@ POST /api/lanes/ensure ``` `cwd` is optional — when missing or empty the server falls back to -`os.homedir()` (the user's home directory). This is how the Sagi mascot -adopts its dedicated workspace without the client needing to know an absolute -path. Existing cwd values still pass through unchanged. `pipeline` is +`os.homedir()` (the user's home directory) — or, for a server-mode member +with an own folder, `/sagi`, made as them on first use (the +host's home lies outside their folder and would be `403 EFOLDER`). This is +how the Sagi mascot adopts its dedicated workspace without the client +needing to know an absolute path. Existing cwd values still pass through unchanged. `pipeline` is optional and, like `title`, applies **only when a workspace is actually created** — an existing workspace is returned untouched. Omitted, the workspace gets `default`. `POST /api/lanes/worktree` and `POST /api/lanes` take the same optional diff --git a/server/__tests__/server-s3-lanes.test.js b/server/__tests__/server-s3-lanes.test.js index 85feae2b..32cbd111 100644 --- a/server/__tests__/server-s3-lanes.test.js +++ b/server/__tests__/server-s3-lanes.test.js @@ -192,3 +192,31 @@ describe("a member's new project folder", () => { assert.equal(fs.existsSync(path.join(TMP, "host-new")), false); }); }); + +describe("the pet chat's workspace for a member", () => { + it("is /sagi, made as them — never the host's home (EFOLDER)", async () => { + const { fakeSudo } = require("./fixtures/fake-sudo"); + const sudo = fakeSudo(); + runAs.__setExecImpl(sudo.exec); + const login = await fetch(`${BASE}/api/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: "mia", password: "member-pass-1" }), + }); + const cm = login.headers.get("set-cookie").split(";")[0]; + const ensure = () => + fetch(`${BASE}/api/lanes/ensure`, { + method: "POST", + headers: { "Content-Type": "application/json", cookie: cm }, + body: JSON.stringify({ title: "Sagi" }), + }).then(async (r) => ({ status: r.status, body: await r.json() })); + const first = await ensure(); + assert.equal(first.status, 201, JSON.stringify(first.body)); + const want = fs.realpathSync(path.join(USERS, "mia", "sagi")); + assert.equal(first.body.lane.cwd, want); + const mk = sudo.calls.find((c) => c.includes("/opt/agenthub/libexec/write-as")); + assert.ok(mk && mk.includes("ah-mia") && mk.includes("mkdir")); + const again = await ensure(); + assert.equal(again.body.lane.id, first.body.lane.id, "the same workspace next time"); + }); +}); diff --git a/server/routes/lanes.js b/server/routes/lanes.js index 4bcd3040..040d16a7 100644 --- a/server/routes/lanes.js +++ b/server/routes/lanes.js @@ -436,6 +436,23 @@ function refuseFolder(res) { }); } +/** + * A member's folder for a workspace no caller picked a directory for (the + * pet chat): `/sagi`, made AS the member under S3 (`mkdirAs`, + * never a host write in their home). The caller still runs it through + * `folderAllowed`, so a link planted there cannot lead outside. + */ +function ownDefaultFolder(own) { + const target = path.join(own, "sagi"); + if (!fs.existsSync(target)) { + const runAs = require("../lib/run-as"); + const acct = runAs.accountForPath(target); + if (acct) runAs.mkdirAs(acct, target); + else fs.mkdirSync(target, { recursive: true }); + } + return target; +} + /** * Idempotent "which lane owns this directory?" — the Workspace page opens on a * cwd, not on a lane id, so it needs one lane to exist for that cwd without @@ -447,10 +464,24 @@ router.post("/ensure", sameOriginGuard, (req, res) => { // specific directory (e.g. Sagi's mascot companion) get a real, existing // path and lane uniqueness stays meaningful. Callers that do care pass one // through unchanged. + // A member's default is NOT the host's home (outside their folder: EFOLDER, + // the pet chat could never start) nor their own home (no workspace under + // S3), but `/sagi`, made as them on first use. // Canonical before the lookup, so the lookup and createLane's insert agree. - const cwd = lanesLib.canonicalCwd( - typeof body.cwd === "string" && body.cwd.trim() ? body.cwd.trim() : os.homedir() - ); + let cwd; + if (typeof body.cwd === "string" && body.cwd.trim()) { + cwd = lanesLib.canonicalCwd(body.cwd.trim()); + } else if (scope.ownFolder(req)) { + try { + cwd = lanesLib.canonicalCwd(ownDefaultFolder(scope.ownFolder(req))); + } catch (err) { + return res.status(err.code === "EMEMBERGONE" ? 409 : 500).json({ + error: { code: err.code || "EMKDIR", message: err.message }, + }); + } + } else { + cwd = lanesLib.canonicalCwd(os.homedir()); + } if (body.tool !== undefined && !aiTools.has(body.tool)) { return res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } });