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:
@@ -427,6 +427,16 @@ describe("lanes changed by another server on the same DB", () => {
|
||||
});
|
||||
|
||||
describe("lane start input", () => {
|
||||
it("refuses to start in a workspace folder that no longer exists (409 ENOCWD)", async () => {
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-gone-"));
|
||||
const id = (await request("POST", "/api/lanes", { cwd })).body.lane.id;
|
||||
fs.rmSync(cwd, { recursive: true, force: true });
|
||||
// Without the check the pane started in the account's home instead.
|
||||
const r = await request("POST", `/api/lanes/${id}/start`, {});
|
||||
assert.equal(r.status, 409);
|
||||
assert.equal(r.body.error.code, "ENOCWD");
|
||||
});
|
||||
|
||||
it("answers 400 for a model that is not a plain model id", async () => {
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-bad-model-"));
|
||||
const id = (await request("POST", "/api/lanes", { cwd })).body.lane.id;
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* @file self-restart.test.js
|
||||
* @description After an update, a server our service unit runs is restarted
|
||||
* by the unit, never by a detached replacement: v2.7.2 on the S3 system unit
|
||||
* left the replacement holding the port while `Restart=always` started the
|
||||
* unit every 3 s, each start exiting "already running".
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { scheduleRestart, owningUnit } = require("../lib/self-restart");
|
||||
|
||||
function deps(unit, restartAnswer = "restarted") {
|
||||
const calls = { restart: [], kill: 0, spawn: 0 };
|
||||
return {
|
||||
calls,
|
||||
unit,
|
||||
svc: () => ({
|
||||
restartUserService: (name) => {
|
||||
calls.restart.push(name);
|
||||
return restartAnswer;
|
||||
},
|
||||
}),
|
||||
kill: () => calls.kill++,
|
||||
spawnHelper: () => {
|
||||
calls.spawn++;
|
||||
return { unref() {} };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("self-restart", () => {
|
||||
it("tells which unit owns this process, from its cgroup or launchd label", () => {
|
||||
const linux = (text) => owningUnit({ platform: "linux", cgroup: () => text });
|
||||
assert.equal(linux("0::/system.slice/agenthub.service\n"), "systemd");
|
||||
assert.equal(
|
||||
linux("0::/user.slice/user-1001.slice/user@1001.service/app.slice/agenthub.service\n"),
|
||||
"systemd"
|
||||
);
|
||||
assert.equal(linux("0::/user.slice/user-1001.slice/session-3.scope\n"), null);
|
||||
assert.equal(linux("0::/system.slice/agenthub.service.d/x\n"), null);
|
||||
assert.equal(
|
||||
owningUnit({
|
||||
platform: "linux",
|
||||
cgroup: () => {
|
||||
throw new Error("no /proc");
|
||||
},
|
||||
}),
|
||||
null
|
||||
);
|
||||
assert.equal(
|
||||
owningUnit({ platform: "darwin", env: { XPC_SERVICE_NAME: "io.smartgift.agenthub" } }),
|
||||
"launchd"
|
||||
);
|
||||
assert.equal(owningUnit({ platform: "darwin", env: {} }), null);
|
||||
});
|
||||
|
||||
it("asks systemd to restart its unit and starts no replacement", () => {
|
||||
const d = deps("systemd");
|
||||
assert.equal(scheduleRestart("/r", d), "service");
|
||||
assert.deepEqual(d.calls.restart, ["agenthub"]);
|
||||
assert.equal(d.calls.spawn, 0);
|
||||
assert.equal(d.calls.kill, 0, "systemd's restart job stops this process");
|
||||
});
|
||||
|
||||
it("exits for Restart=always when systemd cannot be asked — still no replacement", () => {
|
||||
const d = deps("systemd", "failed");
|
||||
assert.equal(scheduleRestart("/r", d), "exit");
|
||||
assert.equal(d.calls.kill, 1);
|
||||
assert.equal(d.calls.spawn, 0);
|
||||
});
|
||||
|
||||
it("exits under launchd, whose KeepAlive starts the new code", () => {
|
||||
const d = deps("launchd");
|
||||
assert.equal(scheduleRestart("/r", d), "exit");
|
||||
assert.equal(d.calls.kill, 1);
|
||||
assert.equal(d.calls.spawn, 0);
|
||||
assert.deepEqual(d.calls.restart, []);
|
||||
});
|
||||
|
||||
it("hands over to the detached helper when no unit owns the server", () => {
|
||||
const d = deps(null);
|
||||
assert.equal(scheduleRestart("/r", d), "helper");
|
||||
assert.equal(d.calls.spawn, 1);
|
||||
assert.equal(d.calls.kill, 1);
|
||||
});
|
||||
});
|
||||
@@ -248,4 +248,20 @@ describe("run-as: a path under the users root belongs to that user", () => {
|
||||
"/opt/agenthub/libexec/write-as"
|
||||
);
|
||||
});
|
||||
it("counts a member's tmux it cannot stat as maybe there, a missing one as none", () => {
|
||||
const home = path.join(TMP, "users", "zoe");
|
||||
const acct = { name: "zoe", login: "ah-zoe", home, uid: 5002 };
|
||||
assert.equal(runAs.mayHaveTmux(acct), false, "no .agenthub at all");
|
||||
const dir = path.join(home, ".agenthub", "tmux");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
assert.equal(runAs.mayHaveTmux(acct), false, "no socket yet");
|
||||
// The member's own 0700 dir, seen by a dashboard without CAP_DAC_READ_SEARCH.
|
||||
fs.chmodSync(dir, 0o000);
|
||||
try {
|
||||
if (process.getuid() !== 0)
|
||||
assert.equal(runAs.mayHaveTmux(acct), true, "EACCES proves nothing");
|
||||
} finally {
|
||||
fs.chmodSync(dir, 0o700);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user