0c2f49876d
- Users page: one Edit user form (display name, email, role, active, a new or generated password, workspaces given or taken back, sign out everywhere), validated as a whole before anything is written (PATCH /api/users/:id, GET /api/users/:id); users.display_name/email added migration-safe. - Settings → Account for every signed-in user: profile, own password (the current one required, this login kept), sign out other devices (/api/me/account, /api/me/password, /api/me/sign-out-others; login cookie only, never a pane token). - Display names wherever a person is named (sidebar, workspace owner groups, Users page and audit log); owner_display_name / reader_display additive. - Users page: admins add and remove Claude/Codex pool accounts. - Folder browser: an admin with their own Linux account opens in their home; the "Server folder" tick opens the host's (never remembered). - AI Proxy: "Config 9router" beside Add profile when 9router is installed, opened on the dashboard's own host (GET /api/ai-proxies/9router). - Terminal: a tmux selection reaches the clipboard over plain HTTP too (writeClipboard's textarea fallback, focus handed back). - Chat: a file the session touched outside the workspace previews (GET /api/sessions/:id/file). - install-system: `!requiretty` only when visudo accepts it (sudo-rs on Ubuntu 25.10+ rejects the setting). - openapi.yaml regenerated; docs (API, SERVER-MODE, LANES) updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
106 lines
4.0 KiB
JavaScript
106 lines
4.0 KiB
JavaScript
/**
|
|
* @file server-s3-onboarded.test.js
|
|
* @description S3: a member's Claude pane on a pool account (or an AI Proxy)
|
|
* must not stop at the first-run "Select login method" screen a fresh home
|
|
* shows even with CLAUDE_CODE_OAUTH_TOKEN set — `run-as.markClaudeOnboarded`
|
|
* adds `hasCompletedOnboarding: true` to the member's `~/.claude.json`, AS the
|
|
* member (write-as, played by fixtures/fake-sudo.js), keeping every other key
|
|
* and leaving a file it cannot trust alone.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const { describe, it, before, beforeEach, 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.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "server-s3-onboarded-")));
|
|
const USERS = path.join(TMP, "users");
|
|
const HOME = path.join(USERS, "mia");
|
|
const FILE = path.join(HOME, ".claude.json");
|
|
process.env.AGENTHUB_SERVER_CONF = path.join(TMP, "server.conf");
|
|
process.env.AGENTHUB_PASSWD = path.join(TMP, "passwd");
|
|
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
|
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
|
|
process.env.CLAUDE_HOME = path.join(TMP, ".claude");
|
|
|
|
const auth = require("../lib/auth");
|
|
const linux = require("../lib/linux-accounts");
|
|
const runAs = require("../lib/run-as");
|
|
const { fakeSudo } = require("./fixtures/fake-sudo");
|
|
const sudo = fakeSudo();
|
|
let acct;
|
|
|
|
before(async () => {
|
|
fs.mkdirSync(HOME, { recursive: true });
|
|
// The member's uid is this test's own, so its files pass the owner check.
|
|
const uid = process.getuid();
|
|
fs.writeFileSync(process.env.AGENTHUB_PASSWD, `ah-mia:x:${uid}:${uid}::/:/bin/sh\n`);
|
|
fs.writeFileSync(
|
|
process.env.AGENTHUB_SERVER_CONF,
|
|
`HOST_USER=h\nHOST_UID=1\nUSERS_ROOT=${USERS}\nPORT=4820\n`
|
|
);
|
|
linux.__resetConfCache();
|
|
runAs.__resetCache();
|
|
linux.__setRunImpl(() => "ok");
|
|
runAs.__setExecImpl(sudo.exec);
|
|
process.env.AGENTHUB_MODE = "server";
|
|
await auth.createUser("mia", "member-pass-1", "developer");
|
|
acct = runAs.accountForPath(HOME);
|
|
assert.ok(acct, "mia is a provisioned member");
|
|
});
|
|
|
|
after(() => {
|
|
delete process.env.AGENTHUB_MODE;
|
|
});
|
|
|
|
beforeEach(() => {
|
|
fs.rmSync(FILE, { force: true });
|
|
sudo.calls.length = 0;
|
|
});
|
|
|
|
describe("markClaudeOnboarded", () => {
|
|
it("creates ~/.claude.json, 0600, as the member", () => {
|
|
assert.equal(runAs.markClaudeOnboarded(acct), true);
|
|
assert.deepEqual(JSON.parse(fs.readFileSync(FILE, "utf8")), { hasCompletedOnboarding: true });
|
|
assert.equal(fs.statSync(FILE).mode & 0o777, 0o600);
|
|
assert.ok(sudo.calls.some((c) => c.includes(FILE) && c.includes("private")));
|
|
});
|
|
|
|
it("adds only the flag, keeping the member's own state", () => {
|
|
fs.writeFileSync(FILE, JSON.stringify({ theme: "light", projects: { "/x": {} } }));
|
|
assert.equal(runAs.markClaudeOnboarded(acct), true);
|
|
assert.deepEqual(JSON.parse(fs.readFileSync(FILE, "utf8")), {
|
|
theme: "light",
|
|
projects: { "/x": {} },
|
|
hasCompletedOnboarding: true,
|
|
});
|
|
});
|
|
|
|
it("writes nothing when the member is already onboarded", () => {
|
|
fs.writeFileSync(FILE, JSON.stringify({ hasCompletedOnboarding: true }));
|
|
assert.equal(runAs.markClaudeOnboarded(acct), false);
|
|
assert.equal(sudo.calls.length, 0);
|
|
});
|
|
|
|
it("leaves a file that is not JSON, or not a regular file, alone", () => {
|
|
fs.writeFileSync(FILE, "{ half-written");
|
|
assert.equal(runAs.markClaudeOnboarded(acct), false);
|
|
assert.equal(fs.readFileSync(FILE, "utf8"), "{ half-written");
|
|
fs.rmSync(FILE);
|
|
fs.mkdirSync(FILE);
|
|
assert.equal(runAs.markClaudeOnboarded(acct), false);
|
|
fs.rmdirSync(FILE);
|
|
assert.equal(sudo.calls.length, 0);
|
|
});
|
|
|
|
it("never writes through a link the member left at the name", () => {
|
|
const target = path.join(TMP, "elsewhere.json");
|
|
fs.writeFileSync(target, "{}");
|
|
fs.symlinkSync(target, FILE);
|
|
runAs.markClaudeOnboarded(acct);
|
|
assert.equal(fs.readFileSync(target, "utf8"), "{}");
|
|
});
|
|
});
|