fix: v2.7.3 — Update now restarts through our own service unit, member panes stay live on a dev server, a start refuses a missing folder

- self-restart: a server in our unit's cgroup (/agenthub.service, system
  or user) restarts via `systemctl restart --no-block` (sudo -n for the
  system unit), our launchd job by exiting into KeepAlive. The detached
  restart-helper replacement held the port while Restart=always started
  the unit every 3 s (v2.7.2 on the S3 box: 240+ restarts, MainPID 0).
- run-as.mayHaveTmux: only ENOENT means "no tmux". A dev server in server
  mode without CAP_DAC_READ_SEARCH got EACCES on a member's 0700
  .agenthub/tmux, dropped the member from the list and ended their runs
  within seconds — the real cause of `401 not a live pane's token`.
- tmux: a member's list is re-asked every 5 s, not 1 s (one sudo + three
  auth.log lines each).
- POST /api/lanes/:id/start: 409 ENOCWD for a folder that no longer
  exists (the pane started in the account's home otherwise).
- Settings: Remote sources only for staff (a developer got a 403).
- scripts/test-server.js drops every inherited AGENTHUB_* (pane env).
Tests: self-restart.test.js, server-s3-panes (mayHaveTmux), lanes-api
(ENOCWD); touched suites 771 + client pages 191, tsc -b.
This commit is contained in:
2026-10-12 14:04:08 +07:00
parent 3a8be55b55
commit 943ba03b76
17 changed files with 271 additions and 34 deletions
+24 -12
View File
@@ -133,13 +133,22 @@ function memberHomes() {
function membersWithTmux() {
return memberHomes()
.map((home) => accountForPath(home))
.filter((acct) => {
try {
return fs.statSync(tmuxSocket(acct)).isSocket();
} catch {
return false;
}
});
.filter(mayHaveTmux);
}
/**
* Whether the member may have a tmux server. Only a missing socket says no:
* a member's `.agenthub/tmux` is theirs (0700), and a dashboard without
* CAP_DAC_READ_SEARCH (a dev server on the same database) gets EACCES there
* — that proves nothing, and reading it as "no server" ended every run of
* theirs within seconds. Their list is asked through sudo, which can tell.
*/
function mayHaveTmux(acct) {
try {
return fs.statSync(tmuxSocket(acct)).isSocket();
} catch (err) {
return err.code !== "ENOENT" && err.code !== "ENOTDIR";
}
}
// uid by login, from /etc/passwd (world-readable; node has no getpwnam).
@@ -357,8 +366,7 @@ function openSafe(file) {
// at no cost): MEMBER_READ_MAX unless the caller passes its own `max`, and
// never past the size `fstat` gave — a file growing under the read is cut.
const MEMBER_READ_MAX = 64 * 1024 * 1024;
const tooBig = (file) =>
Object.assign(new Error(`${file}: too big to read`), { code: "ETOOBIG" });
const tooBig = (file) => Object.assign(new Error(`${file}: too big to read`), { code: "ETOOBIG" });
/** `fs.readFileSync(file, enc)`, guarded (a member's file: ≤ `max` bytes). */
function readFileSafe(file, enc, { max = MEMBER_READ_MAX } = {}) {
@@ -369,7 +377,7 @@ function readFileSafe(file, enc, { max = MEMBER_READ_MAX } = {}) {
if (size > max) throw tooBig(file);
const buf = Buffer.alloc(size);
let off = 0;
for (let n; off < size && (n = fs.readSync(fd, buf, off, size - off, off)) > 0; ) off += n;
for (let n; off < size && (n = fs.readSync(fd, buf, off, size - off, off)) > 0;) off += n;
return enc ? buf.toString(enc, 0, off) : buf.subarray(0, off);
} finally {
fs.closeSync(fd);
@@ -398,7 +406,7 @@ async function readFileSafeAsync(file, enc, { max = MEMBER_READ_MAX } = {}) {
if (size > max) throw tooBig(file);
const buf = Buffer.alloc(size);
let off = 0;
for (let n; off < size && (n = (await fh.read(buf, off, size - off, off)).bytesRead) > 0; )
for (let n; off < size && (n = (await fh.read(buf, off, size - off, off)).bytesRead) > 0;)
off += n;
return enc ? buf.toString(enc, 0, off) : buf.subarray(0, off);
} finally {
@@ -438,7 +446,10 @@ async function* readLines(file, { maxLine = 64 * 1024 * 1024 } = {}) {
let start = 0;
for (let i = view.indexOf(0x0a); i !== -1; i = view.indexOf(0x0a, start)) {
if (skipping || partLen + (i - start) > maxLine) yield "";
else yield text(partLen ? Buffer.concat([...parts, view.subarray(start, i)]) : view.subarray(start, i));
else
yield text(
partLen ? Buffer.concat([...parts, view.subarray(start, i)]) : view.subarray(start, i)
);
parts = [];
partLen = 0;
skipping = false;
@@ -502,6 +513,7 @@ module.exports = {
assertOwnerFits,
hasLinuxAccount,
membersWithTmux,
mayHaveTmux,
memberHomes,
ownerForRead,
tmuxSocket,