Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
431 changes: 431 additions & 0 deletions docs/specs/lr-ec2d-single-user-removal.md

Large diffs are not rendered by default.

96 changes: 72 additions & 24 deletions lib/daemon.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ var fs = require("fs");
var path = require("path");
var { loadConfig, saveConfig, socketPath, ensureConfigDir, generateSlug, syncClayrc, removeFromClayrc, writeCrashInfo, readCrashInfo, clearCrashInfo, isPidAlive, clearStaleConfig, REAL_HOME } = require("./config");
var { createIPCServer } = require("./ipc");
var { createServer, generateAuthToken } = require("./server");
var { createServer } = require("./server");
var { checkAclSupport, grantProjectAccess, revokeProjectAccess, provisionAllUsers, provisionLinuxUser, grantAllUsersAccess, deactivateLinuxUser, ensureProjectsDir } = require("./os-users");
var usersModule = require("./users");
var { createWorktree, removeWorktree, isWorktree } = require("./worktree");
Expand Down Expand Up @@ -109,13 +109,76 @@ if (checkStaleInodes()) {
}


// --- Single-user migration hint ---
if (config.pinHash && !usersModule.isMultiUser()) {
var userData = usersModule.loadUsers();
if (userData.users.length === 0) {
console.log("[daemon] Single-user PIN mode active. Visit Settings → Users to migrate to multi-user mode.");
// --- Single-user to multi-user migration (lr-ec2d) ---
function migrateSingleUserToMultiUser(cfg, data) {
if (data.multiUser) return; // already migrated, no-op

var fsMig = require("fs");
var pathMig = require("path");
var cryptoMig = require("crypto");
var usersFilePath = usersModule.USERS_FILE;

var hasPin = !!(cfg && cfg.pinHash);
var hasAdmin = data.users && data.users.some(function (u) { return u.role === "admin"; });

data.multiUser = true;

// Generate setup code using the users module's exported function
var setupCode = cryptoMig.randomBytes(4).toString("hex").toUpperCase();
data.setupCode = setupCode;

// Case A: PIN set, no users — create admin stub (PIN cannot be transferred, incompatible hash formats)
if (hasPin && (!data.users || data.users.length === 0)) {
var adminId = cryptoMig.randomUUID();
data.users = [{
id: adminId,
username: "admin",
email: null,
displayName: "Admin",
pinHash: null, // PIN cannot be transferred — incompatible hash formats (lr-ec2d spec §7 risk 1)
role: "admin",
createdAt: Date.now(),
mustChangePin: false,
linuxUser: null,
profile: {
name: "Admin",
lang: "en-US",
avatarColor: "#7c3aed",
avatarStyle: "thumbs",
avatarSeed: cryptoMig.randomBytes(4).toString("hex"),
},
}];
}
// Case B: users present but no admin — set setupCode, keep existing users (done above)
// Case C: no PIN, no users — set setupCode, users stay empty (done above)

// Atomic synchronous write
var tmpFile = usersFilePath + ".tmp." + process.pid;
fsMig.writeFileSync(tmpFile, JSON.stringify(data, null, 2), { mode: 0o600 });
fsMig.renameSync(tmpFile, usersFilePath);

// Prominent banner
var port = (cfg && cfg.port) || 3000;
var url = "http://localhost:" + port + "/auth/setup?setupCode=" + setupCode;
console.log("");
console.log("┌─────────────────────────────────────────────────────────────┐");
console.log("│ Clagentic: Console — one-time upgrade step required │");
console.log("│ │");
console.log("│ Your install has been migrated to multi-user mode. │");
console.log("│ Open this URL to set your admin PIN: │");
console.log("│ │");
console.log("│ " + url.padEnd(61) + "│");
console.log("│ │");
console.log("│ Setup code also stored in ~/.clagentic/users.json │");
console.log("│ Your previous PIN cannot be transferred (format change). │");
console.log("│ Set a new PIN via the setup URL above. │");
console.log("└─────────────────────────────────────────────────────────────┘");
console.log("");
}

var _usersDataForMigration = usersModule.loadUsers();
migrateSingleUserToMultiUser(config, _usersDataForMigration);

// --- OS users mode: check required system dependencies ---
if (config.osUsers) {
var checkExec = require("child_process").execFileSync;
Expand Down Expand Up @@ -191,7 +254,6 @@ var relay = createServer({
tlsOptions: tlsOptions,
caPath: caRoot,
builtinCert: config.builtinCert || false,
pinHash: config.pinHash || null,
port: config.port,
debug: config.debug || false,
dangerouslySkipPermissions: config.dangerouslySkipPermissions || false,
Expand Down Expand Up @@ -968,23 +1030,9 @@ var relay = createServer({
console.log("[daemon] Update channel:", config.updateChannel, "(web)");
return { ok: true, updateChannel: config.updateChannel };
},
onSetPin: function (pin) {
if (pin) {
config.pinHash = generateAuthToken(pin);
} else {
config.pinHash = null;
}
relay.setAuthToken(config.pinHash);
saveConfig(config);
console.log("[daemon] PIN", pin ? "set" : "removed", "(web)");
return { ok: true, pinEnabled: !!config.pinHash };
},
onUpgradePin: function (newHash) {
config.pinHash = newHash;
relay.setAuthToken(newHash);
saveConfig(config);
console.log("[daemon] PIN hash auto-upgraded to scrypt");
},
// onSetPin intentionally removed — single-user PIN mode no longer exists (lr-ec2d).
// Individual user PINs are managed via users.js / server-admin.js.
onSetPin: null,
onSetChatLayout: function (layout) {
var val = (layout === "bubble") ? "bubble" : "channel";
config.chatLayout = val;
Expand Down
43 changes: 1 addition & 42 deletions lib/pages.js
Original file line number Diff line number Diff line change
@@ -1,44 +1,3 @@
function pinPageHtml() {
return '<!DOCTYPE html><html lang="en"><head>' +
'<meta charset="UTF-8">' +
'<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">' +
'<meta name="mobile-web-app-capable" content="yes">' +
'<link rel="icon" type="image/png" href="/favicon-banded.png">' +
'<link rel="apple-touch-icon" href="/apple-touch-icon.png">' +
'<title>Clagentic:Console</title>' +
'<style>' + authPageStyles + '</style></head><body><div class="c">' +
'<h1 id="greeting"></h1>' +
'<div class="sub">Enter your PIN to continue</div>' +
pinBoxesHtml +
'<div class="err" id="err"></div>' +
'<script>' +
'(function(){' +
'var h=document.getElementById("greeting");' +
'var visited=localStorage.getItem("clay_visited");' +
'if(visited){h.textContent="Welcome back"}' +
'else{h.textContent="Welcome to Clagentic:Console";localStorage.setItem("clay_visited","1")}' +
'})();' +
pinBoxScript +
'var err=document.getElementById("err");' +
'function submitPin(){' +
'var pin=document.getElementById("pin").value;' +
'var boxes=document.querySelectorAll(".pin-digit");' +
'fetch("/auth",{method:"POST",headers:{"Content-Type":"application/json"},' +
'body:JSON.stringify({pin:pin})})' +
'.then(function(r){return r.json()})' +
'.then(function(d){' +
'if(d.ok){location.reload();return}' +
'if(d.locked){for(var i=0;i<boxes.length;i++)boxes[i].disabled=true;' +
'err.textContent="Too many attempts. Try again in "+Math.ceil(d.retryAfter/60)+" min";' +
'setTimeout(function(){for(var i=0;i<boxes.length;i++)boxes[i].disabled=false;' +
'err.textContent="";resetPinBoxes()},d.retryAfter*1000);return}' +
'var msg="Wrong PIN";if(typeof d.attemptsLeft==="number"&&d.attemptsLeft<=3)msg+=" ("+d.attemptsLeft+" left)";' +
'err.textContent=msg;resetPinBoxes()})' +
'.catch(function(){err.textContent="Connection error"})}' +
'initPinBoxes("pin-boxes","pin",submitPin);' +
'</script></div></body></html>';
}

function setupPageHtml(httpsUrl, httpUrl, hasCert, lanMode) {
return `<!DOCTYPE html><html lang="en"><head>
<meta charset="UTF-8">
Expand Down Expand Up @@ -1259,4 +1218,4 @@ function noProjectsPageHtml() {
'</div></body></html>';
}

module.exports = { pinPageHtml: pinPageHtml, setupPageHtml: setupPageHtml, adminSetupPageHtml: adminSetupPageHtml, multiUserLoginPageHtml: multiUserLoginPageHtml, smtpLoginPageHtml: smtpLoginPageHtml, invitePageHtml: invitePageHtml, smtpInvitePageHtml: smtpInvitePageHtml, noProjectsPageHtml: noProjectsPageHtml };
module.exports = { setupPageHtml: setupPageHtml, adminSetupPageHtml: adminSetupPageHtml, multiUserLoginPageHtml: multiUserLoginPageHtml, smtpLoginPageHtml: smtpLoginPageHtml, invitePageHtml: invitePageHtml, smtpInvitePageHtml: smtpInvitePageHtml, noProjectsPageHtml: noProjectsPageHtml };
87 changes: 1 addition & 86 deletions lib/server-admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,6 @@ function attachAdmin(ctx) {

// List all users (admin only)
if (req.method === "GET" && fullUrl === "/api/admin/users") {
if (!users.isMultiUser()) {
res.writeHead(404, { "Content-Type": "application/json" });
res.end('{"error":"Not found"}');
return true;
}
var mu = getMultiUserFromReq(req);
if (!mu) {
res.writeHead(401, { "Content-Type": "application/json" });
Expand All @@ -54,11 +49,6 @@ function attachAdmin(ctx) {

// Remove user (admin only)
if (req.method === "DELETE" && fullUrl.indexOf("/api/admin/users/") === 0) {
if (!users.isMultiUser()) {
res.writeHead(404, { "Content-Type": "application/json" });
res.end('{"error":"Not found"}');
return true;
}
var mu = getMultiUserFromReq(req);
if (!mu || mu.role !== "admin") {
res.writeHead(403, { "Content-Type": "application/json" });
Expand Down Expand Up @@ -99,11 +89,6 @@ function attachAdmin(ctx) {

// Create user (admin only) — generates a temporary PIN that must be changed on first login
if (req.method === "POST" && fullUrl === "/api/admin/users") {
if (!users.isMultiUser()) {
res.writeHead(404, { "Content-Type": "application/json" });
res.end('{"error":"Not found"}');
return true;
}
var mu = getMultiUserFromReq(req);
if (!mu || mu.role !== "admin") {
res.writeHead(403, { "Content-Type": "application/json" });
Expand Down Expand Up @@ -166,11 +151,6 @@ function attachAdmin(ctx) {

// Reset user PIN (admin only) — generates a new temp PIN
if (req.method === "POST" && fullUrl.match(/^\/api\/admin\/users\/[^/]+\/reset-pin$/)) {
if (!users.isMultiUser()) {
res.writeHead(404, { "Content-Type": "application/json" });
res.end('{"error":"Not found"}');
return true;
}
var mu = getMultiUserFromReq(req);
if (!mu || mu.role !== "admin") {
res.writeHead(403, { "Content-Type": "application/json" });
Expand Down Expand Up @@ -214,11 +194,6 @@ function attachAdmin(ctx) {

// Set Linux user mapping (admin only, OS-level multi-user)
if (req.method === "PUT" && fullUrl.match(/^\/api\/admin\/users\/[^/]+\/linux-user$/)) {
if (!users.isMultiUser()) {
res.writeHead(404, { "Content-Type": "application/json" });
res.end('{"error":"Not found"}');
return true;
}
var mu = getMultiUserFromReq(req);
if (!mu || mu.role !== "admin") {
res.writeHead(403, { "Content-Type": "application/json" });
Expand Down Expand Up @@ -250,11 +225,6 @@ function attachAdmin(ctx) {

// Update user permissions (admin only)
if (req.method === "PUT" && fullUrl.match(/^\/api\/admin\/users\/[^/]+\/permissions$/)) {
if (!users.isMultiUser()) {
res.writeHead(404, { "Content-Type": "application/json" });
res.end('{"error":"Not found"}');
return true;
}
var mu = getMultiUserFromReq(req);
if (!mu || mu.role !== "admin") {
res.writeHead(403, { "Content-Type": "application/json" });
Expand Down Expand Up @@ -286,11 +256,6 @@ function attachAdmin(ctx) {

// Create invite (admin only)
if (req.method === "POST" && fullUrl === "/api/admin/invites") {
if (!users.isMultiUser()) {
res.writeHead(404, { "Content-Type": "application/json" });
res.end('{"error":"Not found"}');
return true;
}
var mu = getMultiUserFromReq(req);
if (!mu || mu.role !== "admin") {
res.writeHead(403, { "Content-Type": "application/json" });
Expand All @@ -308,11 +273,6 @@ function attachAdmin(ctx) {

// List invites (admin only)
if (req.method === "GET" && fullUrl === "/api/admin/invites") {
if (!users.isMultiUser()) {
res.writeHead(404, { "Content-Type": "application/json" });
res.end('{"error":"Not found"}');
return true;
}
var mu = getMultiUserFromReq(req);
if (!mu || mu.role !== "admin") {
res.writeHead(403, { "Content-Type": "application/json" });
Expand All @@ -326,11 +286,6 @@ function attachAdmin(ctx) {

// Revoke invite (admin only)
if (req.method === "DELETE" && fullUrl.indexOf("/api/admin/invites/") === 0) {
if (!users.isMultiUser()) {
res.writeHead(404, { "Content-Type": "application/json" });
res.end('{"error":"Not found"}');
return true;
}
var mu = getMultiUserFromReq(req);
if (!mu || mu.role !== "admin") {
res.writeHead(403, { "Content-Type": "application/json" });
Expand All @@ -356,7 +311,7 @@ function attachAdmin(ctx) {

// Send invite via email (admin only)
if (req.method === "POST" && fullUrl === "/api/admin/invites/email") {
if (!users.isMultiUser() || !smtp.isSmtpConfigured()) {
if (!smtp.isSmtpConfigured()) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end('{"error":"SMTP not configured"}');
return true;
Expand Down Expand Up @@ -398,11 +353,6 @@ function attachAdmin(ctx) {

// Get SMTP config (admin only)
if (req.method === "GET" && fullUrl === "/api/admin/smtp") {
if (!users.isMultiUser()) {
res.writeHead(404, { "Content-Type": "application/json" });
res.end('{"error":"Not found"}');
return true;
}
var mu = getMultiUserFromReq(req);
if (!mu || mu.role !== "admin") {
res.writeHead(403, { "Content-Type": "application/json" });
Expand All @@ -422,11 +372,6 @@ function attachAdmin(ctx) {

// Save SMTP config (admin only)
if (req.method === "POST" && fullUrl === "/api/admin/smtp") {
if (!users.isMultiUser()) {
res.writeHead(404, { "Content-Type": "application/json" });
res.end('{"error":"Not found"}');
return true;
}
var mu = getMultiUserFromReq(req);
if (!mu || mu.role !== "admin") {
res.writeHead(403, { "Content-Type": "application/json" });
Expand Down Expand Up @@ -488,11 +433,6 @@ function attachAdmin(ctx) {

// Test SMTP connection (admin only)
if (req.method === "POST" && fullUrl === "/api/admin/smtp/test") {
if (!users.isMultiUser()) {
res.writeHead(404, { "Content-Type": "application/json" });
res.end('{"error":"Not found"}');
return true;
}
var mu = getMultiUserFromReq(req);
if (!mu || mu.role !== "admin") {
res.writeHead(403, { "Content-Type": "application/json" });
Expand Down Expand Up @@ -543,11 +483,6 @@ function attachAdmin(ctx) {

// Set project visibility (admin only)
if (req.method === "PUT" && /^\/api\/admin\/projects\/[a-z0-9_-]+\/visibility$/.test(fullUrl)) {
if (!users.isMultiUser()) {
res.writeHead(404, { "Content-Type": "application/json" });
res.end('{"error":"Not found"}');
return true;
}
var mu = getMultiUserFromReq(req);
var _visSlug = fullUrl.split("/")[4];
var _visAccess = onGetProjectAccess ? onGetProjectAccess(_visSlug) : null;
Expand Down Expand Up @@ -596,11 +531,6 @@ function attachAdmin(ctx) {

// Set project owner (admin only)
if (req.method === "PUT" && /^\/api\/admin\/projects\/[a-z0-9_-]+\/owner$/.test(fullUrl)) {
if (!users.isMultiUser()) {
res.writeHead(404, { "Content-Type": "application/json" });
res.end('{"error":"Not found"}');
return true;
}
var mu = getMultiUserFromReq(req);
if (!mu || mu.role !== "admin") {
res.writeHead(403, { "Content-Type": "application/json" });
Expand Down Expand Up @@ -648,11 +578,6 @@ function attachAdmin(ctx) {

// Set project allowed users (admin only)
if (req.method === "PUT" && /^\/api\/admin\/projects\/[a-z0-9_-]+\/users$/.test(fullUrl)) {
if (!users.isMultiUser()) {
res.writeHead(404, { "Content-Type": "application/json" });
res.end('{"error":"Not found"}');
return true;
}
var mu = getMultiUserFromReq(req);
var _usrSlug = fullUrl.split("/")[4];
var _usrAccess = onGetProjectAccess ? onGetProjectAccess(_usrSlug) : null;
Expand Down Expand Up @@ -701,11 +626,6 @@ function attachAdmin(ctx) {

// Get project access info (admin or project owner)
if (req.method === "GET" && /^\/api\/admin\/projects\/[a-z0-9_-]+\/access$/.test(fullUrl)) {
if (!users.isMultiUser()) {
res.writeHead(404, { "Content-Type": "application/json" });
res.end('{"error":"Not found"}');
return true;
}
var mu = getMultiUserFromReq(req);
var _accSlug = fullUrl.split("/")[4];
var _accAccess = onGetProjectAccess ? onGetProjectAccess(_accSlug) : null;
Expand Down Expand Up @@ -734,11 +654,6 @@ function attachAdmin(ctx) {

// GET /api/admin/audit — tail the audit log (admin only, read-only)
if (req.method === "GET" && fullUrl.indexOf("/api/admin/audit") === 0) {
if (!users.isMultiUser()) {
res.writeHead(404, { "Content-Type": "application/json" });
res.end('{"error":"Not found"}');
return true;
}
var mu = getMultiUserFromReq(req);
if (!mu || mu.role !== "admin") {
res.writeHead(403, { "Content-Type": "application/json" });
Expand Down
Loading
Loading