8546748759
Dependencies
- Security: body-parser and multer DoS advisories fixed (npm audit: 0).
- Server: Express 5 (query parser kept "extended", req.body defaults to {},
SPA catch-all /{*splat}, send-file helper restores Express 4's dot-folder
rule for sendFile/download, alerts ack route without inline regex),
better-sqlite3 13, js-yaml 5 (server/lib/yaml.js keeps js-yaml 4's default
schema), uuid dropped for crypto.randomUUID, node-pty beta.18, multer 2.5.
- Client: React 19, xterm 6 (WebGL renderer replaces the removed canvas one,
DOM fallback on context loss; Alt+arrow word moves re-mapped), lucide-react 1,
TypeScript 7, i18next/react-i18next/codemirror minors.
Workspace rename step 4
- `agenthub lanes` and `--lane` exit 1 with "renamed: use …"; client api.ts,
the CLI and the MCP server call /api/workspaces; /api/lanes stays mounted.
Server mode (S3): members manage their own Claude Code config
- server/lib/cc-target.js resolves whose config a /cc-config request touches
and gates the router by allowlist; /profiles* and /plugins/update-all stay
admin-only. Member writes go through write-as, CLI calls through sudo as the
member (never -y), reads through openSafe; cc_config_changed reaches only
its owner. Not verified live on an S3 install yet.
Other
- Chat: an HTML file is an artifact only when written whole and not a project
page (index.html, or under src/, public/, templates/, …).
- OpenAPI covers every /api route; openapi-coverage.test.js guards it.
- read-pool test keeps its event loop alive (its worker is unref'd).
- Docs: plan statuses, checklists, CLI.md, LANES.md, SERVER-MODE.md, API.md.
136 lines
4.6 KiB
JavaScript
136 lines
4.6 KiB
JavaScript
/**
|
|
* @file cc-target.js
|
|
* @description Whose Claude Code configuration a `/api/cc-config` request reads
|
|
* and writes — the ONE resolver every handler of `routes/cc-config.js` uses,
|
|
* and the allowlist of the endpoints a non-admin may reach there at all.
|
|
*
|
|
* - Local mode, or an admin: the host's Claude home (`getClaudeHome()`), with
|
|
* every handler behaving exactly as it did before members had a config page.
|
|
* - Server mode, a non-admin with an enabled, provisioned Linux account (S3):
|
|
* their OWN `<home>/.claude` and `<home>/.claude.json`. Their writes run as
|
|
* them (cc-mutate's `ioFor`, cc-plugin-cli's sudo), never as the host.
|
|
* - An analyst with no such account, reading: the host's, read-only — what an
|
|
* analyst read before. `?view=host` gives an analyst WITH an account that
|
|
* same read-only view.
|
|
* - Anyone else (a developer with no account, any non-admin write without
|
|
* one): 403 ENOACCOUNT — never "the host's then".
|
|
*
|
|
* Nothing here takes a home or a path from the request.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const path = require("node:path");
|
|
const { getClaudeHome } = require("./claude-home");
|
|
|
|
/**
|
|
* What a non-admin may call under `/api/cc-config` ("METHOD /path", lower
|
|
* case, HEAD counted as GET): reads of their own config and the writes that
|
|
* run as them. Everything else — `/profiles*` (the host's login switching),
|
|
* `/plugins/update-all` (the host's Update all, AgentHub's own plugin
|
|
* included), and any endpoint added later — stays an admin's until it is
|
|
* listed here.
|
|
*/
|
|
const MEMBER_ROUTES = new Set([
|
|
"GET /target",
|
|
"GET /overview",
|
|
"GET /skills",
|
|
"GET /agents",
|
|
"GET /commands",
|
|
"GET /output-styles",
|
|
"GET /plugins",
|
|
"GET /plugins/available",
|
|
"GET /mcp",
|
|
"GET /hooks",
|
|
"GET /settings",
|
|
"GET /memory",
|
|
"GET /marketplaces",
|
|
"GET /keybindings",
|
|
"GET /statusline",
|
|
"GET /hook-scripts",
|
|
"GET /file",
|
|
"GET /backups",
|
|
"PUT /keybindings",
|
|
"PUT /file",
|
|
"DELETE /file",
|
|
"PATCH /settings",
|
|
"POST /project-memory",
|
|
"POST /plugins/install",
|
|
"POST /plugins/action",
|
|
"POST /marketplaces/add",
|
|
"POST /marketplaces/action",
|
|
"POST /mcp/add",
|
|
"POST /mcp/remove",
|
|
"POST /reload",
|
|
]);
|
|
|
|
/** The host-wide reads an analyst had before (staff read everything): still read-only. */
|
|
const ANALYST_READS = new Set(["GET /profiles", "GET /plugins/update-all"]);
|
|
|
|
/** `"METHOD /path"` as the allowlists spell it (decoded, lower case, no trailing slash). */
|
|
function routeKey(req) {
|
|
const p = require("./auth").apiPath(req).toLowerCase().replace(/\/+$/, "") || "/";
|
|
const method = req.method === "HEAD" ? "GET" : String(req.method || "").toUpperCase();
|
|
return `${method} ${p}`;
|
|
}
|
|
|
|
/** Whether `req.user` may reach this cc-config endpoint at all (local mode and admins: yes). */
|
|
function mayReach(req) {
|
|
const u = req.user;
|
|
if (!u || u.role === "admin") return true;
|
|
const key = routeKey(req);
|
|
return MEMBER_ROUTES.has(key) || (u.role === "analyst" && ANALYST_READS.has(key));
|
|
}
|
|
|
|
/** Express middleware: the router's own gate (403 EADMIN), the single rule for `/cc-config`. */
|
|
function ccGate(req, res, next) {
|
|
if (mayReach(req)) return next();
|
|
res.status(403).json({ error: { code: "EADMIN", message: "only an admin may open this" } });
|
|
}
|
|
|
|
const isRead = (req) => req.method === "GET" || req.method === "HEAD";
|
|
|
|
function host(readOnly = false) {
|
|
return {
|
|
acct: null,
|
|
own: false,
|
|
readOnly,
|
|
home: getClaudeHome(),
|
|
claudeJson: require("./cc-discovery").getClaudeJsonPath(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The target of `req`: `{acct, own, readOnly, home, claudeJson}` — `acct` and
|
|
* `own` set for a member's own config.
|
|
* @throws ENOACCOUNT (status 403) for a non-admin with no account to act on
|
|
*/
|
|
function resolveCcTarget(req) {
|
|
const u = req.user;
|
|
if (!u || u.role === "admin") return host();
|
|
if (u.role === "analyst" && isRead(req) && req.query?.view === "host") return host(true);
|
|
let acct = null;
|
|
try {
|
|
acct = require("./run-as").accountForUser(u.username);
|
|
} catch {
|
|
acct = null; // a disabled / unprovisioned account is no account: refuse below
|
|
}
|
|
if (acct) {
|
|
return {
|
|
acct,
|
|
own: true,
|
|
readOnly: false,
|
|
home: path.join(acct.home, ".claude"),
|
|
claudeJson: path.join(acct.home, ".claude.json"),
|
|
};
|
|
}
|
|
if (u.role === "analyst" && isRead(req)) return host(true);
|
|
throw Object.assign(
|
|
new Error(
|
|
"your Claude Code config lives in your own account, and this server has none for you yet"
|
|
),
|
|
{ code: "ENOACCOUNT", status: 403 }
|
|
);
|
|
}
|
|
|
|
module.exports = { resolveCcTarget, ccGate, mayReach, routeKey, MEMBER_ROUTES, ANALYST_READS };
|