fix(pet): a member's pet chat starts in <their folder>/sagi

POST /api/lanes/ensure without a cwd defaulted to the dashboard's own
home, outside a member's folder, so every member's pet chat failed with
"a workspace must be inside your own folder" (EFOLDER). A member now
gets <their folder>/sagi, made as them (write-as mkdir) on first use.
This commit is contained in:
2026-11-03 11:53:21 +07:00
parent 497faae3bf
commit 5912e9e613
3 changed files with 67 additions and 6 deletions
+5 -3
View File
@@ -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, `<their 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
+28
View File
@@ -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 <their folder>/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");
});
});
+34 -3
View File
@@ -436,6 +436,23 @@ function refuseFolder(res) {
});
}
/**
* A member's folder for a workspace no caller picked a directory for (the
* pet chat): `<own folder>/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 `<their folder>/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" } });