clay-server 2.28.0-beta.3 → 2.29.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/daemon.js CHANGED
@@ -267,6 +267,20 @@ var relay = createServer({
267
267
  spawnOpts.gid = parseInt(passwdLine[1], 10);
268
268
  } catch (e) {}
269
269
  }
270
+ // Pre-create target directory and chown to user so git clone can write into it
271
+ if (config.osUsers && wsUser && wsUser.linuxUser && spawnOpts.uid != null) {
272
+ if (fs.existsSync(targetDir)) {
273
+ callback({ ok: false, error: "Target directory already exists: " + targetDir });
274
+ return;
275
+ }
276
+ try {
277
+ fs.mkdirSync(targetDir, { mode: 0o700 });
278
+ fs.chownSync(targetDir, spawnOpts.uid, spawnOpts.gid);
279
+ } catch (e) {
280
+ callback({ ok: false, error: "Failed to prepare project directory: " + e.message });
281
+ return;
282
+ }
283
+ }
270
284
  var proc = spawn("git", ["clone", cloneUrl, targetDir], spawnOpts);
271
285
  var stderrBuf = "";
272
286
  proc.stderr.on("data", function (chunk) { stderrBuf += chunk.toString(); });
@@ -81,7 +81,8 @@ function attachConnection(ctx) {
81
81
  var _filteredProjects = getProjectList(_userId);
82
82
  var title = getTitle();
83
83
  var project = getProject();
84
- sendTo(ws, { type: "info", cwd: cwd, slug: slug, project: title || project, version: currentVersion, debug: !!debug, dangerouslySkipPermissions: dangerouslySkipPermissions, osUsers: osUsers, lanHost: lanHost, projectCount: _filteredProjects.length, projects: _filteredProjects, projectOwnerId: projectOwnerId });
84
+ var ownerLocked = !!(osUsers && osUsers.length > 0 && /^\/home\/[^/]+\//.test(cwd));
85
+ sendTo(ws, { type: "info", cwd: cwd, slug: slug, project: title || project, version: currentVersion, debug: !!debug, dangerouslySkipPermissions: dangerouslySkipPermissions, osUsers: osUsers, lanHost: lanHost, projectCount: _filteredProjects.length, projects: _filteredProjects, projectOwnerId: projectOwnerId, ownerLocked: ownerLocked });
85
86
  var latestVersion = getLatestVersion();
86
87
  if (latestVersion && ws._clayUser && ws._clayUser.role === "admin") {
87
88
  sendTo(ws, { type: "update_available", version: latestVersion });
@@ -112,6 +112,11 @@ function attachSessions(ctx) {
112
112
  }
113
113
 
114
114
  if (msg.type === "transfer_project_owner") {
115
+ // Home directory projects: ownership is permanently locked
116
+ if (osUsers && osUsers.length > 0 && /^\/home\/[^/]+\//.test(cwd)) {
117
+ sendTo(ws, { type: "error", text: "Cannot transfer ownership of home directory projects." });
118
+ return true;
119
+ }
115
120
  var projectOwnerId = getProjectOwnerId();
116
121
  var isAdmin = ws._clayUser && ws._clayUser.role === "admin";
117
122
  var isProjectOwner = ws._clayUser && projectOwnerId && ws._clayUser.id === projectOwnerId;
@@ -845,6 +850,14 @@ function attachSessions(ctx) {
845
850
  if (msg.type === "browse_dir") {
846
851
  var rawPath = (msg.path || "").replace(/^~/, require("./config").REAL_HOME);
847
852
  var absTarget = path.resolve(rawPath);
853
+ // Multi-user mode: non-admins can only browse their home directory
854
+ if (osUsers && osUsers.length > 0 && ws._clayUser && ws._clayUser.role !== "admin") {
855
+ var browseHome = ws._clayUser.linuxUser ? "/home/" + ws._clayUser.linuxUser : null;
856
+ if (!browseHome || (absTarget !== browseHome && (absTarget + "/").indexOf(browseHome + "/") !== 0)) {
857
+ sendTo(ws, { type: "browse_dir_result", path: msg.path, entries: [], error: "Access restricted to your home directory" });
858
+ return true;
859
+ }
860
+ }
848
861
  var parentDir, prefix;
849
862
  try {
850
863
  var stat = fs.statSync(absTarget);
@@ -884,6 +897,18 @@ function attachSessions(ctx) {
884
897
  if (msg.type === "add_project") {
885
898
  var addPath = (msg.path || "").replace(/^~/, require("./config").REAL_HOME);
886
899
  var addAbs = path.resolve(addPath);
900
+ // Multi-user mode: normal users restricted to their home directory
901
+ if (osUsers && osUsers.length > 0 && ws._clayUser && ws._clayUser.role !== "admin") {
902
+ if (!ws._clayUser.linuxUser) {
903
+ sendTo(ws, { type: "add_project_result", ok: false, error: "No Linux user assigned" });
904
+ return true;
905
+ }
906
+ var userHome = "/home/" + ws._clayUser.linuxUser;
907
+ if (addAbs !== userHome && (addAbs + "/").indexOf(userHome + "/") !== 0) {
908
+ sendTo(ws, { type: "add_project_result", ok: false, error: "Path not allowed. You can only add directories under " + userHome });
909
+ return true;
910
+ }
911
+ }
887
912
  try {
888
913
  var addStat = fs.statSync(addAbs);
889
914
  if (!addStat.isDirectory()) {
package/lib/public/app.js CHANGED
@@ -379,6 +379,7 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
379
379
  get multiUser() { return store.getState().isMultiUserMode; },
380
380
  get myUserId() { return store.getState().myUserId; },
381
381
  get projectOwnerId() { return store.getState().currentProjectOwnerId; },
382
+ get ownerLocked() { return store.getState().ownerLocked; },
382
383
  openDm: function (userId) { openDm(userId); },
383
384
  openMateWizard: function () { requireClayMateInterview(function () { openMateWizard(); }); },
384
385
  openAddProjectModal: function () { openAddProjectModal(); },
@@ -478,7 +478,14 @@
478
478
  display: flex;
479
479
  align-items: center;
480
480
  gap: 8px;
481
+ flex-wrap: wrap;
481
482
  }
483
+ .ps-owner-locked-hint {
484
+ font-size: 12px;
485
+ color: var(--text-muted);
486
+ width: 100%;
487
+ }
488
+ .ps-owner-locked-hint.hidden { display: none; }
482
489
 
483
490
  .ps-transfer-form {
484
491
  margin-top: 8px;
@@ -1012,22 +1012,41 @@ button.top-bar-pill.pill-accent:hover { background: color-mix(in srgb, var(--acc
1012
1012
 
1013
1013
  .add-project-body { margin-bottom: 16px; }
1014
1014
 
1015
- .add-project-input-wrap { position: relative; }
1015
+ .add-project-input-wrap { position: relative; display: flex; align-items: center; background: var(--input-bg); border: 1px solid var(--border); border-radius: 8px; transition: border-color 0.2s; }
1016
+ .add-project-input-wrap:focus-within { border-color: var(--text-dimmer); }
1017
+
1018
+ .add-project-prefix {
1019
+ flex-shrink: 0;
1020
+ padding: 5px 0 5px 8px;
1021
+ font-size: 12px;
1022
+ font-family: "Roboto Mono", monospace;
1023
+ color: var(--accent);
1024
+ background: var(--bg-alt);
1025
+ border-radius: 5px 0 0 5px;
1026
+ white-space: nowrap;
1027
+ user-select: none;
1028
+ pointer-events: none;
1029
+ margin: 4px 0 4px 4px;
1030
+ padding: 3px 6px;
1031
+ background: color-mix(in srgb, var(--accent) 15%, transparent);
1032
+ border-radius: 4px;
1033
+ }
1034
+ .add-project-prefix.hidden { display: none; }
1016
1035
 
1017
1036
  #add-project-input {
1018
- width: 100%;
1019
- background: var(--input-bg);
1020
- border: 1px solid var(--border);
1021
- border-radius: 8px;
1037
+ flex: 1;
1038
+ min-width: 0;
1039
+ background: transparent;
1040
+ border: none;
1022
1041
  color: var(--text);
1023
1042
  font-size: 13px;
1024
1043
  font-family: "Roboto Mono", monospace;
1025
- padding: 10px 12px;
1044
+ padding: 10px 12px 10px 6px;
1026
1045
  outline: none;
1027
- transition: border-color 0.2s;
1028
1046
  }
1047
+ .add-project-prefix.hidden + #add-project-input { padding-left: 12px; }
1029
1048
 
1030
- #add-project-input:focus { border-color: var(--text-dimmer); }
1049
+ #add-project-input:focus { border-color: transparent; }
1031
1050
  #add-project-input::placeholder { color: var(--text-muted); font-family: "Pretendard", system-ui, sans-serif; }
1032
1051
 
1033
1052
  #add-project-suggestions {
@@ -641,6 +641,7 @@
641
641
  <div class="ps-owner-row">
642
642
  <span class="settings-value" id="ps-owner-name">-</span>
643
643
  <button class="settings-btn-sm" id="ps-transfer-btn">Transfer</button>
644
+ <span class="ps-owner-locked-hint hidden" id="ps-owner-locked-hint">Ownership of home directory projects cannot be transferred.</span>
644
645
  </div>
645
646
  <div class="ps-transfer-form hidden" id="ps-transfer-form">
646
647
  <select class="ps-select" id="ps-transfer-select"></select>
@@ -1461,6 +1462,7 @@
1461
1462
  <div class="add-project-body">
1462
1463
  <div class="add-project-panel active" data-panel="existing">
1463
1464
  <div class="add-project-input-wrap">
1465
+ <span id="add-project-prefix" class="add-project-prefix hidden"></span>
1464
1466
  <input type="text" id="add-project-input" placeholder="/" autocomplete="off" spellcheck="false">
1465
1467
  <div id="add-project-suggestions" class="hidden"></div>
1466
1468
  </div>
@@ -263,6 +263,7 @@ export function processMessage(msg) {
263
263
  if (serverVersionEl) serverVersionEl.textContent = msg.version;
264
264
  }
265
265
  if (msg.projectOwnerId !== undefined) store.setState({ currentProjectOwnerId: msg.projectOwnerId });
266
+ if (msg.ownerLocked !== undefined) store.setState({ ownerLocked: !!msg.ownerLocked });
266
267
  if (msg.osUsers !== undefined) store.setState({ isOsUsers: !!msg.osUsers });
267
268
  if (msg.lanHost) window.__lanHost = msg.lanHost;
268
269
  if (msg.dangerouslySkipPermissions) {
@@ -27,6 +27,8 @@ var addProjectCancel = null;
27
27
  var addProjectModeBtns = null;
28
28
  var addProjectPanels = null;
29
29
  var addProjectRemoved = null;
30
+ var addProjectPrefixEl = null;
31
+ var addProjectPrefixValue = "";
30
32
  var addProjectDebounce = null;
31
33
  var addProjectActiveIdx = -1;
32
34
  var addProjectMode = "existing";
@@ -47,6 +49,7 @@ export function initProjects(ctx) {
47
49
  addProjectModeBtns = addProjectModal.querySelectorAll(".add-project-mode-btn");
48
50
  addProjectPanels = addProjectModal.querySelectorAll(".add-project-panel");
49
51
  addProjectRemoved = document.getElementById("add-project-removed");
52
+ addProjectPrefixEl = document.getElementById("add-project-prefix");
50
53
 
51
54
  // Mode button click listeners
52
55
  for (var mbi = 0; mbi < addProjectModeBtns.length; mbi++) {
@@ -59,7 +62,7 @@ export function initProjects(ctx) {
59
62
  // Existing project input listeners
60
63
  addProjectInput.addEventListener("focus", function () {
61
64
  var val = addProjectInput.value;
62
- if (val && addProjectSuggestions.children.length === 0) {
65
+ if ((val || addProjectPrefixValue) && addProjectSuggestions.children.length === 0) {
63
66
  requestBrowseDir(val);
64
67
  } else if (addProjectSuggestions.children.length > 0) {
65
68
  addProjectSuggestions.classList.remove("hidden");
@@ -109,10 +112,10 @@ export function initProjects(ctx) {
109
112
  ? items[addProjectActiveIdx]
110
113
  : items.length > 0 ? items[0] : null;
111
114
  if (target) {
112
- var p = target.dataset.path + "/";
113
- addProjectInput.value = p;
115
+ var fullP = target.dataset.path + "/";
116
+ addProjectInput.value = stripPrefix(fullP);
114
117
  addProjectError.classList.add("hidden");
115
- requestBrowseDir(p);
118
+ requestBrowseDir(addProjectInput.value);
116
119
  }
117
120
  return;
118
121
  }
@@ -120,10 +123,10 @@ export function initProjects(ctx) {
120
123
  if (e.key === "Enter") {
121
124
  e.preventDefault();
122
125
  if (addProjectActiveIdx >= 0 && addProjectActiveIdx < items.length) {
123
- var picked = items[addProjectActiveIdx].dataset.path + "/";
124
- addProjectInput.value = picked;
126
+ var fullPicked = items[addProjectActiveIdx].dataset.path + "/";
127
+ addProjectInput.value = stripPrefix(fullPicked);
125
128
  addProjectError.classList.add("hidden");
126
- requestBrowseDir(picked);
129
+ requestBrowseDir(addProjectInput.value);
127
130
  return;
128
131
  }
129
132
  submitAddProject();
@@ -614,7 +617,6 @@ function switchAddProjectMode(mode) {
614
617
 
615
618
  export function openAddProjectModal() {
616
619
  addProjectModal.classList.remove("hidden");
617
- addProjectInput.value = "/";
618
620
  addProjectCreateInput.value = "";
619
621
  addProjectCloneInput.value = "";
620
622
  addProjectError.classList.add("hidden");
@@ -626,17 +628,29 @@ export function openAddProjectModal() {
626
628
  addProjectOk.disabled = false;
627
629
  var existingBtn = addProjectModal.querySelector('.add-project-mode-btn[data-mode="existing"]');
628
630
  if (_ctx.isOsUsers) {
629
- // Default: disable existing directory for multi-user, but allow for admins
630
- existingBtn.disabled = true;
631
- switchAddProjectMode("create");
632
- if (_ctx.checkAdminAccess) {
633
- _ctx.checkAdminAccess().then(function (isAdmin) {
634
- if (isAdmin) {
635
- existingBtn.disabled = false;
636
- }
637
- });
631
+ existingBtn.disabled = false;
632
+ var myUser = _ctx.cachedAllUsers.find(function (u) { return u.id === _ctx.myUserId; });
633
+ var isAdmin = myUser && myUser.role === "admin";
634
+ if (!isAdmin && myUser && myUser.linuxUser) {
635
+ // Non-admin: lock prefix to home directory
636
+ addProjectPrefixValue = "/home/" + myUser.linuxUser + "/";
637
+ addProjectPrefixEl.textContent = addProjectPrefixValue;
638
+ addProjectPrefixEl.classList.remove("hidden");
639
+ addProjectInput.value = "";
640
+ addProjectInput.placeholder = "subdirectory";
641
+ } else {
642
+ // Admin: no prefix restriction
643
+ addProjectPrefixValue = "";
644
+ addProjectPrefixEl.classList.add("hidden");
645
+ addProjectInput.value = "/";
646
+ addProjectInput.placeholder = "/";
638
647
  }
648
+ switchAddProjectMode("existing");
639
649
  } else {
650
+ addProjectPrefixValue = "";
651
+ addProjectPrefixEl.classList.add("hidden");
652
+ addProjectInput.value = "/";
653
+ addProjectInput.placeholder = "/";
640
654
  existingBtn.disabled = false;
641
655
  switchAddProjectMode("existing");
642
656
  }
@@ -693,12 +707,25 @@ export function closeAddProjectModal() {
693
707
  addProjectError.classList.add("hidden");
694
708
  addProjectCloneProgress.classList.add("hidden");
695
709
  addProjectActiveIdx = -1;
710
+ addProjectPrefixValue = "";
711
+ addProjectPrefixEl.classList.add("hidden");
696
712
  if (addProjectDebounce) { clearTimeout(addProjectDebounce); addProjectDebounce = null; }
697
713
  }
698
714
 
715
+ function getFullPath(inputVal) {
716
+ return addProjectPrefixValue + inputVal;
717
+ }
718
+
719
+ function stripPrefix(fullPath) {
720
+ if (addProjectPrefixValue && fullPath.indexOf(addProjectPrefixValue) === 0) {
721
+ return fullPath.slice(addProjectPrefixValue.length);
722
+ }
723
+ return fullPath;
724
+ }
725
+
699
726
  function requestBrowseDir(val) {
700
727
  if (!_ctx.getWs() || _ctx.getWs().readyState !== 1) return;
701
- _ctx.getWs().send(JSON.stringify({ type: "browse_dir", path: val }));
728
+ _ctx.getWs().send(JSON.stringify({ type: "browse_dir", path: getFullPath(val) }));
702
729
  }
703
730
 
704
731
  export function handleBrowseDirResult(msg) {
@@ -721,11 +748,11 @@ export function handleBrowseDirResult(msg) {
721
748
  item.innerHTML = '<i data-lucide="folder"></i><span class="add-project-suggestion-name">' +
722
749
  escapeHtml(entry.name) + '</span>';
723
750
  item.addEventListener("click", function (e) {
724
- var p = this.dataset.path + "/";
725
- addProjectInput.value = p;
751
+ var fullP = this.dataset.path + "/";
752
+ addProjectInput.value = stripPrefix(fullP);
726
753
  addProjectInput.focus();
727
754
  addProjectError.classList.add("hidden");
728
- requestBrowseDir(p);
755
+ requestBrowseDir(addProjectInput.value);
729
756
  });
730
757
  addProjectSuggestions.appendChild(item);
731
758
  }
@@ -777,7 +804,7 @@ function submitAddProject() {
777
804
  addProjectOk.disabled = true;
778
805
 
779
806
  if (addProjectMode === "existing") {
780
- var val = addProjectInput.value.replace(/\/+$/, "");
807
+ var val = getFullPath(addProjectInput.value).replace(/\/+$/, "");
781
808
  if (!val) { addProjectOk.disabled = false; return; }
782
809
  if (_ctx.getWs() && _ctx.getWs().readyState === 1) {
783
810
  _ctx.getWs().send(JSON.stringify({ type: "add_project", path: val }));
@@ -250,12 +250,17 @@ function populateProfile() {
250
250
  var ownerField = document.getElementById("ps-owner-field");
251
251
  if (ownerField) {
252
252
  var ownerId = currentProject ? currentProject.projectOwnerId : null;
253
+ var isOwnerLocked = currentProject ? currentProject.ownerLocked : false;
253
254
  var isMultiUser = ctx.multiUser;
254
255
  if (isMultiUser) {
255
256
  ownerField.style.display = "";
256
257
  var ownerNameEl = document.getElementById("ps-owner-name");
257
258
  var transferBtn = document.getElementById("ps-transfer-btn");
259
+ var ownerLockedHint = document.getElementById("ps-owner-locked-hint");
258
260
  if (transferBtn) transferBtn.style.display = "none";
261
+ if (ownerLockedHint) {
262
+ if (isOwnerLocked) { ownerLockedHint.classList.remove("hidden"); } else { ownerLockedHint.classList.add("hidden"); }
263
+ }
259
264
  // Fetch user list (only succeeds for admin)
260
265
  fetch("/api/admin/users").then(function (r) {
261
266
  if (!r.ok) throw new Error("not admin");
@@ -272,14 +277,14 @@ function populateProfile() {
272
277
  } else {
273
278
  if (ownerNameEl) ownerNameEl.textContent = "Not set";
274
279
  }
275
- // Admin can always transfer
276
- if (transferBtn) transferBtn.style.display = "";
280
+ // Admin can transfer unless ownership is locked (home directory projects)
281
+ if (transferBtn && !isOwnerLocked) transferBtn.style.display = "";
277
282
  }).catch(function () {
278
283
  // Not admin, show owner name from limited info
279
284
  if (ownerId) {
280
285
  if (ownerNameEl) ownerNameEl.textContent = ownerId;
281
- // Project owner can also transfer
282
- if (ctx.myUserId && ctx.myUserId === ownerId && transferBtn) {
286
+ // Project owner can also transfer unless locked
287
+ if (!isOwnerLocked && ctx.myUserId && ctx.myUserId === ownerId && transferBtn) {
283
288
  transferBtn.style.display = "";
284
289
  }
285
290
  } else {
@@ -1055,6 +1055,7 @@ function renderSheetSettings(listEl) {
1055
1055
  }
1056
1056
  }
1057
1057
  }
1058
+ if (proj && _ctx.ownerLocked) proj = Object.assign({}, proj, { ownerLocked: true });
1058
1059
  openProjectSettings(getCachedCurrentSlug(), proj);
1059
1060
  }, 250);
1060
1061
  } else if (item.action === "server-settings") {
@@ -578,7 +578,7 @@ function showProjectCtxMenu(anchorEl, slug, name, icon, position) {
578
578
  settingsItem.addEventListener("click", function (e) {
579
579
  e.stopPropagation();
580
580
  closeProjectCtxMenu();
581
- openProjectSettings(slug, { slug: slug, name: name, icon: icon, projectOwnerId: _ctx.projectOwnerId });
581
+ openProjectSettings(slug, { slug: slug, name: name, icon: icon, projectOwnerId: _ctx.projectOwnerId, ownerLocked: _ctx.ownerLocked });
582
582
  });
583
583
  menu.appendChild(settingsItem);
584
584
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "2.28.0-beta.3",
3
+ "version": "2.29.0-beta.1",
4
4
  "description": "Self-hosted Claude Code in your browser. Multi-session, multi-user, push notifications.",
5
5
  "bin": {
6
6
  "clay-server": "./bin/cli.js",