chore: v2.8.15 — Oh My Pi login sharing, omp --allow-home, Markdown Preview default, first-login password
- omp-share.js: an admin's switch (S3) runs omp's own auth broker + gateway as the host on loopback; a member's omp pane reaches it as a scoped client keyed by its pane token (clients file = live omp panes only), runs on --model agenthub/<model>, never holds a credential. - omp catalog: --allow-home, so a workspace at the account's home keeps its sessions there (they were recorded under a temp dir and dropped). - Settings → Appearance → Open Markdown as: Preview (default) or Raw, for Files/Changes, the config viewer and a chat's file panel. - First-login password change and password rules, avatar URLs, Artifacts "Mine" includes installed shared skills/plugins, a member's own hook pages.
This commit is contained in:
@@ -119,6 +119,34 @@ describe("pages", () => {
|
||||
assert.equal(artifacts.captureFromHook("s1", { tool_input: { action: "read" } }), null);
|
||||
assert.equal(artifacts.captureFromHook("s1", { tool_response: "no link" }), null);
|
||||
});
|
||||
|
||||
it("gives a page from a session no workspace owns to the member who sent the hook", () => {
|
||||
const uid = db
|
||||
.prepare("INSERT INTO users (username, password_hash) VALUES ('ann', 'x')")
|
||||
.run().lastInsertRowid;
|
||||
const hook = (url) => ({
|
||||
tool_input: { action: "publish" },
|
||||
tool_response: `Published: ${url}`,
|
||||
});
|
||||
const own = artifacts.captureFromHook("loose-1", hook("https://claude.ai/artifact/m1"), {
|
||||
kind: "member",
|
||||
name: "ann",
|
||||
});
|
||||
assert.equal(own.owner_id, Number(uid));
|
||||
assert.equal(own.lane_id, null);
|
||||
// The host account names nobody; a workspace's owner still wins over the peer.
|
||||
const host = artifacts.captureFromHook("loose-2", hook("https://claude.ai/artifact/m2"), {
|
||||
kind: "host",
|
||||
});
|
||||
assert.equal(host.owner_id, null);
|
||||
const ws = artifacts.captureFromHook("s1", hook("https://claude.ai/artifact/m3"), {
|
||||
kind: "member",
|
||||
name: "ann",
|
||||
});
|
||||
const laneOwner = db.prepare("SELECT owner_id FROM lanes WHERE id = ?").get(lane.id).owner_id;
|
||||
assert.equal(ws.owner_id, laneOwner);
|
||||
assert.equal(ws.lane_id, lane.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("files", () => {
|
||||
@@ -145,6 +173,12 @@ describe("files", () => {
|
||||
assert.match(raw.text, /<h1>hi<\/h1>/);
|
||||
assert.match(raw.headers.get("content-security-policy"), /sandbox/);
|
||||
assert.equal(raw.headers.get("x-content-type-options"), "nosniff");
|
||||
// Deleting the source on disk takes nothing away: only a delete in AgentHub does.
|
||||
const live = path.join(ws, "out", "report.html");
|
||||
fs.rmSync(live);
|
||||
assert.equal((await request("GET", `/api/artifacts/${id}`)).status, 200);
|
||||
assert.match((await request("GET", `/api/artifacts/${id}/raw`)).text, /<h1>hi<\/h1>/);
|
||||
fs.writeFileSync(live, "changed");
|
||||
const other = await request("GET", `/api/artifacts/${id}/raw?path=../../t.db`);
|
||||
assert.equal(other.status, 404);
|
||||
|
||||
@@ -160,6 +194,32 @@ describe("files", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("my library", () => {
|
||||
it("adds the shared skills of others the caller installed, and only those", () => {
|
||||
const ins = db.prepare(
|
||||
"INSERT INTO artifacts (kind, title, owner_id, source, shared) VALUES ('skill', ?, ?, ?, ?)"
|
||||
);
|
||||
const theirs = Number(ins.run("theirs", 77, "lib-theirs", 1).lastInsertRowid);
|
||||
const notInstalled = Number(ins.run("other", 77, "lib-other", 1).lastInsertRowid);
|
||||
const unshared = Number(ins.run("private", 77, "lib-private", 0).lastInsertRowid);
|
||||
const own = Number(ins.run("own", 5, "lib-own", 0).lastInsertRowid);
|
||||
for (const name of ["lib-theirs", "lib-private"])
|
||||
fs.mkdirSync(path.join(process.env.CLAUDE_HOME, "skills", name), { recursive: true });
|
||||
const req = { user: { id: 5, role: "admin" } };
|
||||
const ids = artifacts.list(req, { scope: "mine", kind: "skill" }).map((a) => [a.id, a.mine]);
|
||||
assert.deepEqual(
|
||||
ids.filter(([id]) => [theirs, notInstalled, unshared, own].includes(id)).sort(),
|
||||
[
|
||||
[theirs, false],
|
||||
[own, true],
|
||||
].sort()
|
||||
);
|
||||
db.prepare(
|
||||
`DELETE FROM artifacts WHERE id IN (${[theirs, notInstalled, unshared, own].join(",")})`
|
||||
).run();
|
||||
});
|
||||
});
|
||||
|
||||
describe("skills", () => {
|
||||
it("snapshots a workspace skill and installs it into ~/.claude/skills and ~/.agents/skills", async () => {
|
||||
const list = await request("GET", `/api/artifacts/skills?laneId=${lane.id}`);
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
* served with `nosniff` and a `sandbox` CSP; `avatarAt` reaches `/api/auth/me`;
|
||||
* another account never sees it through `/api/me`; staff read it at
|
||||
* `/api/users/:id/avatar` (a developer is refused) and a workspace carries
|
||||
* `owner_avatar_at` for the sidebar's owner groups.
|
||||
* `owner_avatar_at` for the sidebar's owner groups. A picture from a link
|
||||
* (`POST /api/me/avatar/url`, server/lib/avatar-url.js) takes https only and
|
||||
* never reaches a loopback/private address.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -108,6 +110,51 @@ it("refuses an SVG, an empty body, a too-large image and no login", async () =>
|
||||
assert.equal((await put("", PNG)).status, 401);
|
||||
});
|
||||
|
||||
it("a picture from a link: https only, never a private or local address", async () => {
|
||||
const ava = await login("ava", "ava-pass-1");
|
||||
const post = (url) =>
|
||||
fetch(BASE + "/api/me/avatar/url", {
|
||||
method: "POST",
|
||||
headers: { cookie: ava, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
for (const [url, code] of [
|
||||
["not a url", "EBADURL"],
|
||||
["http://example.com/a.png", "EBADURL"],
|
||||
["https://user:pw@example.com/a.png", "EBADURL"],
|
||||
["https://127.0.0.1/a.png", "EPRIVATEURL"],
|
||||
["https://[::1]/a.png", "EPRIVATEURL"],
|
||||
["https://localhost/a.png", "EPRIVATEURL"],
|
||||
["https://169.254.169.254/latest", "EPRIVATEURL"],
|
||||
]) {
|
||||
const res = await post(url);
|
||||
assert.equal(res.status, 400, url);
|
||||
assert.equal((await res.json()).error.code, code, url);
|
||||
}
|
||||
assert.equal((await post("")).status, 400);
|
||||
|
||||
const { isPublicAddress } = require("../lib/avatar-url");
|
||||
for (const a of [
|
||||
"10.1.2.3",
|
||||
"172.20.0.1",
|
||||
"192.168.1.1",
|
||||
"100.64.0.1",
|
||||
"0.0.0.0",
|
||||
"::",
|
||||
"fe80::1",
|
||||
"fd00::1",
|
||||
"::ffff:127.0.0.1",
|
||||
"::ffff:7f00:1",
|
||||
"2002:7f00::1",
|
||||
"ff02::1",
|
||||
]) {
|
||||
assert.equal(isPublicAddress(a), false, a);
|
||||
}
|
||||
for (const a of ["8.8.8.8", "1.1.1.1", "2606:4700:4700::1111", "::ffff:8.8.8.8"]) {
|
||||
assert.equal(isPublicAddress(a), true, a);
|
||||
}
|
||||
});
|
||||
|
||||
it("staff read another account's avatar; workspaces carry owner_avatar_at", async () => {
|
||||
const ava = await login("ava", "ava-pass-1");
|
||||
const ann = await login("ann", "ann-pass-1");
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* @file omp-share.test.js
|
||||
* @description Oh My Pi login sharing (server/lib/omp-share.js): a member's
|
||||
* `models.yml` gets one `agenthub` provider pointing at the loopback gateway
|
||||
* with the pane token's env var NAME as its key (the rest of the file kept, a
|
||||
* non-mapping refused); the gateway's clients file holds only LIVE omp panes'
|
||||
* token hashes; a spawn is untouched while sharing is off, for a host pane, or
|
||||
* without S3; and the route refuses outside S3. No omp daemon is started.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after, afterEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "omp-share-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
process.env.LANES_ROOT = path.join(TMP, "lanes");
|
||||
process.env.CLAUDE_HOME = path.join(TMP, ".claude");
|
||||
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
|
||||
process.env.AGENTHUB_OMP_GATEWAY_PORT = "4999";
|
||||
|
||||
const yaml = require("js-yaml");
|
||||
const { createApp, startServer } = require("../index");
|
||||
const share = require("../lib/omp-share");
|
||||
const linux = require("../lib/linux-accounts");
|
||||
const runAs = require("../lib/run-as");
|
||||
const tmux = require("../lib/tmux");
|
||||
const { db } = require("../db");
|
||||
|
||||
const realS3 = linux.s3Enabled;
|
||||
const realLive = tmux.liveSessions;
|
||||
const flag = path.join(TMP, "data", "omp-share", "enabled");
|
||||
const clientsFile = path.join(TMP, "data", "omp-share", "clients.json");
|
||||
|
||||
function turnOn() {
|
||||
fs.mkdirSync(path.dirname(flag), { recursive: true });
|
||||
fs.writeFileSync(flag, "");
|
||||
linux.s3Enabled = () => true;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
linux.s3Enabled = realS3;
|
||||
tmux.liveSessions = realLive;
|
||||
fs.rmSync(flag, { force: true });
|
||||
});
|
||||
|
||||
after(() => fs.rmSync(TMP, { recursive: true, force: true }));
|
||||
|
||||
describe("mergedModels", () => {
|
||||
it("adds the provider to an empty or missing file", () => {
|
||||
const doc = yaml.load(share.mergedModels(""));
|
||||
assert.deepEqual(doc, {
|
||||
providers: {
|
||||
agenthub: {
|
||||
baseUrl: "http://127.0.0.1:4999",
|
||||
apiKey: "AGENTHUB_PANE_TOKEN",
|
||||
api: "anthropic-messages",
|
||||
authHeader: true,
|
||||
discovery: { type: "proxy" },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the member's own providers and settings", () => {
|
||||
const text =
|
||||
"equivalence:\n x: y\nproviders:\n mine:\n baseUrl: http://h/v1\n apiKey: K\n";
|
||||
const doc = yaml.load(share.mergedModels(text));
|
||||
assert.equal(doc.providers.mine.baseUrl, "http://h/v1");
|
||||
assert.deepEqual(doc.equivalence, { x: "y" });
|
||||
assert.equal(doc.providers.agenthub.apiKey, "AGENTHUB_PANE_TOKEN");
|
||||
});
|
||||
|
||||
it("writes nothing when the provider is already there as is", () => {
|
||||
const once = share.mergedModels("");
|
||||
assert.equal(share.mergedModels(once), null);
|
||||
});
|
||||
|
||||
it("replaces a stale provider of ours", () => {
|
||||
const stale = "providers:\n agenthub:\n baseUrl: http://127.0.0.1:1\n";
|
||||
assert.equal(
|
||||
yaml.load(share.mergedModels(stale)).providers.agenthub.baseUrl,
|
||||
"http://127.0.0.1:4999"
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a file that is not a mapping", () => {
|
||||
assert.throws(() => share.mergedModels("- a\n- b\n"), /mapping/);
|
||||
assert.throws(() => share.mergedModels("providers: [1]\n"), /mapping/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shareModel", () => {
|
||||
const host = "anthropic/claude-sonnet-5-5";
|
||||
it("runs on the host's default when none was asked for", () => {
|
||||
assert.equal(share.shareModel(null, host), "agenthub/anthropic/claude-sonnet-5-5");
|
||||
});
|
||||
it("maps a requested model onto the gateway's provider", () => {
|
||||
assert.equal(share.shareModel("claude-opus-5-5", host), "agenthub/anthropic/claude-opus-5-5");
|
||||
assert.equal(share.shareModel("openai-codex/gpt-5.5", host), "agenthub/openai-codex/gpt-5.5");
|
||||
assert.equal(share.shareModel("agenthub/anthropic/x", host), "agenthub/anthropic/x");
|
||||
});
|
||||
it("leaves the choice to omp when there is nothing to go on", () => {
|
||||
assert.equal(share.shareModel(null, null), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("forSpawn", () => {
|
||||
const member = { login: "ah-mia", home: path.join(TMP, "home-mia") };
|
||||
|
||||
it("is null while sharing is off, for a host pane, or without S3", () => {
|
||||
assert.equal(share.forSpawn(member, "agenthub-lane-1", "h"), null); // off
|
||||
fs.mkdirSync(path.dirname(flag), { recursive: true });
|
||||
fs.writeFileSync(flag, "");
|
||||
assert.equal(share.forSpawn(member, "agenthub-lane-1", "h"), null); // no S3
|
||||
linux.s3Enabled = () => true;
|
||||
assert.equal(share.forSpawn(null, "agenthub-lane-1", "h"), null); // the host's
|
||||
assert.equal(share.forSpawn(member, "agenthub-lane-1", null), null); // no token
|
||||
});
|
||||
|
||||
it("writes the member's models.yml AS them and lists the pane's token", () => {
|
||||
turnOn();
|
||||
const writes = [];
|
||||
const real = { read: runAs.readFileSafe, mkdir: runAs.mkdirAs, write: runAs.writeAs };
|
||||
runAs.readFileSafe = () => {
|
||||
const e = new Error("missing");
|
||||
e.code = "ENOENT";
|
||||
throw e;
|
||||
};
|
||||
runAs.mkdirAs = (acct, dir) => writes.push(["mkdir", acct.login, dir]);
|
||||
runAs.writeAs = (acct, file, mode, content) => writes.push([mode, acct.login, file, content]);
|
||||
tmux.liveSessions = () => new Set();
|
||||
try {
|
||||
const s = share.forSpawn(member, "agenthub-lane-7", "a".repeat(64));
|
||||
assert.deepEqual(s.env, { OMP_SKIP_SETUP: "1" });
|
||||
s.place();
|
||||
const agentDir = path.join(member.home, ".omp", "agent");
|
||||
assert.deepEqual(writes[0], ["mkdir", "ah-mia", agentDir]);
|
||||
assert.equal(writes[1][0], "replace");
|
||||
assert.equal(writes[1][2], path.join(agentDir, "models.yml"));
|
||||
assert.equal(yaml.load(writes[1][3]).providers.agenthub.apiKey, "AGENTHUB_PANE_TOKEN");
|
||||
const clients = JSON.parse(fs.readFileSync(clientsFile, "utf8"));
|
||||
assert.deepEqual(clients["agenthub-lane-7"], { tokenSha256: "a".repeat(64) });
|
||||
assert.equal((fs.statSync(clientsFile).mode & 0o777).toString(8), "600");
|
||||
} finally {
|
||||
runAs.readFileSafe = real.read;
|
||||
runAs.mkdirAs = real.mkdir;
|
||||
runAs.writeAs = real.write;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("liveClients", () => {
|
||||
before(() => {
|
||||
const ins = db.prepare(
|
||||
`INSERT INTO dashboard_runs (id, cwd, status, tool, pane_token_hash) VALUES (?, '/x', ?, ?, ?)`
|
||||
);
|
||||
ins.run("agenthub-lane-21", "running", "omp", "b".repeat(64));
|
||||
ins.run("agenthub-lane-22", "running", "omp", "c".repeat(64)); // pane gone
|
||||
ins.run("agenthub-lane-23", "ended", "omp", "d".repeat(64));
|
||||
ins.run("agenthub-lane-24", "running", "claude", "e".repeat(64));
|
||||
});
|
||||
|
||||
it("lists only live omp panes", () => {
|
||||
tmux.liveSessions = () => new Set(["agenthub-lane-21", "agenthub-lane-23", "agenthub-lane-24"]);
|
||||
const c = share.liveClients();
|
||||
assert.deepEqual(c["agenthub-lane-21"], { tokenSha256: "b".repeat(64) });
|
||||
for (const id of ["agenthub-lane-22", "agenthub-lane-23", "agenthub-lane-24"]) {
|
||||
assert.equal(c[id], undefined, id);
|
||||
}
|
||||
});
|
||||
|
||||
it("says nothing when tmux could not be asked", () => {
|
||||
tmux.liveSessions = () => null;
|
||||
assert.equal(share.liveClients(), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("/api/ai-tools/omp-sharing", () => {
|
||||
let server;
|
||||
let BASE;
|
||||
before(async () => {
|
||||
server = await startServer(createApp(), 0);
|
||||
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
});
|
||||
after(() => server.close());
|
||||
|
||||
it("reports it unavailable and refuses the switch outside S3", async () => {
|
||||
const got = await (await fetch(`${BASE}/api/ai-tools/omp-sharing`)).json();
|
||||
assert.equal(got.available, false);
|
||||
assert.equal(got.enabled, false);
|
||||
const put = await fetch(`${BASE}/api/ai-tools/omp-sharing`, {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json", origin: BASE },
|
||||
body: JSON.stringify({ enabled: true }),
|
||||
});
|
||||
assert.equal(put.status, 409);
|
||||
assert.equal((await put.json()).error.code, "ENOS3");
|
||||
assert.equal(fs.existsSync(flag), false);
|
||||
});
|
||||
|
||||
it("wants a boolean", async () => {
|
||||
const put = await fetch(`${BASE}/api/ai-tools/omp-sharing`, {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json", origin: BASE },
|
||||
body: JSON.stringify({ enabled: "yes" }),
|
||||
});
|
||||
assert.equal(put.status, 400);
|
||||
});
|
||||
});
|
||||
@@ -815,6 +815,8 @@ describe("pty-run", () => {
|
||||
);
|
||||
assert.deepEqual(paneArgv(call), [
|
||||
at("omp"),
|
||||
// A workspace at the account's home stays there (omp moves to a temp dir otherwise).
|
||||
"--allow-home",
|
||||
"--model",
|
||||
"opus",
|
||||
"--resume",
|
||||
@@ -823,7 +825,12 @@ describe("pty-run", () => {
|
||||
]);
|
||||
assert.throws(
|
||||
() =>
|
||||
pty.spawnRun({ laneId: 18, cwd: "/tmp/repo", tool: "omp", resumeSessionId: "--abcdefgh" }),
|
||||
pty.spawnRun({
|
||||
laneId: 18,
|
||||
cwd: "/tmp/repo",
|
||||
tool: "omp",
|
||||
resumeSessionId: "--abcdefgh",
|
||||
}),
|
||||
{ code: "EBADSESSION" }
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* @file server-mode-password-change.test.js
|
||||
* @description A password an admin GIVES (Users page create or reset) may be
|
||||
* anything non-empty, and the account must then pick its own before anything
|
||||
* else: its login reaches only the `/me` reads and `POST /api/me/password`
|
||||
* (EPWCHANGE), opens no WebSocket, and its own password must pass every rule
|
||||
* (EWEAKPASSWORD with the failed `rules`, never the current one). Also: the
|
||||
* last login survives a logout (`users.last_login_at`), the last enabled
|
||||
* admin can never be demoted or disabled (ELASTADMIN, the CLI's path
|
||||
* included) and old audit entries are pruned (`AGENTHUB_AUDIT_DAYS`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "server-mode-password-change-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
const { db } = require("../db");
|
||||
const auth = require("../lib/auth");
|
||||
|
||||
let server;
|
||||
let BASE;
|
||||
const BOSS_PASS = "Admin-pass-2026";
|
||||
|
||||
async function call(method, p, { cookie, body } = {}) {
|
||||
const res = await fetch(BASE + p, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json", ...(cookie ? { cookie } : {}) },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return { status: res.status, body: await res.json().catch(() => null) };
|
||||
}
|
||||
|
||||
async function login(username, password) {
|
||||
const res = await fetch(BASE + "/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
if (res.status !== 200) return null;
|
||||
return { cookie: res.headers.get("set-cookie").split(";")[0], user: (await res.json()).user };
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
server = await startServer(createApp(), 0);
|
||||
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
await auth.createUser("boss", BOSS_PASS, "admin");
|
||||
});
|
||||
|
||||
after(() => {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
delete process.env.AGENTHUB_AUDIT_DAYS;
|
||||
server?.close();
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("a password an admin gave", () => {
|
||||
it("is accepted weak, then the user must pick a strong one of their own", async () => {
|
||||
const boss = (await login("boss", BOSS_PASS)).cookie;
|
||||
const made = await call("POST", "/api/users", {
|
||||
cookie: boss,
|
||||
body: { username: "neo", password: "123", currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(made.status, 201);
|
||||
assert.equal(made.body.user.mustChangePassword, true);
|
||||
|
||||
const neo = await login("neo", "123");
|
||||
assert.equal(neo.user.mustChangePassword, true);
|
||||
// Only its own account and the password change, until it has one.
|
||||
assert.equal(
|
||||
(await call("GET", "/api/lanes", { cookie: neo.cookie })).body.error.code,
|
||||
"EPWCHANGE"
|
||||
);
|
||||
const patch = await call("PATCH", "/api/me/account", {
|
||||
cookie: neo.cookie,
|
||||
body: { displayName: "Neo" },
|
||||
});
|
||||
assert.equal(patch.body.error.code, "EPWCHANGE");
|
||||
assert.equal((await call("GET", "/api/me/account", { cookie: neo.cookie })).status, 200);
|
||||
const me = await call("GET", "/api/auth/me", { cookie: neo.cookie });
|
||||
assert.equal(me.body.user.mustChangePassword, true);
|
||||
|
||||
const weak = await call("POST", "/api/me/password", {
|
||||
cookie: neo.cookie,
|
||||
body: { currentPassword: "123", newPassword: "neo12345" },
|
||||
});
|
||||
assert.equal(weak.status, 400);
|
||||
assert.equal(weak.body.error.code, "EWEAKPASSWORD");
|
||||
assert.deepEqual(weak.body.error.rules, ["upper", "symbol", "username"]);
|
||||
|
||||
const ok = await call("POST", "/api/me/password", {
|
||||
cookie: neo.cookie,
|
||||
body: { currentPassword: "123", newPassword: "Green-Tea-42" },
|
||||
});
|
||||
assert.equal(ok.status, 200);
|
||||
assert.equal((await call("GET", "/api/lanes", { cookie: neo.cookie })).status, 200);
|
||||
assert.equal(
|
||||
(await call("GET", "/api/auth/me", { cookie: neo.cookie })).body.user.mustChangePassword,
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("an admin's reset flags the account again; their own new password must pass every rule", async () => {
|
||||
const boss = (await login("boss", BOSS_PASS)).cookie;
|
||||
const neoId = auth.findUser("neo").id;
|
||||
const reset = await call("PATCH", `/api/users/${neoId}`, {
|
||||
cookie: boss,
|
||||
body: { password: "abc", currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(reset.status, 200);
|
||||
assert.equal(reset.body.user.mustChangePassword, true);
|
||||
assert.equal((await login("neo", "abc")).user.mustChangePassword, true);
|
||||
|
||||
const bossId = auth.findUser("boss").id;
|
||||
const own = (password) =>
|
||||
call("PATCH", `/api/users/${bossId}`, {
|
||||
cookie: boss,
|
||||
body: { password, currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal((await own("123")).body.error.code, "EWEAKPASSWORD");
|
||||
const same = await own(BOSS_PASS);
|
||||
assert.equal(same.body.error.code, "EWEAKPASSWORD");
|
||||
assert.deepEqual(same.body.error.rules, ["reused"]);
|
||||
const done = await own("Blue-Sky-77!");
|
||||
assert.equal(done.status, 200);
|
||||
assert.equal(done.body.user.mustChangePassword, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("account bookkeeping", () => {
|
||||
it("keeps the last login after the user signs out", async () => {
|
||||
const pass = "Blue-Sky-77!";
|
||||
const s = await login("boss", pass);
|
||||
await call("POST", "/api/auth/logout", { cookie: s.cookie });
|
||||
const id = auth.findUser("boss").id;
|
||||
db.prepare("DELETE FROM auth_sessions WHERE user_id = ?").run(id);
|
||||
const stats = auth.loginStats(id);
|
||||
assert.equal(stats.logins, 0);
|
||||
assert.ok(stats.lastLoginAt, "the last login is still known");
|
||||
assert.equal(auth.allLoginStats().get(id).lastLoginAt, stats.lastLoginAt);
|
||||
});
|
||||
|
||||
it("never demotes, disables or deletes the last enabled admin", () => {
|
||||
for (const fn of [
|
||||
() => auth.setRole("boss", "developer"),
|
||||
() => auth.setDisabled("boss", true),
|
||||
() => auth.deleteUser("boss"),
|
||||
]) {
|
||||
assert.throws(fn, { code: "ELASTADMIN" });
|
||||
}
|
||||
assert.equal(auth.findUser("boss").role, "admin");
|
||||
assert.equal(auth.findUser("boss").disabled, 0);
|
||||
});
|
||||
|
||||
it("prunes audit entries older than AGENTHUB_AUDIT_DAYS", () => {
|
||||
const { pruneAudit } = require("../lib/audit");
|
||||
const insert = db.prepare(
|
||||
"INSERT INTO audit_log (user_id, owner_id, method, path, at) VALUES (1, 1, 'GET', ?, ?)"
|
||||
);
|
||||
insert.run("/old", new Date(Date.now() - 40 * 864e5).toISOString());
|
||||
insert.run("/new", new Date().toISOString());
|
||||
process.env.AGENTHUB_AUDIT_DAYS = "0";
|
||||
assert.equal(pruneAudit({ force: true }), 0, "0 keeps everything");
|
||||
process.env.AGENTHUB_AUDIT_DAYS = "30";
|
||||
assert.ok(pruneAudit({ force: true }) >= 1);
|
||||
const paths = db
|
||||
.prepare("SELECT path FROM audit_log")
|
||||
.all()
|
||||
.map((r) => r.path);
|
||||
assert.ok(!paths.includes("/old"));
|
||||
assert.ok(paths.includes("/new"));
|
||||
});
|
||||
});
|
||||
@@ -230,19 +230,26 @@ describe("Settings → Account (/api/me)", () => {
|
||||
body: { currentPassword: "nope", newPassword: "leo-new-pass" },
|
||||
});
|
||||
assert.equal(wrong.body.error.code, "EREAUTH");
|
||||
const ok = await call("POST", "/api/me/password", {
|
||||
const weak = await call("POST", "/api/me/password", {
|
||||
cookie: leo,
|
||||
body: { currentPassword: "member-pass-2", newPassword: "leo-new-pass" },
|
||||
});
|
||||
assert.equal(weak.status, 400);
|
||||
assert.equal(weak.body.error.code, "EWEAKPASSWORD");
|
||||
assert.deepEqual(weak.body.error.rules, ["upper", "digit", "username"]);
|
||||
const ok = await call("POST", "/api/me/password", {
|
||||
cookie: leo,
|
||||
body: { currentPassword: "member-pass-2", newPassword: "New-Pass-2026" },
|
||||
});
|
||||
assert.equal(ok.status, 200);
|
||||
assert.equal((await call("GET", "/api/me/account", { cookie: leo })).status, 200);
|
||||
assert.equal((await call("GET", "/api/me/account", { cookie: other })).status, 401);
|
||||
assert.ok(await login("leo", "leo-new-pass"));
|
||||
assert.ok(await login("leo", "New-Pass-2026"));
|
||||
});
|
||||
|
||||
it("signs out other devices", async () => {
|
||||
const a = await login("leo", "leo-new-pass");
|
||||
const b = await login("leo", "leo-new-pass");
|
||||
const a = await login("leo", "New-Pass-2026");
|
||||
const b = await login("leo", "New-Pass-2026");
|
||||
const r = await call("POST", "/api/me/sign-out-others", { cookie: a });
|
||||
assert.ok(r.body.ended >= 1);
|
||||
assert.equal((await call("GET", "/api/me/account", { cookie: a })).status, 200);
|
||||
|
||||
@@ -116,6 +116,10 @@ before(async () => {
|
||||
});
|
||||
cookies[n] = login.headers.get("set-cookie").split(";")[0];
|
||||
}
|
||||
// A second admin, so demoting "boss" never leaves the install without one (ELASTADMIN).
|
||||
db.prepare(
|
||||
"INSERT INTO users (username, role, password_hash) VALUES ('boss2', 'admin', 'x')"
|
||||
).run();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
|
||||
@@ -13,7 +13,7 @@ id follows, as one more argv element — `pty-run.js` emits them in place of
|
||||
`resume` when a start carries `resumeSessionId`, and only for an id that starts
|
||||
with a letter or digit. A tool without it (and without a `launcher`) refuses a
|
||||
resume by id (`EUNSUPPORTED`) rather than start fresh under a "resume".
|
||||
Built-in: omp, Grok and Cursor (`--resume`), OpenCode (`--session`). A session id may carry `_` (OpenCode's `ses_…`).
|
||||
Built-in: omp, Grok and Cursor (`--resume`), OpenCode (`--session`). omp always gets `--allow-home`: started in its account's home (a workspace there) it would otherwise move to a temp dir, and its session would name that folder, not the workspace's. A session id may carry `_` (OpenCode's `ses_…`).
|
||||
|
||||
## Install / update (A2)
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
"detect": [
|
||||
"omp"
|
||||
],
|
||||
"args": [
|
||||
"--allow-home"
|
||||
],
|
||||
"prompt": {
|
||||
"mode": "argv"
|
||||
},
|
||||
|
||||
@@ -1942,6 +1942,19 @@ for (const col of ["display_name", "email", "avatar_at"]) {
|
||||
db.prepare(`ALTER TABLE users ADD COLUMN ${col} TEXT`).run();
|
||||
}
|
||||
}
|
||||
// When the account last logged in (kept past its logins' expiry, unlike
|
||||
// `auth_sessions`), and whether it must pick its own password before anything
|
||||
// else (set when an admin creates it or resets its password — server/lib/auth.js).
|
||||
for (const [col, type] of [
|
||||
["last_login_at", "TEXT"],
|
||||
["must_change_password", "INTEGER NOT NULL DEFAULT 0"],
|
||||
]) {
|
||||
try {
|
||||
db.prepare(`SELECT ${col} FROM users LIMIT 1`).get();
|
||||
} catch {
|
||||
db.prepare(`ALTER TABLE users ADD COLUMN ${col} ${type}`).run();
|
||||
}
|
||||
}
|
||||
// An admin's pin of one server-mode user to one pool account per tool
|
||||
// (server/lib/account-pins.js): that user's new sessions skip the balancer.
|
||||
// The account is named, not referenced — the pool lives in files.
|
||||
|
||||
@@ -1297,6 +1297,10 @@ if (require.main === module) {
|
||||
startBackgroundServices();
|
||||
// The status-area icon (server/lib/tray.js) — production servers only.
|
||||
require("./lib/tray").startTrayWatch();
|
||||
// Oh My Pi login sharing's broker + gateway, when an admin turned it on (S3).
|
||||
require("./lib/omp-share")
|
||||
.start()
|
||||
.catch((err) => console.warn("omp share failed to start:", err.message));
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(listenFailure(err, PORT));
|
||||
@@ -1312,6 +1316,12 @@ if (require.main === module) {
|
||||
process.exit(1);
|
||||
}
|
||||
shutdownInProgress = true;
|
||||
// Its omp daemons are this server's children; KillMode=process would keep them.
|
||||
try {
|
||||
require("./lib/omp-share").stop();
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
console.log(`\n${signal} received — shutting down gracefully… (hit Ctrl+C again to force)`);
|
||||
|
||||
// Drop realtime clients first — open WS sockets otherwise hold the HTTP
|
||||
|
||||
+30
-9
@@ -39,7 +39,8 @@
|
||||
* Share, unshare, install and uninstall are written to `audit_log`.
|
||||
* - Editing a skill's name, description or instructions (`editSkill`) writes
|
||||
* a NEW snapshot and swaps the row to it; the old snapshot is dropped, and
|
||||
* copies already installed stay as they were (Reinstall picks it up).
|
||||
* copies already installed stay as they were (uninstall and install again
|
||||
* to take the new one).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -149,7 +150,8 @@ function view(row, req, owners = null) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows this request may see. `scope`: `mine` (own rows; every row in
|
||||
* The rows this request may see. `scope`: `mine` (own library rows plus the
|
||||
* shared skills/plugins of others the caller has installed; every row in
|
||||
* local mode), `hub` (shared rows, server mode), anything else = all visible.
|
||||
* Each row also carries `installed` (`installedCheck`).
|
||||
*/
|
||||
@@ -158,8 +160,12 @@ function list(req, { scope = "all", kind, q, laneId, sessionId, limit = 200 } =
|
||||
const where = [v.where];
|
||||
const params = [...v.params];
|
||||
if (scope === "mine" && req?.user) {
|
||||
where.push("a.owner_id = ? AND a.in_library = 1");
|
||||
params.push(req.user.id);
|
||||
// Their own library, plus what others shared that they installed (kept
|
||||
// below only when `installed` — a disk read, so it cannot be SQL).
|
||||
where.push(
|
||||
"((a.owner_id = ? AND a.in_library = 1) OR (a.shared = 1 AND a.owner_id IS NOT ? AND a.kind IN ('skill', 'plugin')))"
|
||||
);
|
||||
params.push(req.user.id, req.user.id);
|
||||
} else if (scope === "hub") {
|
||||
where.push("a.shared = 1");
|
||||
}
|
||||
@@ -193,7 +199,8 @@ function list(req, { scope = "all", kind, q, laneId, sessionId, limit = 200 } =
|
||||
).all(...params);
|
||||
const owners = new Map();
|
||||
const installed = installedCheck(req);
|
||||
return rows.map((r) => ({ ...view(r, req, owners), installed: installed(r) }));
|
||||
const out = rows.map((r) => ({ ...view(r, req, owners), installed: installed(r) }));
|
||||
return scope === "mine" && req?.user ? out.filter((a) => a.mine || a.installed) : out;
|
||||
}
|
||||
|
||||
// ── Small helpers ─────────────────────────────────────────────────────────
|
||||
@@ -284,12 +291,25 @@ function addPage(req, { url, title, description, laneId, sessionId }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Who a hook's page belongs to when no workspace owns its session (a member's
|
||||
* `claude` started from their own terminal): the S3 member the hook's
|
||||
* connection came from (`hook-auth.js` `hookPeer`, by uid — never the body).
|
||||
* The host's account is shared by every admin, so it names nobody.
|
||||
*/
|
||||
function peerOwner(peer) {
|
||||
if (peer?.kind !== "member" || !peer.name) return null;
|
||||
const row = sql("SELECT id FROM users WHERE username = ? AND disabled = 0").get(peer.name);
|
||||
return row ? row.id : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `Artifact` tool's PostToolUse hook: a publish answers with the page's
|
||||
* claude.ai URL somewhere in its response. Recorded once per (owner, url),
|
||||
* owned by the session's workspace owner. Returns the row or null.
|
||||
* owned by the session's workspace owner, else by the member who sent the
|
||||
* hook (`peerOwner`). Returns the row or null.
|
||||
*/
|
||||
function captureFromHook(sessionId, data) {
|
||||
function captureFromHook(sessionId, data, peer = null) {
|
||||
const input = data?.tool_input || {};
|
||||
if (input.action && input.action !== "publish") return null;
|
||||
const text = JSON.stringify(data?.tool_response ?? "");
|
||||
@@ -297,7 +317,8 @@ function captureFromHook(sessionId, data) {
|
||||
if (!m) return null;
|
||||
const url = m[0].replace(/[\\"]+$/, "");
|
||||
if (!PAGE_URL_RE.test(url)) return null;
|
||||
const ownerId = sessionOwner(sessionId);
|
||||
const laneId = sessionLane(sessionId);
|
||||
const ownerId = laneId != null ? sessionOwner(sessionId) : peerOwner(peer);
|
||||
const same = sql("SELECT * FROM artifacts WHERE kind = 'page' AND url = ? AND owner_id IS ?").get(
|
||||
url,
|
||||
ownerId
|
||||
@@ -309,7 +330,7 @@ function captureFromHook(sessionId, data) {
|
||||
title: cleanTitle(input.title, file ? path.basename(file) : "Artifact"),
|
||||
description: cleanDescription(input.description),
|
||||
owner_id: ownerId,
|
||||
lane_id: sessionLane(sessionId),
|
||||
lane_id: laneId,
|
||||
session_id: String(sessionId),
|
||||
source: file ? file.slice(0, 1000) : null,
|
||||
url,
|
||||
|
||||
+40
-10
@@ -8,11 +8,47 @@
|
||||
* for a member (who can only read their own). An admin's account change on
|
||||
* the Users page is written here too (`recordAccountChange`), and so is an
|
||||
* admin's look into another user's home (`recordHomeRead`, File Explorer).
|
||||
* Entries are kept `AGENTHUB_AUDIT_DAYS` days (default 180; `0` keeps them
|
||||
* all): older ones are deleted as new ones are written, at most once an hour.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const sql = (q) => require("../db").db.prepare(q);
|
||||
|
||||
const DEFAULT_KEEP_DAYS = 180;
|
||||
const PRUNE_EVERY_MS = 60 * 60 * 1000;
|
||||
let lastPrune = 0;
|
||||
|
||||
/** Days of audit history kept; 0 = forever. */
|
||||
function keepDays() {
|
||||
const v = process.env.AGENTHUB_AUDIT_DAYS;
|
||||
if (v === undefined || v === "") return DEFAULT_KEEP_DAYS;
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : DEFAULT_KEEP_DAYS;
|
||||
}
|
||||
|
||||
/** Deletes entries older than `keepDays()`; returns how many. `force` skips the hourly gate. */
|
||||
function pruneAudit({ force = false } = {}) {
|
||||
const days = keepDays();
|
||||
if (!days || (!force && Date.now() - lastPrune < PRUNE_EVERY_MS)) return 0;
|
||||
lastPrune = Date.now();
|
||||
const cutoff = new Date(Date.now() - days * 864e5).toISOString();
|
||||
try {
|
||||
return sql("DELETE FROM audit_log WHERE at < ?").run(cutoff).changes;
|
||||
} catch (err) {
|
||||
console.warn("[audit] could not prune:", err.message);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Writes one entry, then prunes when it is time. */
|
||||
function insertEntry(userId, ownerId, sessionId, method, path) {
|
||||
sql(
|
||||
"INSERT INTO audit_log (user_id, owner_id, session_id, method, path) VALUES (?, ?, ?, ?, ?)"
|
||||
).run(userId, ownerId, sessionId, method, path);
|
||||
pruneAudit();
|
||||
}
|
||||
|
||||
// Paths that name one session's content.
|
||||
const SESSION_PATHS = [
|
||||
/^\/sessions\/(?!facets\/?$)([^/]+)/i,
|
||||
@@ -35,9 +71,7 @@ function auditGuard(req, res, next) {
|
||||
if (owner === u.id) continue;
|
||||
const ownerRole = sql("SELECT role FROM users WHERE id = ?").get(owner)?.role;
|
||||
if (ownerRole !== "developer") continue;
|
||||
sql(
|
||||
"INSERT INTO audit_log (user_id, owner_id, session_id, method, path) VALUES (?, ?, ?, ?, ?)"
|
||||
).run(u.id, owner, id, req.method, req.originalUrl.slice(0, 500));
|
||||
insertEntry(u.id, owner, id, req.method, req.originalUrl.slice(0, 500));
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[audit] could not record:", err.message);
|
||||
@@ -64,20 +98,16 @@ function listAudit(limit = 100) {
|
||||
/** An admin's account change from the Users page: who, on whom, what (never
|
||||
* a password). `owner_id` is the account changed; `path` carries the action. */
|
||||
function recordAccountChange(actorId, targetId, method, action) {
|
||||
sql(
|
||||
"INSERT INTO audit_log (user_id, owner_id, session_id, method, path) VALUES (?, ?, NULL, ?, ?)"
|
||||
).run(actorId, targetId, method, String(action).slice(0, 500));
|
||||
insertEntry(actorId, targetId, null, method, String(action).slice(0, 500));
|
||||
}
|
||||
|
||||
/** An admin opening another user's home in the File Explorer (read-only). */
|
||||
function recordHomeRead(actorId, ownerId, method, what) {
|
||||
try {
|
||||
sql(
|
||||
"INSERT INTO audit_log (user_id, owner_id, session_id, method, path) VALUES (?, ?, NULL, ?, ?)"
|
||||
).run(actorId, ownerId, method, String(what).slice(0, 500));
|
||||
insertEntry(actorId, ownerId, null, method, String(what).slice(0, 500));
|
||||
} catch (err) {
|
||||
console.warn("[audit] could not record:", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { auditGuard, listAudit, recordAccountChange, recordHomeRead };
|
||||
module.exports = { auditGuard, listAudit, recordAccountChange, recordHomeRead, pruneAudit };
|
||||
|
||||
+127
-17
@@ -22,6 +22,15 @@
|
||||
* (`validateProfile`), which its owner edits in Settings → Account without
|
||||
* that step-up; changing their OWN password there needs the old one and
|
||||
* keeps the login it was changed from (`setPassword`'s `keepToken`).
|
||||
* - Two password bars: an admin may GIVE any non-empty password (a create or a
|
||||
* reset — `validatePassword`), and such an account is flagged
|
||||
* `must_change_password`; a password its OWNER picks must pass every rule of
|
||||
* `passwordProblems` (`validateOwnPassword`). While the flag is set, a
|
||||
* cookie login reaches only `/auth/*`, the `/me` reads and
|
||||
* `POST /me/password` (EPWCHANGE) and opens no WebSocket; a pane token is
|
||||
* not held back — its agents were started before the reset.
|
||||
* - Neither the dashboard nor the CLI can leave the install with no enabled
|
||||
* admin (`assertKeepsAdmin`, ELASTADMIN).
|
||||
*
|
||||
* This is login, not isolation: every member still sees every workspace and
|
||||
* session, and every pane runs as the one host account (phases S2/S3 of the
|
||||
@@ -134,6 +143,39 @@ function validatePassword(password) {
|
||||
if (password.length > 1024) throw err("EBADPASSWORD", "password too long");
|
||||
}
|
||||
|
||||
/** Shortest password an account may pick for itself. */
|
||||
const PASSWORD_MIN_LENGTH = 8;
|
||||
|
||||
/**
|
||||
* The rules a password picked by its OWNER fails, as ids (empty = it passes):
|
||||
* `length`, `lower`, `upper`, `digit`, `symbol` and `username` (it contains
|
||||
* the username). The client's checklist (client/src/lib/passwordRules.ts)
|
||||
* mirrors these; this is the one that decides.
|
||||
*/
|
||||
function passwordProblems(password, username) {
|
||||
const p = String(password ?? "");
|
||||
const out = [];
|
||||
if ([...p].length < PASSWORD_MIN_LENGTH) out.push("length");
|
||||
if (!/\p{Ll}/u.test(p)) out.push("lower");
|
||||
if (!/\p{Lu}/u.test(p)) out.push("upper");
|
||||
if (!/\p{N}/u.test(p)) out.push("digit");
|
||||
if (!/[^\p{L}\p{N}]/u.test(p)) out.push("symbol");
|
||||
const name = String(username || "").toLowerCase();
|
||||
if (name && p.toLowerCase().includes(name)) out.push("username");
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A password the account picks for itself: every rule (EWEAKPASSWORD). */
|
||||
function validateOwnPassword(password, username) {
|
||||
validatePassword(password);
|
||||
const failed = passwordProblems(password, username);
|
||||
if (failed.length) {
|
||||
throw Object.assign(err("EWEAKPASSWORD", `password too weak: ${failed.join(", ")}`), {
|
||||
rules: failed,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Users (the CLI's `agenthub users …` and `agenthub server setup`) ────────
|
||||
|
||||
const publicUser = (u) =>
|
||||
@@ -147,6 +189,8 @@ const publicUser = (u) =>
|
||||
// When the avatar was last set (null = none): the client's cache-buster.
|
||||
avatarAt: u.avatar_at ?? null,
|
||||
createdAt: u.created_at ?? null,
|
||||
// An admin gave this password: the account must pick its own first.
|
||||
mustChangePassword: Boolean(u.must_change_password),
|
||||
};
|
||||
|
||||
const MAX_DISPLAY_NAME = 100;
|
||||
@@ -199,7 +243,8 @@ function findUser(username) {
|
||||
return sql("SELECT * FROM users WHERE username = ?").get(String(username || ""));
|
||||
}
|
||||
|
||||
async function createUser(username, password, role = "developer") {
|
||||
/** `mustChange`: an admin gave the password — the user picks their own at first login. */
|
||||
async function createUser(username, password, role = "developer", { mustChange = false } = {}) {
|
||||
const name = validateUsername(username);
|
||||
const linux = require("./linux-accounts");
|
||||
linux.assertLinuxName(name);
|
||||
@@ -210,8 +255,8 @@ async function createUser(username, password, role = "developer") {
|
||||
// S3: the Linux account first — a user who cannot get one is not created.
|
||||
linux.provision("create", name);
|
||||
const { lastInsertRowid } = sql(
|
||||
"INSERT INTO users (username, role, password_hash) VALUES (?, ?, ?)"
|
||||
).run(name, role, hash);
|
||||
"INSERT INTO users (username, role, password_hash, must_change_password) VALUES (?, ?, ?, ?)"
|
||||
).run(name, role, hash, mustChange ? 1 : 0);
|
||||
// S3, opt-in: the admin's plugin baseline, installed AS the new member in
|
||||
// the background (each marketplace is a clone) — the user exists already.
|
||||
require("./member-claude")
|
||||
@@ -227,14 +272,29 @@ function requireUser(username) {
|
||||
}
|
||||
|
||||
/** New password; every login of that user ends — but `keepToken`'s, when
|
||||
* the user changed their own password from that login. */
|
||||
async function setPassword(username, password, { keepToken } = {}) {
|
||||
* the user changed their own password from that login. `mustChange`: an admin
|
||||
* gave it, so the user must pick their own next; without it the flag clears. */
|
||||
async function setPassword(username, password, { keepToken, mustChange = false } = {}) {
|
||||
const u = requireUser(username);
|
||||
validatePassword(password);
|
||||
sql("UPDATE users SET password_hash = ? WHERE id = ?").run(await hashPassword(password), u.id);
|
||||
sql("UPDATE users SET password_hash = ?, must_change_password = ? WHERE id = ?").run(
|
||||
await hashPassword(password),
|
||||
mustChange ? 1 : 0,
|
||||
u.id
|
||||
);
|
||||
endSessions(u.id, keepToken);
|
||||
}
|
||||
|
||||
/** EWEAKPASSWORD (`reused`) when `password` is the account's current one. */
|
||||
async function assertNotCurrent(userId, password) {
|
||||
const row = sql("SELECT password_hash FROM users WHERE id = ?").get(userId);
|
||||
if (row && (await verifyPassword(String(password ?? ""), row.password_hash))) {
|
||||
throw Object.assign(err("EWEAKPASSWORD", "the new password is the current one"), {
|
||||
rules: ["reused"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** End every login of `userId` but `exceptToken`'s; returns how many ended. */
|
||||
function endSessions(userId, exceptToken) {
|
||||
if (exceptToken) {
|
||||
@@ -246,18 +306,52 @@ function endSessions(userId, exceptToken) {
|
||||
return sql("DELETE FROM auth_sessions WHERE user_id = ?").run(userId).changes;
|
||||
}
|
||||
|
||||
/** `{ logins, lastLoginAt }`: the user's unexpired logins and the newest one's start. */
|
||||
/** `{ logins, lastLoginAt }`: the user's unexpired logins and their last login
|
||||
* ever (`users.last_login_at`; the newest login's start for one made before it). */
|
||||
function loginStats(userId) {
|
||||
const r = sql(
|
||||
`SELECT COUNT(*) AS n, MAX(created_at) AS last FROM auth_sessions
|
||||
WHERE user_id = ? AND expires_at > ?`
|
||||
).get(userId, new Date().toISOString());
|
||||
return { logins: r.n, lastLoginAt: r.last || null };
|
||||
const at = sql("SELECT last_login_at FROM users WHERE id = ?").get(userId)?.last_login_at;
|
||||
return { logins: r.n, lastLoginAt: at || r.last || null };
|
||||
}
|
||||
|
||||
/** `loginStats` for every account in two queries: Map(user id → stats). */
|
||||
function allLoginStats() {
|
||||
const now = new Date().toISOString();
|
||||
const live = new Map(
|
||||
sql(
|
||||
`SELECT user_id, COUNT(*) AS n, MAX(created_at) AS last FROM auth_sessions
|
||||
WHERE expires_at > ? GROUP BY user_id`
|
||||
)
|
||||
.all(now)
|
||||
.map((r) => [r.user_id, r])
|
||||
);
|
||||
const out = new Map();
|
||||
for (const u of sql("SELECT id, last_login_at FROM users").all()) {
|
||||
const r = live.get(u.id);
|
||||
out.set(u.id, { logins: r?.n || 0, lastLoginAt: u.last_login_at || r?.last || null });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* ELASTADMIN when `u` is the only enabled admin and the change would leave it
|
||||
* no longer one — the CLI included, which has no "yourself" to refuse.
|
||||
*/
|
||||
function assertKeepsAdmin(u, what) {
|
||||
if (u.role !== "admin" || u.disabled) return;
|
||||
const others = sql(
|
||||
"SELECT COUNT(*) AS n FROM users WHERE role = 'admin' AND disabled = 0 AND id != ?"
|
||||
).get(u.id).n;
|
||||
if (!others) throw err("ELASTADMIN", `the last admin cannot be ${what}`);
|
||||
}
|
||||
|
||||
function setRole(username, role) {
|
||||
if (!ROLES.includes(role)) throw err("EBADROLE", `role: one of ${ROLES.join(", ")}`);
|
||||
const u = requireUser(username);
|
||||
if (role !== "admin") assertKeepsAdmin(u, "demoted");
|
||||
sql("UPDATE users SET role = ? WHERE id = ?").run(role, u.id);
|
||||
if (role !== "admin") stopHostPanes(u.id);
|
||||
}
|
||||
@@ -287,6 +381,7 @@ function stopHostPanes(userId) {
|
||||
* `deleteUser` below is the one that removes an account for good. */
|
||||
function setDisabled(username, disabled) {
|
||||
const u = requireUser(username);
|
||||
if (disabled) assertKeepsAdmin(u, "disabled");
|
||||
const linux = require("./linux-accounts");
|
||||
// S3: lock (and end the processes of) the Linux account too — when there is
|
||||
// one: a user made before S3, or whose name fits no Linux login, has none,
|
||||
@@ -316,12 +411,7 @@ function setDisabled(username, disabled) {
|
||||
*/
|
||||
function deleteUser(username) {
|
||||
const u = requireUser(username);
|
||||
if (u.role === "admin") {
|
||||
const others = sql(
|
||||
"SELECT COUNT(*) AS n FROM users WHERE role = 'admin' AND disabled = 0 AND id != ?"
|
||||
).get(u.id).n;
|
||||
if (!others) throw err("ELASTADMIN", "the last admin cannot be deleted");
|
||||
}
|
||||
assertKeepsAdmin(u, "deleted");
|
||||
const lanes = sql("SELECT id, cwd FROM lanes WHERE owner_id = ?").all(u.id);
|
||||
sql("DELETE FROM auth_sessions WHERE user_id = ?").run(u.id);
|
||||
const pty = require("./pty-run");
|
||||
@@ -514,6 +604,7 @@ async function login(req, username, password) {
|
||||
if (!u || !ok || u.disabled) throw err("EBADLOGIN", "wrong username or password");
|
||||
failures.delete(ipKey);
|
||||
failures.delete(userKey);
|
||||
sql("UPDATE users SET last_login_at = ? WHERE id = ?").run(new Date().toISOString(), u.id);
|
||||
return { user: publicUser(u), token: startSession(u.id) };
|
||||
}
|
||||
|
||||
@@ -598,6 +689,12 @@ const underPrefix = (p, x) => {
|
||||
|
||||
const deny = (res, status, code, message) => res.status(status).json({ error: { code, message } });
|
||||
|
||||
/** What a login flagged `must_change_password` may still call. */
|
||||
function mayBeforePasswordChange(method, lp) {
|
||||
if (method === "GET" || method === "HEAD") return lp === "/me" || lp.startsWith("/me/");
|
||||
return method === "POST" && lp === "/me/password";
|
||||
}
|
||||
|
||||
/** Express middleware for `/api`; a no-op in local mode. */
|
||||
function authGuard(req, res, next) {
|
||||
if (!isServerMode()) return next();
|
||||
@@ -610,9 +707,15 @@ function authGuard(req, res, next) {
|
||||
? next()
|
||||
: deny(res, 403, "ELOCALONLY", "hooks are accepted from this machine only");
|
||||
}
|
||||
const user = userFromRequest(req) || userFromPaneToken(req);
|
||||
const cookieUser = userFromRequest(req);
|
||||
const user = cookieUser || userFromPaneToken(req);
|
||||
if (!user) return deny(res, 401, "EAUTH", "log in first");
|
||||
req.user = user;
|
||||
// A password an admin gave: the login may only read its own account and
|
||||
// pick its own password until it has.
|
||||
if (cookieUser?.mustChangePassword && !mayBeforePasswordChange(req.method, lp)) {
|
||||
return deny(res, 403, "EPWCHANGE", "choose your own password first");
|
||||
}
|
||||
const under = (x) => underPrefix(p, x);
|
||||
const isRead = req.method === "GET" || req.method === "HEAD";
|
||||
if (
|
||||
@@ -655,8 +758,10 @@ function isWebSocketLoggedIn(req) {
|
||||
}
|
||||
if (host !== String(req.headers.host || "").toLowerCase() || !isHostAllowed(host)) return false;
|
||||
}
|
||||
// Which terminals a login may open is the run check's (websocket.js).
|
||||
return Boolean(userFromRequest(req));
|
||||
// Which terminals a login may open is the run check's (websocket.js). None
|
||||
// until a password an admin gave is replaced.
|
||||
const user = userFromRequest(req);
|
||||
return Boolean(user && !user.mustChangePassword);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
@@ -684,8 +789,13 @@ module.exports = {
|
||||
validateProfile,
|
||||
setProfile,
|
||||
validatePassword,
|
||||
validateOwnPassword,
|
||||
passwordProblems,
|
||||
assertNotCurrent,
|
||||
PASSWORD_MIN_LENGTH,
|
||||
endSessions,
|
||||
loginStats,
|
||||
allLoginStats,
|
||||
hasAdmin,
|
||||
userFromRequest,
|
||||
login,
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* @file avatar-url.js
|
||||
* @description Fetches an avatar picture from a URL the account typed
|
||||
* (Settings → Account, `POST /api/me/avatar/url`), so the server — not the
|
||||
* browser, which most image hosts' CORS would block — downloads it. A URL the
|
||||
* user names makes the SERVER connect somewhere, so it is held to: `https://`
|
||||
* with no userinfo, a host whose EVERY resolved address is public (no
|
||||
* loopback, private, link-local, CGNAT, multicast or reserved range — the
|
||||
* dashboard's own network is never reachable this way), the connection pinned
|
||||
* to the vetted address (a second DNS answer cannot rebind it), at most
|
||||
* `MAX_REDIRECTS` redirects each re-checked the same way, a timeout, and at
|
||||
* most `MAX_AVATAR_BYTES` read. No cookie or credential is ever sent. The
|
||||
* bytes then go through `setAvatar`'s own magic-byte check like an upload.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const dns = require("node:dns");
|
||||
const https = require("node:https");
|
||||
const net = require("node:net");
|
||||
const { MAX_AVATAR_BYTES } = require("./avatars");
|
||||
|
||||
const MAX_REDIRECTS = 3;
|
||||
const TIMEOUT_MS = 10_000;
|
||||
|
||||
const fail = (code, message) => Object.assign(new Error(message), { code });
|
||||
|
||||
const blocked = new net.BlockList();
|
||||
for (const [addr, bits] of [
|
||||
["0.0.0.0", 8],
|
||||
["10.0.0.0", 8],
|
||||
["100.64.0.0", 10],
|
||||
["127.0.0.0", 8],
|
||||
["169.254.0.0", 16],
|
||||
["172.16.0.0", 12],
|
||||
["192.0.0.0", 24],
|
||||
["192.0.2.0", 24],
|
||||
["192.88.99.0", 24],
|
||||
["192.168.0.0", 16],
|
||||
["198.18.0.0", 15],
|
||||
["198.51.100.0", 24],
|
||||
["203.0.113.0", 24],
|
||||
["224.0.0.0", 4],
|
||||
["240.0.0.0", 4],
|
||||
]) {
|
||||
blocked.addSubnet(addr, bits, "ipv4");
|
||||
}
|
||||
for (const [addr, bits] of [
|
||||
["::", 128],
|
||||
["::1", 128],
|
||||
["64:ff9b::", 96],
|
||||
["64:ff9b:1::", 48],
|
||||
["100::", 64],
|
||||
["2001::", 23],
|
||||
["2001:db8::", 32],
|
||||
["2002::", 16],
|
||||
["fc00::", 7],
|
||||
["fe80::", 10],
|
||||
["ff00::", 8],
|
||||
]) {
|
||||
blocked.addSubnet(addr, bits, "ipv6");
|
||||
}
|
||||
|
||||
/** True only for a globally routable unicast address. */
|
||||
function isPublicAddress(address) {
|
||||
const family = net.isIP(address);
|
||||
if (family === 4) return !blocked.check(address, "ipv4");
|
||||
if (family !== 6) return false;
|
||||
const lower = address.toLowerCase();
|
||||
// An IPv4-mapped/compatible address is judged as the IPv4 address it carries.
|
||||
const mapped = /^::(?:ffff:)?(\d+\.\d+\.\d+\.\d+)$/.exec(lower);
|
||||
if (mapped) return isPublicAddress(mapped[1]);
|
||||
if (/^::(?:ffff:)?[0-9a-f]{1,4}:[0-9a-f]{1,4}$/.test(lower)) return false;
|
||||
// Only global unicast (2000::/3) is public.
|
||||
if (!/^[23][0-9a-f]{0,3}:/.test(lower)) return false;
|
||||
return !blocked.check(address, "ipv6");
|
||||
}
|
||||
|
||||
/** The URL as a `URL`, or EBADURL: https only, no userinfo, a real host. */
|
||||
function parseAvatarUrl(text) {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(String(text ?? "").trim());
|
||||
} catch {
|
||||
throw fail("EBADURL", "not a valid URL");
|
||||
}
|
||||
if (url.protocol !== "https:") throw fail("EBADURL", "only an https:// link");
|
||||
if (url.username || url.password) throw fail("EBADURL", "a link with a login is refused");
|
||||
if (!url.hostname) throw fail("EBADURL", "not a valid URL");
|
||||
// Node never calls `lookup` for an IP-literal host, so it is checked here.
|
||||
const literal = url.hostname.replace(/^\[|\]$/g, "");
|
||||
if (net.isIP(literal) && !isPublicAddress(literal)) {
|
||||
throw fail("EPRIVATEURL", "that address is not on the public internet");
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* `lookup` for `https.request`: resolves every address and refuses the host
|
||||
* when ANY of them is not public, then hands over only vetted ones.
|
||||
*/
|
||||
function safeLookup(hostname, options, cb) {
|
||||
if (typeof options === "function") [cb, options] = [options, {}];
|
||||
const literal = hostname.replace(/^\[|\]$/g, "");
|
||||
const done = (addrs) => {
|
||||
if (!addrs.length || !addrs.every((a) => isPublicAddress(a.address))) {
|
||||
return cb(fail("EPRIVATEURL", "that address is not on the public internet"));
|
||||
}
|
||||
if (options?.all) return cb(null, addrs);
|
||||
return cb(null, addrs[0].address, addrs[0].family);
|
||||
};
|
||||
if (net.isIP(literal)) return done([{ address: literal, family: net.isIP(literal) }]);
|
||||
dns.lookup(hostname, { all: true, verbatim: true }, (err, addrs) => {
|
||||
if (err) return cb(fail("EFETCH", `could not resolve ${hostname}`));
|
||||
done(addrs);
|
||||
});
|
||||
}
|
||||
|
||||
function getOnce(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.get(
|
||||
url,
|
||||
{
|
||||
lookup: safeLookup,
|
||||
agent: false,
|
||||
timeout: TIMEOUT_MS,
|
||||
headers: { Accept: "image/png,image/jpeg,image/gif,image/webp", "User-Agent": "AgentHub" },
|
||||
},
|
||||
(res) => {
|
||||
const status = res.statusCode || 0;
|
||||
if (status >= 300 && status < 400 && res.headers.location) {
|
||||
res.resume();
|
||||
return resolve({ redirect: new URL(res.headers.location, url).href });
|
||||
}
|
||||
if (status !== 200) {
|
||||
res.resume();
|
||||
return reject(fail("EFETCH", `the server answered ${status}`));
|
||||
}
|
||||
if (Number(res.headers["content-length"]) > MAX_AVATAR_BYTES) {
|
||||
res.destroy();
|
||||
return reject(fail("ETOOBIG", "the image is too large"));
|
||||
}
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
res.on("data", (chunk) => {
|
||||
size += chunk.length;
|
||||
if (size > MAX_AVATAR_BYTES) {
|
||||
res.destroy();
|
||||
return reject(fail("ETOOBIG", "the image is too large"));
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
res.on("end", () => resolve({ body: Buffer.concat(chunks) }));
|
||||
res.on("error", (e) => reject(e.code ? e : fail("EFETCH", e.message)));
|
||||
}
|
||||
);
|
||||
req.on("timeout", () => req.destroy(fail("EFETCH", "the server took too long")));
|
||||
req.on("error", (e) =>
|
||||
reject(e.code === "EPRIVATEURL" || e.code === "EFETCH" ? e : fail("EFETCH", e.message))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Downloads the picture at `text`; resolves to its bytes (unchecked — `setAvatar` sniffs them). */
|
||||
async function fetchAvatarImage(text) {
|
||||
let url = parseAvatarUrl(text);
|
||||
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
||||
const out = await getOnce(url);
|
||||
if (out.body) return out.body;
|
||||
url = parseAvatarUrl(out.redirect);
|
||||
}
|
||||
throw fail("EFETCH", "too many redirects");
|
||||
}
|
||||
|
||||
module.exports = { fetchAvatarImage, isPublicAddress, parseAvatarUrl };
|
||||
@@ -0,0 +1,464 @@
|
||||
/**
|
||||
* @file omp-share.js
|
||||
* @description Server mode with S3: the HOST account's Oh My Pi (omp) login,
|
||||
* shared with every member's omp pane without any member ever holding it.
|
||||
* An admin's switch (Settings → AI tools → Oh My Pi login sharing,
|
||||
* `GET/PUT /api/ai-tools/omp-sharing`), off by default.
|
||||
*
|
||||
* While it is on, this server keeps two omp daemons of its own running, as the
|
||||
* host, on loopback only:
|
||||
* - `omp auth-broker serve` over the host's own `~/.omp` credential store —
|
||||
* the vault. Its bearer token (`~/.omp/auth-broker.token`, 0600 in the
|
||||
* host's home) reaches nothing but the gateway's env.
|
||||
* - `omp auth-gateway serve --clients=<file>` — an API gateway that answers
|
||||
* `/v1/messages`, `/v1/chat/completions`, `/v1/responses`, `/v1/models`
|
||||
* with the broker's credentials put in upstream. Its own unscoped token
|
||||
* stays in the host's home too.
|
||||
* A member's omp pane reaches the gateway as a SCOPED client: the clients file
|
||||
* lists the SHA-256 of each live omp pane's own pane token
|
||||
* (`dashboard_runs.pane_token_hash`, the same hash `auth.js` keeps), and its
|
||||
* `~/.omp/agent/models.yml` gets one `agenthub` provider whose `apiKey` is the
|
||||
* env var NAME `AGENTHUB_PANE_TOKEN`. A scoped client can call models but
|
||||
* never read a credential or the gateway's usage, and its token stops working
|
||||
* within the gateway's 10 s re-read once its pane is gone (`syncClients`) — so
|
||||
* an agent can spend through the login while its pane lives, never carry it
|
||||
* away. omp's gateway serves only its built-in providers' models (a custom
|
||||
* `models.yml` provider of the host, such as a 9Router, is not shared).
|
||||
*
|
||||
* Nothing here returns or logs a token. Local mode and a server without S3
|
||||
* never start anything.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const net = require("node:net");
|
||||
const { spawn } = require("node:child_process");
|
||||
|
||||
const PROVIDER = "agenthub";
|
||||
const TOKEN_ENV = "AGENTHUB_PANE_TOKEN";
|
||||
const SYNC_MS = 15_000;
|
||||
/** A spawn's token counts before its run row exists (recordRun runs after). */
|
||||
const PENDING_MS = 60_000;
|
||||
|
||||
const port = (name, fallback) => {
|
||||
const n = parseInt(process.env[name] || "", 10);
|
||||
return Number.isInteger(n) && n > 0 && n < 65536 ? n : fallback;
|
||||
};
|
||||
const brokerPort = () => port("AGENTHUB_OMP_BROKER_PORT", 4826);
|
||||
const gatewayPort = () => port("AGENTHUB_OMP_GATEWAY_PORT", 4827);
|
||||
|
||||
const dir = () => path.join(require("./claude-home").getDataDir(), "omp-share");
|
||||
const onFlag = () => path.join(dir(), "enabled");
|
||||
const clientsFile = () => path.join(dir(), "clients.json");
|
||||
const pidsFile = () => path.join(dir(), "pids.json");
|
||||
const logFile = (name) => path.join(dir(), `${name}.log`);
|
||||
/** The host's own omp config dir, where the broker keeps its token. */
|
||||
const ompDir = () => process.env.AGENTHUB_OMP_DIR || path.join(os.homedir(), ".omp");
|
||||
|
||||
function available() {
|
||||
return require("./linux-accounts").s3Enabled();
|
||||
}
|
||||
|
||||
const enabled = () => fs.existsSync(onFlag());
|
||||
|
||||
function ensureDir() {
|
||||
fs.mkdirSync(dir(), { recursive: true, mode: 0o700 });
|
||||
}
|
||||
|
||||
// ── the clients file ──────────────────────────────────────────────────────
|
||||
|
||||
const pending = new Map(); // run id → { hash, at }
|
||||
let lastClients = null;
|
||||
|
||||
/** `{ <run id>: { tokenSha256 } }` for every live omp pane, or null when tmux could not say. */
|
||||
function liveClients() {
|
||||
const live = require("./tmux").liveSessions();
|
||||
if (!live) return null;
|
||||
const rows = require("../db")
|
||||
.db.prepare(
|
||||
`SELECT id, pane_token_hash FROM dashboard_runs
|
||||
WHERE tool = 'omp' AND status IN ('running', 'spawning') AND pane_token_hash IS NOT NULL`
|
||||
)
|
||||
.all();
|
||||
const out = {};
|
||||
for (const r of rows) if (live.has(r.id)) out[r.id] = { tokenSha256: r.pane_token_hash };
|
||||
const now = Date.now();
|
||||
for (const [id, p] of pending) {
|
||||
if (now - p.at > PENDING_MS) pending.delete(id);
|
||||
else out[id] = { tokenSha256: p.hash };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function writeClients(clients) {
|
||||
const text = `${JSON.stringify(clients, null, 2)}\n`;
|
||||
if (text === lastClients) return;
|
||||
ensureDir();
|
||||
const tmp = `${clientsFile()}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(tmp, text, { mode: 0o600 });
|
||||
fs.renameSync(tmp, clientsFile());
|
||||
lastClients = text;
|
||||
}
|
||||
|
||||
/** Rewrite the clients file from the live omp panes (a gone pane's token drops out). */
|
||||
function syncClients() {
|
||||
if (!enabled() || !available()) return;
|
||||
try {
|
||||
const clients = liveClients();
|
||||
if (clients) writeClients(clients);
|
||||
} catch (err) {
|
||||
console.warn("omp share: clients sync failed:", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── a member's models.yml ─────────────────────────────────────────────────
|
||||
|
||||
function providerEntry() {
|
||||
return {
|
||||
baseUrl: `http://127.0.0.1:${gatewayPort()}`,
|
||||
apiKey: TOKEN_ENV,
|
||||
api: "anthropic-messages",
|
||||
authHeader: true,
|
||||
discovery: { type: "proxy" },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The text of `models.yml` with our provider set, or null when it is already
|
||||
* there as is. Throws on a file that is not a YAML mapping — it is the
|
||||
* member's, and is never overwritten with something it did not say.
|
||||
*/
|
||||
function mergedModels(text) {
|
||||
const yaml = require("js-yaml");
|
||||
const doc = text && text.trim() ? yaml.load(text) : {};
|
||||
if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
|
||||
throw new Error("models.yml is not a YAML mapping");
|
||||
}
|
||||
if (
|
||||
doc.providers != null &&
|
||||
(typeof doc.providers !== "object" || Array.isArray(doc.providers))
|
||||
) {
|
||||
throw new Error("models.yml `providers` is not a mapping");
|
||||
}
|
||||
const want = providerEntry();
|
||||
const have = doc.providers?.[PROVIDER];
|
||||
if (have && JSON.stringify(have) === JSON.stringify(want)) return null;
|
||||
doc.providers = { ...(doc.providers || {}), [PROVIDER]: want };
|
||||
return yaml.dump(doc, { lineWidth: -1, noRefs: true });
|
||||
}
|
||||
|
||||
/** Point a member's omp at the gateway: their `~/.omp/agent/models.yml`, written AS them. */
|
||||
function writeMemberModels(acct) {
|
||||
const runAs = require("./run-as");
|
||||
const agentDir = path.join(acct.home, ".omp", "agent");
|
||||
const file = path.join(agentDir, "models.yml");
|
||||
let text = "";
|
||||
try {
|
||||
text = runAs.readFileSafe(file, "utf8");
|
||||
} catch (err) {
|
||||
if (err.code !== "ENOENT") throw err;
|
||||
runAs.mkdirAs(acct, agentDir);
|
||||
}
|
||||
const next = mergedModels(text);
|
||||
if (next !== null) runAs.writeAs(acct, file, "replace", next);
|
||||
}
|
||||
|
||||
/** The host's own default omp model (`modelRoles.default` in `~/.omp/agent/config.yml`), or null. */
|
||||
function hostDefaultModel() {
|
||||
try {
|
||||
const doc = require("js-yaml").load(
|
||||
fs.readFileSync(path.join(ompDir(), "agent", "config.yml"), "utf8")
|
||||
);
|
||||
const m = doc?.modelRoles?.default;
|
||||
return typeof m === "string" && m.includes("/") ? m : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `--model` a shared pane runs on: the requested model, or the host's
|
||||
* default, as the `agenthub` provider's id (`agenthub/<provider>/<model>`).
|
||||
* Without it omp falls back to the provider's FIRST listed model — a retired
|
||||
* `claude-3-5-sonnet` on the gateway, which answers 404 and retries forever —
|
||||
* or to a built-in provider the member has no login for.
|
||||
*/
|
||||
function shareModel(requested, hostDefault = hostDefaultModel()) {
|
||||
const want = requested || hostDefault;
|
||||
if (!want) return requested || null;
|
||||
if (want.startsWith(`${PROVIDER}/`)) return want;
|
||||
if (want.includes("/")) return `${PROVIDER}/${want}`;
|
||||
const provider = hostDefault ? hostDefault.split("/")[0] : "anthropic";
|
||||
return `${PROVIDER}/${provider}/${want}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by `spawnRun` for an omp pane: `{env, model(), place()}` — env to add,
|
||||
* the `--model` to run on (`shareModel`) and the home to prepare — or
|
||||
* null when sharing is off (the pane then runs on the member's own login).
|
||||
* Only a MEMBER's pane — the host's omp already holds the login itself.
|
||||
*/
|
||||
function forSpawn(member, runId, tokenHash) {
|
||||
if (!member || !tokenHash || !enabled() || !available()) return null;
|
||||
return {
|
||||
env: { OMP_SKIP_SETUP: "1" },
|
||||
model: (requested) => shareModel(requested),
|
||||
place() {
|
||||
try {
|
||||
writeMemberModels(member);
|
||||
} catch (err) {
|
||||
console.warn(`omp share: ${member.login}'s models.yml left as is:`, err.message);
|
||||
}
|
||||
pending.set(runId, { hash: tokenHash, at: Date.now() });
|
||||
syncClients();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── the two daemons ───────────────────────────────────────────────────────
|
||||
|
||||
const procs = { broker: null, gateway: null };
|
||||
const status = { broker: null, gateway: null }; // { pid, startedAt } | { error }
|
||||
let timers = [];
|
||||
let stopping = false;
|
||||
|
||||
function ompBinary() {
|
||||
return require("./ai-tools").detect("omp").path;
|
||||
}
|
||||
|
||||
function readPids() {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(pidsFile(), "utf8")) || {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writePids() {
|
||||
ensureDir();
|
||||
const pids = {};
|
||||
for (const [k, p] of Object.entries(procs)) if (p?.pid) pids[k] = p.pid;
|
||||
fs.writeFileSync(pidsFile(), JSON.stringify(pids), { mode: 0o600 });
|
||||
}
|
||||
|
||||
/** A daemon an earlier server left behind (KillMode=process keeps children): ours by its argv only. */
|
||||
function killStale() {
|
||||
for (const [name, pid] of Object.entries(readPids())) {
|
||||
if (!Number.isInteger(pid)) continue;
|
||||
let argv = "";
|
||||
try {
|
||||
argv = fs.readFileSync(`/proc/${pid}/cmdline`, "utf8");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (argv.includes(`auth-${name}\0serve`)) {
|
||||
try {
|
||||
process.kill(pid, "SIGTERM");
|
||||
} catch {
|
||||
/* gone already */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function portOpen(p) {
|
||||
return new Promise((resolve) => {
|
||||
const s = net.connect({ host: "127.0.0.1", port: p });
|
||||
s.once("connect", () => (s.destroy(), resolve(true)));
|
||||
s.once("error", () => resolve(false));
|
||||
});
|
||||
}
|
||||
|
||||
async function waitPort(p, ms = 15_000) {
|
||||
for (const end = Date.now() + ms; Date.now() < end;) {
|
||||
if (await portOpen(p)) return true;
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The env a daemon gets: the host's basics only, never AgentHub's own secrets. */
|
||||
function daemonEnv(extra = {}) {
|
||||
const keep = ["HOME", "USER", "LOGNAME", "PATH", "LANG", "LC_ALL", "TMPDIR"];
|
||||
const env = {};
|
||||
for (const k of keep) if (process.env[k]) env[k] = process.env[k];
|
||||
return { ...env, OMP_SKIP_SETUP: "1", ...extra };
|
||||
}
|
||||
|
||||
function launch(name, bin, args, env) {
|
||||
ensureDir();
|
||||
const out = fs.openSync(logFile(name), "a", 0o600);
|
||||
const child = spawn(bin, args, { env, stdio: ["ignore", out, out], windowsHide: true });
|
||||
fs.closeSync(out);
|
||||
procs[name] = child;
|
||||
status[name] = { pid: child.pid, startedAt: new Date().toISOString() };
|
||||
child.on("error", (err) => (status[name] = { error: err.message }));
|
||||
child.on("exit", (code, signal) => {
|
||||
// One this module stopped (or replaced) itself is no crash.
|
||||
if (procs[name] !== child) return;
|
||||
procs[name] = null;
|
||||
if (stopping || !enabled()) return;
|
||||
status[name] = { error: `exited (${signal || code})` };
|
||||
// The pair restarts together: the gateway holds the broker's token.
|
||||
restartSoon();
|
||||
});
|
||||
writePids();
|
||||
return child;
|
||||
}
|
||||
|
||||
let restartTimer = null;
|
||||
let backoff = 5_000;
|
||||
function restartSoon() {
|
||||
if (restartTimer) return;
|
||||
restartTimer = setTimeout(() => {
|
||||
restartTimer = null;
|
||||
stopDaemons();
|
||||
start().catch(() => {});
|
||||
backoff = Math.min(backoff * 2, 5 * 60_000);
|
||||
}, backoff);
|
||||
if (restartTimer.unref) restartTimer.unref();
|
||||
}
|
||||
|
||||
async function startDaemons() {
|
||||
const bin = ompBinary();
|
||||
if (!bin) {
|
||||
status.broker = status.gateway = { error: "omp is not installed" };
|
||||
return;
|
||||
}
|
||||
killStale();
|
||||
// The gateway refuses to start without the clients file it names.
|
||||
if (!fs.existsSync(clientsFile())) writeClients({});
|
||||
const bport = brokerPort();
|
||||
const gport = gatewayPort();
|
||||
for (const p of [bport, gport]) {
|
||||
if (await waitPortClosed(p)) continue;
|
||||
status.broker = status.gateway = { error: `port ${p} is taken` };
|
||||
return;
|
||||
}
|
||||
launch("broker", bin, ["auth-broker", "serve", `--bind=127.0.0.1:${bport}`], daemonEnv());
|
||||
if (!(await waitPort(bport))) {
|
||||
status.broker = { error: "the broker did not start listening" };
|
||||
return restartSoon();
|
||||
}
|
||||
let token;
|
||||
try {
|
||||
token = fs.readFileSync(path.join(ompDir(), "auth-broker.token"), "utf8").trim();
|
||||
} catch {
|
||||
status.broker = { error: "the broker wrote no token" };
|
||||
return restartSoon();
|
||||
}
|
||||
launch(
|
||||
"gateway",
|
||||
bin,
|
||||
["auth-gateway", "serve", `--bind=127.0.0.1:${gport}`, `--clients=${clientsFile()}`],
|
||||
daemonEnv({
|
||||
OMP_AUTH_BROKER_URL: `http://127.0.0.1:${bport}`,
|
||||
OMP_AUTH_BROKER_TOKEN: token,
|
||||
})
|
||||
);
|
||||
if (await waitPort(gport)) backoff = 5_000;
|
||||
else status.gateway = { error: "the gateway did not start listening" };
|
||||
}
|
||||
|
||||
/** A port a previous daemon is still letting go of gets a few seconds. */
|
||||
async function waitPortClosed(p, ms = 5_000) {
|
||||
for (const end = Date.now() + ms; Date.now() < end;) {
|
||||
if (!(await portOpen(p))) return true;
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function stopDaemons() {
|
||||
for (const [name, child] of Object.entries(procs)) {
|
||||
if (!child) continue;
|
||||
procs[name] = null;
|
||||
try {
|
||||
child.kill("SIGTERM");
|
||||
} catch {
|
||||
/* gone already */
|
||||
}
|
||||
}
|
||||
try {
|
||||
fs.rmSync(pidsFile(), { force: true });
|
||||
} catch {
|
||||
/* nothing recorded */
|
||||
}
|
||||
}
|
||||
|
||||
/** Start the daemons and the clients sync — when sharing is on, under S3 only. */
|
||||
async function start() {
|
||||
stopping = false;
|
||||
if (!available() || !enabled()) return;
|
||||
if (!timers.length) {
|
||||
const t = setInterval(syncClients, SYNC_MS);
|
||||
if (t.unref) t.unref();
|
||||
timers.push(t);
|
||||
}
|
||||
syncClients();
|
||||
if (procs.broker || procs.gateway) return;
|
||||
await startDaemons();
|
||||
}
|
||||
|
||||
/** Stop everything this module started (the server's shutdown, or the switch turned off). */
|
||||
function stop() {
|
||||
stopping = true;
|
||||
for (const t of timers) clearInterval(t);
|
||||
timers = [];
|
||||
if (restartTimer) clearTimeout(restartTimer);
|
||||
restartTimer = null;
|
||||
stopDaemons();
|
||||
status.broker = status.gateway = null;
|
||||
}
|
||||
|
||||
async function setEnabled(on) {
|
||||
ensureDir();
|
||||
if (on) {
|
||||
fs.writeFileSync(onFlag(), "", { mode: 0o600 });
|
||||
await start();
|
||||
} else {
|
||||
fs.rmSync(onFlag(), { force: true });
|
||||
stop();
|
||||
// No pane keeps a way in once sharing is off.
|
||||
lastClients = null;
|
||||
pending.clear();
|
||||
if (fs.existsSync(clientsFile())) writeClients({});
|
||||
}
|
||||
return state();
|
||||
}
|
||||
|
||||
/** What the settings card shows — never a token. */
|
||||
function state() {
|
||||
const live = (name) => (procs[name] ? status[name] : null);
|
||||
return {
|
||||
available: available(),
|
||||
enabled: enabled(),
|
||||
installed: !!ompBinary(),
|
||||
broker: live("broker")
|
||||
? { running: true }
|
||||
: { running: false, error: status.broker?.error || null },
|
||||
gateway: live("gateway")
|
||||
? { running: true, port: gatewayPort() }
|
||||
: { running: false, error: status.gateway?.error || null },
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
available,
|
||||
enabled,
|
||||
setEnabled,
|
||||
start,
|
||||
stop,
|
||||
state,
|
||||
forSpawn,
|
||||
syncClients,
|
||||
// exported for the tests
|
||||
mergedModels,
|
||||
providerEntry,
|
||||
shareModel,
|
||||
liveClients,
|
||||
PROVIDER,
|
||||
TOKEN_ENV,
|
||||
};
|
||||
+19
-2
@@ -302,7 +302,11 @@ function buildCodexArgv({
|
||||
* file, and the prompt is one argv element — or, for `after-start`, nothing
|
||||
* here: spawnRun pastes it into the pane once the CLI is up.
|
||||
*/
|
||||
function buildCatalogArgv(entry, bin, { model, resumeSessionId, resumeLast, bypass, initialPrompt }) {
|
||||
function buildCatalogArgv(
|
||||
entry,
|
||||
bin,
|
||||
{ model, resumeSessionId, resumeLast, bypass, initialPrompt }
|
||||
) {
|
||||
const argv = [bin, ...entry.args];
|
||||
if (model && entry.model) argv.push(entry.model.flag, model);
|
||||
if (resumeSessionId && entry.resumeById) argv.push(...entry.resumeById, resumeSessionId);
|
||||
@@ -485,6 +489,16 @@ function spawnRun(args) {
|
||||
? require("./codex-pool").forSpawn(require("./run-as").accountForLane(laneId), laneOwner)
|
||||
: null;
|
||||
const paneCred = require("./auth").newPaneToken();
|
||||
// A member's omp on the host's shared omp login (omp-share.js): its
|
||||
// models.yml points at the loopback gateway, keyed by this pane's token.
|
||||
const ompShare =
|
||||
tool === "omp"
|
||||
? require("./omp-share").forSpawn(
|
||||
require("./run-as").accountForLane(laneId),
|
||||
runIdForLane(laneId, slot),
|
||||
paneCred?.hash
|
||||
)
|
||||
: null;
|
||||
if (forkSession === true) {
|
||||
if (!resumeSessionId) throw makeErr("EBADSESSION", "a fork needs resumeSessionId");
|
||||
if (entry.launcher !== "claude") {
|
||||
@@ -540,7 +554,8 @@ function spawnRun(args) {
|
||||
systemPrompt,
|
||||
systemPromptFile,
|
||||
tool,
|
||||
model,
|
||||
// A shared omp login runs on the gateway's provider (omp-share.js).
|
||||
model: ompShare ? ompShare.model(model) : model,
|
||||
permissionMode,
|
||||
effort,
|
||||
sandbox,
|
||||
@@ -654,6 +669,7 @@ function spawnRun(args) {
|
||||
// this process only. Through tmux `-e`, never typed into the pane.
|
||||
...(poolAccount ? poolAccount.env : {}),
|
||||
...(codexPool ? codexPool.env : {}),
|
||||
...(ompShare ? ompShare.env : {}),
|
||||
// Server mode: the CLI, MCP server and statusline in this pane act as the
|
||||
// workspace's owner with it (server/lib/auth.js userFromPaneToken).
|
||||
...(paneCred ? { AGENTHUB_PANE_TOKEN: paneCred.token } : {}),
|
||||
@@ -688,6 +704,7 @@ function spawnRun(args) {
|
||||
const prepareHome = () => {
|
||||
codexPool?.place();
|
||||
poolAccount?.place();
|
||||
ompShare?.place();
|
||||
if (member && entry.launcher === "claude" && (poolAccount || proxyProfile)) {
|
||||
require("./run-as").markClaudeOnboarded(member);
|
||||
}
|
||||
|
||||
@@ -40,7 +40,8 @@ const schemas = {
|
||||
resume: { type: "boolean", description: "Offers Resume last session" },
|
||||
resumeById: {
|
||||
type: "boolean",
|
||||
description: "Can resume a named session (Claude, Codex, a catalog `resumeById` such as omp)",
|
||||
description:
|
||||
"Can resume a named session (Claude, Codex, a catalog `resumeById` such as omp)",
|
||||
},
|
||||
bypass: { type: "boolean", description: "Offers the per-run Bypass permissions switch" },
|
||||
proxyCapable: { type: "boolean", description: "Can use an AI Proxy profile" },
|
||||
@@ -132,6 +133,46 @@ const paths = {
|
||||
responses: ok,
|
||||
},
|
||||
},
|
||||
"/api/ai-tools/omp-sharing": {
|
||||
get: {
|
||||
tags: ["AI Tools"],
|
||||
summary: "Oh My Pi login sharing state",
|
||||
description:
|
||||
"Server mode with per-user accounts (S3): whether every member's omp pane runs on the HOST account's omp login through omp's own auth broker + gateway on loopback (server/lib/omp-share.js), and whether both daemons run. Never returns a token.",
|
||||
operationId: "aiToolsOmpSharing",
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"`{available, enabled, installed, broker: {running, error?}, gateway: {running, port?, error?}}`.",
|
||||
},
|
||||
},
|
||||
},
|
||||
put: {
|
||||
tags: ["AI Tools"],
|
||||
summary: "Turn Oh My Pi login sharing on or off",
|
||||
description:
|
||||
"An admin's switch (off by default). On starts `omp auth-broker serve` and `omp auth-gateway serve --clients=…` as the host; off stops them and empties the clients file, so no pane keeps a way in. Same-origin only; audited.",
|
||||
operationId: "aiToolsSetOmpSharing",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["enabled"],
|
||||
properties: { enabled: { type: "boolean" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: { description: "The new state, as GET." },
|
||||
400: { description: "EBADFIELD" },
|
||||
403: { description: "Not an admin" },
|
||||
409: { description: "ENOS3 — not server mode with per-user accounts" },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/ai-tools/{id}": {
|
||||
put: {
|
||||
tags: ["AI Tools"],
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* - GET/PATCH /api/me/account, POST /api/me/password, POST /api/me/sign-out-others
|
||||
* (Auth) — Settings → Account: the account's own profile, password and logins
|
||||
* - GET/PUT/DELETE /api/me/avatar (Auth) — the account's own picture
|
||||
* - POST /api/me/avatar/url (Auth) — the same picture downloaded from a link
|
||||
* - GET/PATCH /api/users/{id} (Auth) — the Users page's Edit user form
|
||||
* - GET /api/users/{id}/avatar (Auth) — one account's picture (admin/analyst)
|
||||
* - GET /api/users/tmux, POST /api/users/{id}/tmux-restart, POST /api/users/tmux-restart-idle
|
||||
@@ -267,7 +268,7 @@ const paths = {
|
||||
tags: ["Auth"],
|
||||
operationId: "changeMyPassword",
|
||||
summary:
|
||||
"Change the account's own password: the current one required (throttled); this login stays, every other ends",
|
||||
"Change the account's own password: the current one required (throttled); the new one must pass every rule (8+ characters, lower, upper, digit, symbol, not the username) and differ from the current one; this login stays, every other ends; clears mustChangePassword",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
@@ -285,7 +286,7 @@ const paths = {
|
||||
},
|
||||
responses: {
|
||||
200: { description: "Changed ({ok: true})" },
|
||||
400: err("EBADPASSWORD"),
|
||||
400: err("EBADPASSWORD / EWEAKPASSWORD (error.rules names the failed rules)"),
|
||||
401: err("EAUTH — no login cookie (a pane token is refused)"),
|
||||
403: err("EREAUTH (current password wrong) / ELOCALMODE"),
|
||||
429: err("ETOOMANY — 10 wrong passwords in 15 minutes"),
|
||||
@@ -494,6 +495,34 @@ const paths = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/me/avatar/url": {
|
||||
post: {
|
||||
tags: ["Auth"],
|
||||
operationId: "setMyAvatarFromUrl",
|
||||
summary:
|
||||
"Set the account's picture from an https link the server downloads (public addresses only, at most 3 redirects, PNG/JPEG/GIF/WebP, at most 1 MB). Login cookie only, audited",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["url"],
|
||||
properties: { url: { type: "string", format: "uri" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: { description: "Set", content: accountBody },
|
||||
400: err("EBADURL / EPRIVATEURL / EBADIMAGE"),
|
||||
401: err("EAUTH — no login cookie (a pane token is refused)"),
|
||||
403: err("ELOCALMODE"),
|
||||
413: err("ETOOBIG"),
|
||||
502: err("EFETCH — the link could not be downloaded"),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/users/{id}/avatar": {
|
||||
get: {
|
||||
tags: ["Auth"],
|
||||
|
||||
@@ -36,6 +36,36 @@ router.post("/refresh", sameOriginGuard, async (_req, res) => {
|
||||
res.json(aiTools.view());
|
||||
});
|
||||
|
||||
// Oh My Pi login sharing (server/lib/omp-share.js): S3 only, an admin's
|
||||
// switch (`/ai-tools` writes are admin-only, auth.js ADMIN_WRITE_PREFIXES).
|
||||
// Declared before `/:id`, which would take `omp-sharing` as a tool id.
|
||||
router.get("/omp-sharing", (_req, res) => {
|
||||
res.json(require("../lib/omp-share").state());
|
||||
});
|
||||
|
||||
router.put("/omp-sharing", sameOriginGuard, async (req, res) => {
|
||||
const on = req.body?.enabled;
|
||||
if (typeof on !== "boolean") {
|
||||
return res.status(400).json({ error: { code: "EBADFIELD", message: "enabled: boolean" } });
|
||||
}
|
||||
const share = require("../lib/omp-share");
|
||||
if (!share.available()) {
|
||||
return res.status(409).json({
|
||||
error: { code: "ENOS3", message: "sharing needs server mode with per-user accounts" },
|
||||
});
|
||||
}
|
||||
const state = await share.setEnabled(on);
|
||||
if (req.user) {
|
||||
require("../lib/audit").recordAccountChange(
|
||||
req.user.id,
|
||||
null,
|
||||
"PUT",
|
||||
`omp login sharing ${on ? "on" : "off"}`
|
||||
);
|
||||
}
|
||||
res.json(state);
|
||||
});
|
||||
|
||||
router.put("/:id", sameOriginGuard, (req, res) => {
|
||||
const { enabled, default: makeDefault, defaultModel } = req.body || {};
|
||||
// Every field checked before anything is written: a bad one saves nothing.
|
||||
|
||||
@@ -521,7 +521,7 @@ function applyFirstUserDescriptor(sessionId, result) {
|
||||
if (refreshedAgent) broadcast("agent_updated", refreshedAgent);
|
||||
}
|
||||
|
||||
const processEventTx = db.transaction((hookType, data, tool = "claude") => {
|
||||
const processEventTx = db.transaction((hookType, data, tool = "claude", peer = null) => {
|
||||
const sessionId = data.session_id;
|
||||
if (!sessionId) return null;
|
||||
|
||||
@@ -1297,7 +1297,7 @@ const processEventTx = db.transaction((hookType, data, tool = "claude") => {
|
||||
// A claude.ai page the session published goes into its owner's Artifacts.
|
||||
if (hookType === "PostToolUse" && toolName === "Artifact" && tool !== "codex") {
|
||||
try {
|
||||
const row = require("../lib/artifacts").captureFromHook(sessionId, data);
|
||||
const row = require("../lib/artifacts").captureFromHook(sessionId, data, peer);
|
||||
if (row) {
|
||||
broadcast("artifact_changed", { id: row.id, ownerId: row.owner_id, shared: false });
|
||||
}
|
||||
@@ -1313,12 +1313,12 @@ const processEventTx = db.transaction((hookType, data, tool = "claude") => {
|
||||
* that transaction COMMITTED — in order, in the same tick. A frame sent from
|
||||
* inside it described a row a later throw could still roll back.
|
||||
*/
|
||||
function processEvent(hookType, data, tool) {
|
||||
function processEvent(hookType, data, tool, peer = null) {
|
||||
const outer = pendingMarks;
|
||||
pendingMarks = [];
|
||||
let marks = null;
|
||||
try {
|
||||
const out = holdFrames(() => processEventTx(hookType, data, tool));
|
||||
const out = holdFrames(() => processEventTx(hookType, data, tool, peer));
|
||||
marks = pendingMarks;
|
||||
return out;
|
||||
} finally {
|
||||
@@ -1628,7 +1628,7 @@ function ingestHook(body, pushSource = null, peer = { kind: "host" }) {
|
||||
held = [];
|
||||
let result = null;
|
||||
try {
|
||||
result = processEvent(hook_type, data, tool);
|
||||
result = processEvent(hook_type, data, tool, pushSource ? null : peer);
|
||||
if (result) {
|
||||
if (pushSource) {
|
||||
// Tagged in the same tick processEvent created it, so no local liveness
|
||||
|
||||
+31
-4
@@ -18,14 +18,18 @@
|
||||
* `/account` (server mode) is the signed-in account itself for Settings →
|
||||
* Account: GET its profile, workspace/session counts and logins; PATCH its
|
||||
* display name and email; `POST /password` changes its own password (the old
|
||||
* one required, throttled like a step-up; every OTHER login ends);
|
||||
* one required, throttled like a step-up; every OTHER login ends; the new one
|
||||
* must pass every rule of `auth.passwordProblems` and differ from the old —
|
||||
* EWEAKPASSWORD with the failed `rules` — and it clears the "pick your own
|
||||
* password" flag an admin's create/reset sets);
|
||||
* `POST /sign-out-others` ends every login but this one. These writes take the
|
||||
* account from the login COOKIE only — a pane token (an agent acting as its
|
||||
* owner) may not change the owner's login. Each change is audited.
|
||||
* `/avatar` (server mode) is the account's own picture (server/lib/avatars.js):
|
||||
* GET serves it (`nosniff`, `sandbox` CSP), PUT takes the raw image bytes
|
||||
* (PNG/JPEG/GIF/WebP, at most 1 MB) and DELETE removes it — both writes are
|
||||
* account writes like the profile's. The sidebar shows it beside the name.
|
||||
* (PNG/JPEG/GIF/WebP, at most 1 MB), `POST /avatar/url` downloads the same
|
||||
* from an https link (public addresses only, server/lib/avatar-url.js) and
|
||||
* DELETE removes it — all writes are account writes like the profile's. The sidebar shows it beside the name.
|
||||
*
|
||||
* S3 (one Linux user per account): an account's dark/light choice also becomes
|
||||
* the theme in their own Claude Code settings, written as them.
|
||||
@@ -63,6 +67,7 @@ const PREF_KEYS = [
|
||||
"agenthub.tour",
|
||||
"agenthub.chatUi",
|
||||
"agenthub.workspace.allTabs",
|
||||
"agenthub.markdown.view",
|
||||
];
|
||||
const MAX_VALUE = 8 * 1024;
|
||||
|
||||
@@ -170,7 +175,11 @@ const ACCOUNT_STATUS = {
|
||||
EBADFIELD: 400,
|
||||
EBADEMAIL: 400,
|
||||
EBADPASSWORD: 400,
|
||||
EWEAKPASSWORD: 400,
|
||||
EBADIMAGE: 400,
|
||||
EBADURL: 400,
|
||||
EPRIVATEURL: 400,
|
||||
EFETCH: 502,
|
||||
ETOOBIG: 413,
|
||||
EREAUTH: 403,
|
||||
ETOOMANY: 429,
|
||||
@@ -206,7 +215,10 @@ function accountWrite(handler) {
|
||||
} catch (err) {
|
||||
const status = ACCOUNT_STATUS[err.code];
|
||||
if (!status) return res.status(500).json({ error: { message: err.message } });
|
||||
res.status(status).json({ error: { code: err.code, message: err.message } });
|
||||
// EWEAKPASSWORD names the rules the password failed.
|
||||
res.status(status).json({
|
||||
error: { code: err.code, message: err.message, ...(err.rules && { rules: err.rules }) },
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -233,6 +245,10 @@ router.post(
|
||||
sameOriginGuard,
|
||||
accountWrite(async (req, user, body) => {
|
||||
await auth.confirmPassword(user, body.currentPassword);
|
||||
// The account's own choice: every rule, and not the one it has now. This
|
||||
// also clears a "pick your own password" flag an admin's reset left.
|
||||
auth.validateOwnPassword(body.newPassword, user.username);
|
||||
await auth.assertNotCurrent(user.id, body.newPassword);
|
||||
await auth.setPassword(user.username, body.newPassword, { keepToken: auth.cookieToken(req) });
|
||||
return { out: { ok: true }, action: "own password changed" };
|
||||
})
|
||||
@@ -273,6 +289,17 @@ router.put(
|
||||
})
|
||||
);
|
||||
|
||||
// The same picture from a link: the server downloads it (server/lib/avatar-url.js).
|
||||
router.post(
|
||||
"/avatar/url",
|
||||
sameOriginGuard,
|
||||
accountWrite(async (_req, user, body) => {
|
||||
const buf = await require("../lib/avatar-url").fetchAvatarImage(body.url);
|
||||
avatars.setAvatar(user.id, buf);
|
||||
return { out: accountView(user.id), action: "own avatar set from a link" };
|
||||
})
|
||||
);
|
||||
|
||||
router.delete(
|
||||
"/avatar",
|
||||
sameOriginGuard,
|
||||
|
||||
+83
-32
@@ -25,7 +25,11 @@
|
||||
* workspace) — are an admin's only (`/users` is in ADMIN_WRITE_PREFIXES), each
|
||||
* needs the admin's own password again (`currentPassword`, `confirmPassword`),
|
||||
* an admin never demotes or disables themselves (ESELF), every change is
|
||||
* audited, and in local mode — no login to check — they all refuse.
|
||||
* audited, and in local mode — no login to check — they all refuse. A
|
||||
* password an admin gives another account (create, reset) may be anything
|
||||
* non-empty and makes that account pick its own at its next login
|
||||
* (`must_change_password`); an admin's own new password must pass every
|
||||
* rule, like Settings → Account's (`validateOwnPassword`, EWEAKPASSWORD).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -33,7 +37,6 @@ const { Router } = require("express");
|
||||
const { db, stmts } = require("../db");
|
||||
const auth = require("../lib/auth");
|
||||
const { listUsers } = auth;
|
||||
const { sessionWhere } = require("../lib/scope");
|
||||
const { listAudit, recordAccountChange } = require("../lib/audit");
|
||||
|
||||
const router = Router();
|
||||
@@ -54,42 +57,72 @@ const USAGE_COLS = [
|
||||
"long_cache_write_1h_tokens",
|
||||
];
|
||||
|
||||
/** $ and tokens a member's sessions spent since `sinceHour` (UTC `YYYY-MM-DDTHH`). */
|
||||
function usageSince(userId, sinceHour, rules) {
|
||||
// Which account each session belongs to — through its workspace, or a
|
||||
// dashboard run on one (server/lib/scope.js's sessionWhere, for every user at once).
|
||||
const OWNED_SESSIONS = `
|
||||
SELECT s.id AS session_id, l.owner_id AS uid
|
||||
FROM sessions s JOIN lanes l ON l.id = s.lane_id
|
||||
WHERE l.owner_id IS NOT NULL
|
||||
UNION
|
||||
SELECT s.id, l.owner_id
|
||||
FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id JOIN sessions s ON s.id = r.session_id
|
||||
WHERE l.owner_id IS NOT NULL`;
|
||||
|
||||
/** Map(user id → {cost, tokens}) every account's sessions spent since `sinceHour` (UTC `YYYY-MM-DDTHH`). */
|
||||
function usageSince(sinceHour, rules) {
|
||||
const { calculateCost } = require("./pricing");
|
||||
const w = sessionWhere({ user: { id: userId, role: "developer" } }, "s.id", "s.lane_id");
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT h.model, h.speed, h.inference_geo, h.service_tier,
|
||||
`WITH owned AS (${OWNED_SESSIONS})
|
||||
SELECT o.uid, h.model, h.speed, h.inference_geo, h.service_tier,
|
||||
${USAGE_COLS.map((c) => `SUM(h.${c}) AS ${c}`).join(", ")}
|
||||
FROM token_usage_hourly h JOIN sessions s ON s.id = h.session_id
|
||||
WHERE h.hour >= ? AND ${w.where}
|
||||
GROUP BY h.model, h.speed, h.inference_geo, h.service_tier`
|
||||
FROM token_usage_hourly h JOIN owned o ON o.session_id = h.session_id
|
||||
WHERE h.hour >= ?
|
||||
GROUP BY o.uid, h.model, h.speed, h.inference_geo, h.service_tier`
|
||||
)
|
||||
.all(sinceHour, ...w.params);
|
||||
const tokens = rows.reduce(
|
||||
(n, r) => n + r.input_tokens + r.output_tokens + r.cache_read_tokens + r.cache_write_tokens,
|
||||
0
|
||||
);
|
||||
return { cost: rows.length ? calculateCost(rows, rules).total_cost : 0, tokens };
|
||||
.all(sinceHour);
|
||||
const byUser = new Map();
|
||||
for (const r of rows) {
|
||||
if (!byUser.has(r.uid)) byUser.set(r.uid, []);
|
||||
byUser.get(r.uid).push(r);
|
||||
}
|
||||
const out = new Map();
|
||||
for (const [uid, list] of byUser) {
|
||||
const tokens = list.reduce(
|
||||
(n, r) => n + r.input_tokens + r.output_tokens + r.cache_read_tokens + r.cache_write_tokens,
|
||||
0
|
||||
);
|
||||
out.set(uid, { cost: calculateCost(list, rules).total_cost, tokens });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const hourAgo = (days) => new Date(Date.now() - days * 864e5).toISOString().slice(0, 13);
|
||||
|
||||
/** Map(key → n) from rows of `{k, n}`. */
|
||||
const counts = (rows) => new Map(rows.map((r) => [r.k, r.n]));
|
||||
|
||||
router.get("/", (_req, res) => {
|
||||
const rules = stmts.listPricing.all();
|
||||
const users = listUsers().map((u) => {
|
||||
const w = sessionWhere({ user: { id: u.id, role: "developer" } });
|
||||
return {
|
||||
...u,
|
||||
...auth.loginStats(u.id),
|
||||
workspaces: db.prepare("SELECT COUNT(*) AS n FROM lanes WHERE owner_id = ?").get(u.id).n,
|
||||
sessions: db.prepare(`SELECT COUNT(*) AS n FROM sessions WHERE ${w.where}`).get(...w.params)
|
||||
.n,
|
||||
last7d: usageSince(u.id, hourAgo(7), rules),
|
||||
last30d: usageSince(u.id, hourAgo(30), rules),
|
||||
};
|
||||
});
|
||||
// A fixed number of grouped queries, however many accounts there are.
|
||||
const logins = auth.allLoginStats();
|
||||
const workspaces = counts(
|
||||
db.prepare("SELECT owner_id AS k, COUNT(*) AS n FROM lanes GROUP BY owner_id").all()
|
||||
);
|
||||
const sessions = counts(
|
||||
db.prepare(`SELECT uid AS k, COUNT(*) AS n FROM (${OWNED_SESSIONS}) GROUP BY uid`).all()
|
||||
);
|
||||
const last7d = usageSince(hourAgo(7), rules);
|
||||
const last30d = usageSince(hourAgo(30), rules);
|
||||
const none = { cost: 0, tokens: 0 };
|
||||
const users = listUsers().map((u) => ({
|
||||
...u,
|
||||
...(logins.get(u.id) || { logins: 0, lastLoginAt: null }),
|
||||
workspaces: workspaces.get(u.id) || 0,
|
||||
sessions: sessions.get(u.id) || 0,
|
||||
last7d: last7d.get(u.id) || none,
|
||||
last30d: last30d.get(u.id) || none,
|
||||
}));
|
||||
res.json({ users });
|
||||
});
|
||||
|
||||
@@ -112,6 +145,7 @@ const STATUS = {
|
||||
EBADEMAIL: 400,
|
||||
ENOTOWNED: 409,
|
||||
EBADPASSWORD: 400,
|
||||
EWEAKPASSWORD: 400,
|
||||
EBADROLE: 400,
|
||||
EBADFIELD: 400,
|
||||
EREAUTH: 403,
|
||||
@@ -150,7 +184,9 @@ function accountChange(handler) {
|
||||
} catch (err) {
|
||||
const status = STATUS[err.code];
|
||||
if (!status) return res.status(500).json({ error: { message: err.message } });
|
||||
res.status(status).json({ error: { code: err.code, message: err.message } });
|
||||
res.status(status).json({
|
||||
error: { code: err.code, message: err.message, ...(err.rules && { rules: err.rules }) },
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -196,7 +232,8 @@ router.post(
|
||||
"/",
|
||||
accountChange(async (_req, body) => {
|
||||
const role = body.role === undefined ? "developer" : body.role;
|
||||
const user = await auth.createUser(body.username, body.password, role);
|
||||
// The admin's password is a starting one: the user picks their own first.
|
||||
const user = await auth.createUser(body.username, body.password, role, { mustChange: true });
|
||||
return { status: 201, user, action: `create ${user.username} (${user.role})` };
|
||||
})
|
||||
);
|
||||
@@ -226,7 +263,13 @@ router.patch(
|
||||
if (self && body.disabled) throw ESELF("you cannot disable yourself");
|
||||
}
|
||||
const disabledChange = body.disabled !== undefined && body.disabled !== Boolean(u.disabled);
|
||||
if (body.password !== undefined) auth.validatePassword(body.password);
|
||||
if (body.password !== undefined) {
|
||||
// Your own password: every rule. Someone else's: a starting one they replace.
|
||||
if (self) {
|
||||
auth.validateOwnPassword(body.password, u.username);
|
||||
await auth.assertNotCurrent(u.id, body.password);
|
||||
} else auth.validatePassword(body.password);
|
||||
}
|
||||
if (body.signOut !== undefined && body.signOut !== true) {
|
||||
throw Object.assign(new Error("signOut: true"), { code: "EBADFIELD" });
|
||||
}
|
||||
@@ -265,7 +308,7 @@ router.patch(
|
||||
// the login they are doing it from.
|
||||
const keepToken = self ? auth.cookieToken(req) : undefined;
|
||||
if (body.password !== undefined) {
|
||||
await auth.setPassword(u.username, body.password, { keepToken });
|
||||
await auth.setPassword(u.username, body.password, { keepToken, mustChange: !self });
|
||||
done.push("password reset");
|
||||
}
|
||||
if (body.signOut) {
|
||||
@@ -375,7 +418,15 @@ router.post(
|
||||
"/:id/password",
|
||||
accountChange(async (req, body) => {
|
||||
const u = target(req);
|
||||
await auth.setPassword(u.username, body.password);
|
||||
const self = u.id === req.user.id;
|
||||
if (self) {
|
||||
auth.validateOwnPassword(body.password, u.username);
|
||||
await auth.assertNotCurrent(u.id, body.password);
|
||||
}
|
||||
await auth.setPassword(u.username, body.password, {
|
||||
keepToken: self ? auth.cookieToken(req) : undefined,
|
||||
mustChange: !self,
|
||||
});
|
||||
return { user: auth.publicUser(u), action: `${u.username}: password reset` };
|
||||
})
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user