@vibe80/vibe80 0.1.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.
Files changed (123) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +52 -0
  3. package/bin/vibe80.js +176 -0
  4. package/client/dist/assets/DiffPanel-C_IGzKI5.js +1 -0
  5. package/client/dist/assets/ExplorerPanel-BtlyAT00.js +11 -0
  6. package/client/dist/assets/LogsPanel-BW79JWzR.js +1 -0
  7. package/client/dist/assets/SettingsPanel-b9B7ygP_.js +1 -0
  8. package/client/dist/assets/TerminalPanel-C3fc1HbK.js +1 -0
  9. package/client/dist/assets/browser-e3WgtMs-.js +8 -0
  10. package/client/dist/assets/index-CgqGyssr.css +32 -0
  11. package/client/dist/assets/index-DnwKjoj7.js +706 -0
  12. package/client/dist/assets/vibe80_dark-D7OVPKcU.svg +51 -0
  13. package/client/dist/assets/vibe80_light-BJK37ybI.svg +50 -0
  14. package/client/dist/favicon.ico +0 -0
  15. package/client/dist/favicon.png +0 -0
  16. package/client/dist/favicon.svg +35 -0
  17. package/client/dist/index.html +14 -0
  18. package/client/index.html +16 -0
  19. package/client/package.json +34 -0
  20. package/client/public/favicon.ico +0 -0
  21. package/client/public/favicon.png +0 -0
  22. package/client/public/favicon.svg +35 -0
  23. package/client/public/pwa-192x192.png +0 -0
  24. package/client/public/pwa-512x512.png +0 -0
  25. package/client/src/App.jsx +3131 -0
  26. package/client/src/assets/logo_small.png +0 -0
  27. package/client/src/assets/vibe80_dark.svg +51 -0
  28. package/client/src/assets/vibe80_light.svg +50 -0
  29. package/client/src/components/Chat/ChatComposer.jsx +228 -0
  30. package/client/src/components/Chat/ChatMessages.jsx +811 -0
  31. package/client/src/components/Chat/ChatToolbar.jsx +109 -0
  32. package/client/src/components/Chat/useChatComposer.js +462 -0
  33. package/client/src/components/Diff/DiffPanel.jsx +129 -0
  34. package/client/src/components/Explorer/ExplorerPanel.jsx +449 -0
  35. package/client/src/components/Logs/LogsPanel.jsx +80 -0
  36. package/client/src/components/SessionGate/SessionGate.jsx +874 -0
  37. package/client/src/components/Settings/SettingsPanel.jsx +212 -0
  38. package/client/src/components/Terminal/TerminalPanel.jsx +39 -0
  39. package/client/src/components/Topbar/Topbar.jsx +101 -0
  40. package/client/src/components/WorktreeTabs.css +419 -0
  41. package/client/src/components/WorktreeTabs.jsx +604 -0
  42. package/client/src/hooks/useAttachments.jsx +125 -0
  43. package/client/src/hooks/useBacklog.js +254 -0
  44. package/client/src/hooks/useChatClear.js +90 -0
  45. package/client/src/hooks/useChatCollapse.js +42 -0
  46. package/client/src/hooks/useChatCommands.js +294 -0
  47. package/client/src/hooks/useChatExport.js +144 -0
  48. package/client/src/hooks/useChatMessagesState.js +69 -0
  49. package/client/src/hooks/useChatSend.js +158 -0
  50. package/client/src/hooks/useChatSocket.js +1239 -0
  51. package/client/src/hooks/useDiffNavigation.js +19 -0
  52. package/client/src/hooks/useExplorerActions.js +1184 -0
  53. package/client/src/hooks/useGitIdentity.js +114 -0
  54. package/client/src/hooks/useLayoutMode.js +31 -0
  55. package/client/src/hooks/useLocalPreferences.js +131 -0
  56. package/client/src/hooks/useMessageSync.js +30 -0
  57. package/client/src/hooks/useNotifications.js +132 -0
  58. package/client/src/hooks/usePaneNavigation.js +67 -0
  59. package/client/src/hooks/usePanelState.js +13 -0
  60. package/client/src/hooks/useProviderSelection.js +70 -0
  61. package/client/src/hooks/useRepoBranchesModels.js +218 -0
  62. package/client/src/hooks/useRepoStatus.js +350 -0
  63. package/client/src/hooks/useRpcLogActions.js +19 -0
  64. package/client/src/hooks/useRpcLogView.js +58 -0
  65. package/client/src/hooks/useSessionHandoff.js +97 -0
  66. package/client/src/hooks/useSessionLifecycle.js +287 -0
  67. package/client/src/hooks/useSessionReset.js +63 -0
  68. package/client/src/hooks/useSessionResync.js +77 -0
  69. package/client/src/hooks/useTerminalSession.js +328 -0
  70. package/client/src/hooks/useToolbarExport.js +27 -0
  71. package/client/src/hooks/useTurnInterrupt.js +43 -0
  72. package/client/src/hooks/useVibe80Forms.js +128 -0
  73. package/client/src/hooks/useWorkspaceAuth.js +932 -0
  74. package/client/src/hooks/useWorktreeCloseConfirm.js +46 -0
  75. package/client/src/hooks/useWorktrees.js +396 -0
  76. package/client/src/i18n.jsx +87 -0
  77. package/client/src/index.css +5147 -0
  78. package/client/src/locales/en.json +37 -0
  79. package/client/src/locales/fr.json +321 -0
  80. package/client/src/main.jsx +16 -0
  81. package/client/vite.config.js +62 -0
  82. package/docs/api/asyncapi.json +1511 -0
  83. package/docs/api/openapi.json +3242 -0
  84. package/git_hooks/prepare-commit-msg +35 -0
  85. package/package.json +36 -0
  86. package/server/package.json +29 -0
  87. package/server/scripts/rotate-workspace-secret.js +101 -0
  88. package/server/src/claudeClient.js +454 -0
  89. package/server/src/clientEvents.js +594 -0
  90. package/server/src/clientFactory.js +164 -0
  91. package/server/src/codexClient.js +468 -0
  92. package/server/src/config.js +27 -0
  93. package/server/src/helpers.js +138 -0
  94. package/server/src/index.js +1641 -0
  95. package/server/src/middleware/auth.js +93 -0
  96. package/server/src/middleware/debug.js +89 -0
  97. package/server/src/middleware/errorTypes.js +60 -0
  98. package/server/src/providerLogger.js +60 -0
  99. package/server/src/routes/files.js +114 -0
  100. package/server/src/routes/git.js +183 -0
  101. package/server/src/routes/health.js +13 -0
  102. package/server/src/routes/sessions.js +407 -0
  103. package/server/src/routes/workspaces.js +296 -0
  104. package/server/src/routes/worktrees.js +993 -0
  105. package/server/src/runAs.js +458 -0
  106. package/server/src/runtimeStore.js +32 -0
  107. package/server/src/services/auth.js +157 -0
  108. package/server/src/services/claudeThreadDirectory.js +33 -0
  109. package/server/src/services/session.js +918 -0
  110. package/server/src/services/workspace.js +858 -0
  111. package/server/src/storage/index.js +17 -0
  112. package/server/src/storage/redis.js +412 -0
  113. package/server/src/storage/sqlite.js +649 -0
  114. package/server/src/worktreeManager.js +717 -0
  115. package/server/tests/README.md +13 -0
  116. package/server/tests/factories/workspaceFactory.js +13 -0
  117. package/server/tests/fixtures/workspaceCredentials.json +4 -0
  118. package/server/tests/integration/routes/workspaces-routes.test.js +626 -0
  119. package/server/tests/setup/env.js +9 -0
  120. package/server/tests/unit/helpers.test.js +95 -0
  121. package/server/tests/unit/services/auth.test.js +181 -0
  122. package/server/tests/unit/services/workspace.test.js +115 -0
  123. package/server/vitest.config.js +23 -0
@@ -0,0 +1,858 @@
1
+ import crypto from "crypto";
2
+ import path from "path";
3
+ import os from "os";
4
+ import {
5
+ runCommand,
6
+ runCommandOutput,
7
+ runAsCommand,
8
+ runAsCommandOutput,
9
+ } from "../runAs.js";
10
+ import storage from "../storage/index.js";
11
+ import { generateId } from "../helpers.js";
12
+ import { logDebug } from "../middleware/debug.js";
13
+
14
+ const deploymentMode = process.env.DEPLOYMENT_MODE;
15
+ const isMonoUser = deploymentMode === "mono_user";
16
+ const workspaceHomeBase = process.env.WORKSPACE_HOME_BASE || "/home";
17
+ const workspaceRootBase = process.env.WORKSPACE_ROOT_DIRECTORY || "/workspaces";
18
+ const workspaceRootName = "vibe80_workspace";
19
+ const workspaceSessionsDirName = "sessions";
20
+ const rootHelperPath = process.env.VIBE80_ROOT_HELPER || "/usr/local/bin/vibe80-root";
21
+ const sudoPath = process.env.VIBE80_SUDO_PATH || "sudo";
22
+ const workspaceUidMin = Number.parseInt(process.env.WORKSPACE_UID_MIN, 10) || 200000;
23
+ const workspaceUidMax = Number.parseInt(process.env.WORKSPACE_UID_MAX, 10) || 999999999;
24
+ const workspaceUserExistsCache = new Map();
25
+
26
+ export const workspaceIdPattern = isMonoUser ? /^default$/ : /^w[0-9a-f]{24}$/;
27
+
28
+ export { isMonoUser };
29
+
30
+ const runRootCommand = (args, options = {}) => {
31
+ if (isMonoUser) {
32
+ throw new Error("Root helpers are not available in mono_user mode.");
33
+ }
34
+ return runCommand(sudoPath, ["-n", rootHelperPath, ...args], options);
35
+ };
36
+
37
+ export const getWorkspacePaths = (workspaceId) => {
38
+ const home = isMonoUser ? os.homedir() : path.join(workspaceHomeBase, workspaceId);
39
+ const root = isMonoUser
40
+ ? path.join(home, workspaceRootName)
41
+ : path.join(workspaceRootBase, workspaceId);
42
+ const sessionsDir = path.join(root, workspaceSessionsDirName);
43
+ return {
44
+ homeDir: home,
45
+ rootDir: root,
46
+ sessionsDir,
47
+ };
48
+ };
49
+
50
+ export const getWorkspaceSshPaths = (workspaceHome) => {
51
+ const sshDir = path.join(workspaceHome, ".ssh");
52
+ return {
53
+ sshDir,
54
+ knownHostsPath: path.join(sshDir, "known_hosts"),
55
+ };
56
+ };
57
+
58
+ export const getWorkspaceAuthPaths = (workspaceHome) => ({
59
+ codexDir: path.join(workspaceHome, ".codex"),
60
+ codexAuthPath: path.join(workspaceHome, ".codex", "auth.json"),
61
+ claudeAuthPath: path.join(workspaceHome, ".claude.json"),
62
+ claudeDir: path.join(workspaceHome, ".claude"),
63
+ claudeCredentialsPath: path.join(workspaceHome, ".claude", ".credentials.json"),
64
+ });
65
+
66
+ export const ensureWorkspaceDir = async (workspaceId, dirPath, mode = 0o700) => {
67
+ await runAsCommand(workspaceId, "/bin/mkdir", ["-p", dirPath]);
68
+ await runAsCommand(workspaceId, "/bin/chmod", [mode.toString(8), dirPath]);
69
+ };
70
+
71
+ export const writeWorkspaceFile = async (workspaceId, filePath, content, mode = 0o600) => {
72
+ await runAsCommand(workspaceId, "/usr/bin/tee", [filePath], { input: content });
73
+ await runAsCommand(workspaceId, "/bin/chmod", [mode.toString(8), filePath]);
74
+ };
75
+
76
+ export const appendWorkspaceFile = async (workspaceId, filePath, content, mode = 0o600) => {
77
+ await runAsCommand(workspaceId, "/usr/bin/tee", ["-a", filePath], { input: content });
78
+ await runAsCommand(workspaceId, "/bin/chmod", [mode.toString(8), filePath]);
79
+ };
80
+
81
+ export const workspaceUserExists = async (workspaceId) => {
82
+ if (isMonoUser) {
83
+ return workspaceId === "default";
84
+ }
85
+ try {
86
+ await runCommandOutput("getent", ["passwd", workspaceId]);
87
+ return true;
88
+ } catch {
89
+ return false;
90
+ }
91
+ };
92
+
93
+ export const listWorkspaceEntries = async (workspaceId, dirPath) => {
94
+ let output;
95
+ try {
96
+ output = await runAsCommandOutput(
97
+ workspaceId,
98
+ "/usr/bin/find",
99
+ [dirPath, "-maxdepth", "1", "-mindepth", "1", "-printf", "%y\t%f\0"],
100
+ { binary: true }
101
+ );
102
+ const parsed = output
103
+ .toString("utf8")
104
+ .split("\0")
105
+ .filter(Boolean)
106
+ .map((line) => {
107
+ const [type, name] = line.split("\t");
108
+ return { type, name };
109
+ })
110
+ .filter((entry) => entry.type && entry.name);
111
+ if (parsed.length > 0) {
112
+ return parsed;
113
+ }
114
+ } catch (error) {
115
+ logDebug("[debug] listWorkspaceEntries failed", {
116
+ workspaceId,
117
+ dirPath,
118
+ error: error?.message || error,
119
+ });
120
+ }
121
+
122
+ try {
123
+ const fallbackOutput = await runAsCommandOutput(workspaceId, "/usr/bin/find", [
124
+ dirPath,
125
+ "-maxdepth",
126
+ "1",
127
+ "-mindepth",
128
+ "1",
129
+ "-printf",
130
+ "%y\t%f\n",
131
+ ]);
132
+ const parsed = fallbackOutput
133
+ .split("\n")
134
+ .map((line) => line.trim())
135
+ .filter(Boolean)
136
+ .map((line) => {
137
+ const [type, name] = line.split("\t");
138
+ return { type, name };
139
+ })
140
+ .filter((entry) => entry.type && entry.name);
141
+ if (parsed.length > 0) {
142
+ return parsed;
143
+ }
144
+ if (output) {
145
+ logDebug("[debug] listWorkspaceEntries empty parse", {
146
+ workspaceId,
147
+ dirPath,
148
+ sample: output.toString("utf8").slice(0, 200),
149
+ });
150
+ }
151
+ } catch (error) {
152
+ logDebug("[debug] listWorkspaceEntries fallback failed", {
153
+ workspaceId,
154
+ dirPath,
155
+ error: error?.message || error,
156
+ });
157
+ }
158
+ return [];
159
+ };
160
+
161
+ export const getWorkspaceStat = async (workspaceId, targetPath, options = {}) => {
162
+ const output = await runAsCommandOutput(workspaceId, "/usr/bin/stat", [
163
+ "-c",
164
+ "%f\t%s\t%a",
165
+ targetPath,
166
+ ], options);
167
+ const [modeHex, sizeRaw, modeRaw] = output.trim().split("\t");
168
+ const modeValue = Number.parseInt(modeHex, 16);
169
+ const typeBits = Number.isFinite(modeValue) ? modeValue & 0o170000 : null;
170
+ let type = "";
171
+ if (typeBits === 0o100000) {
172
+ type = "regular";
173
+ } else if (typeBits === 0o040000) {
174
+ type = "directory";
175
+ } else if (typeBits === 0o120000) {
176
+ type = "symlink";
177
+ } else if (Number.isFinite(typeBits)) {
178
+ type = "other";
179
+ }
180
+ return {
181
+ type,
182
+ size: Number.parseInt(sizeRaw, 10),
183
+ mode: modeRaw,
184
+ };
185
+ };
186
+
187
+ export const workspacePathExists = async (workspaceId, targetPath) => {
188
+ try {
189
+ await runAsCommandOutput(workspaceId, "/usr/bin/stat", ["-c", "%F", targetPath]);
190
+ return true;
191
+ } catch {
192
+ return false;
193
+ }
194
+ };
195
+
196
+ export const readWorkspaceFileBuffer = async (
197
+ workspaceId,
198
+ filePath,
199
+ maxBytes,
200
+ options = {}
201
+ ) => {
202
+ const stat = await getWorkspaceStat(workspaceId, filePath, options);
203
+ if (!stat.type || !stat.type.startsWith("regular")) {
204
+ console.warn("readWorkspaceFileBuffer: non-regular path", {
205
+ workspaceId,
206
+ filePath,
207
+ type: stat.type || null,
208
+ size: stat.size,
209
+ mode: stat.mode,
210
+ });
211
+ throw new Error("Path is not a file.");
212
+ }
213
+ if (Number.isFinite(maxBytes) && stat.size > maxBytes) {
214
+ const buffer = await runAsCommandOutput(
215
+ workspaceId,
216
+ "/usr/bin/head",
217
+ ["-c", String(maxBytes), filePath],
218
+ { binary: true, ...options }
219
+ );
220
+ return { buffer, truncated: true };
221
+ }
222
+ const buffer = await runAsCommandOutput(
223
+ workspaceId,
224
+ "/bin/cat",
225
+ [filePath],
226
+ { binary: true, ...options }
227
+ );
228
+ return { buffer, truncated: false };
229
+ };
230
+
231
+ export const writeWorkspaceFilePreserveMode = async (workspaceId, filePath, content) => {
232
+ const stat = await getWorkspaceStat(workspaceId, filePath);
233
+ if (!stat.type || !stat.type.startsWith("regular")) {
234
+ throw new Error("Path is not a file.");
235
+ }
236
+ await runAsCommand(workspaceId, "/usr/bin/tee", [filePath], { input: content });
237
+ if (stat.mode) {
238
+ await runAsCommand(workspaceId, "/bin/chmod", [stat.mode, filePath]);
239
+ }
240
+ };
241
+
242
+ export const getWorkspaceUserIds = async (workspaceId) => {
243
+ if (isMonoUser) {
244
+ const uid = typeof process.getuid === "function" ? process.getuid() : os.userInfo().uid;
245
+ const gid = typeof process.getgid === "function" ? process.getgid() : os.userInfo().gid;
246
+ const ids = { uid, gid };
247
+ await storage.saveWorkspaceUserIds(workspaceId, ids);
248
+ return ids;
249
+ }
250
+ const workspaceRecord = await storage.getWorkspace(workspaceId);
251
+ let ids = null;
252
+ if (Number.isFinite(workspaceRecord?.uid) && Number.isFinite(workspaceRecord?.gid)) {
253
+ ids = {
254
+ uid: Number(workspaceRecord.uid),
255
+ gid: Number(workspaceRecord.gid),
256
+ };
257
+ }
258
+ if (!ids) {
259
+ const cached = await storage.getWorkspaceUserIds(workspaceId);
260
+ if (Number.isFinite(cached?.uid) && Number.isFinite(cached?.gid)) {
261
+ ids = {
262
+ uid: Number(cached.uid),
263
+ gid: Number(cached.gid),
264
+ };
265
+ if (workspaceRecord) {
266
+ await persistWorkspaceRecord({
267
+ workspaceId,
268
+ providers: workspaceRecord.providers || {},
269
+ ids,
270
+ existing: workspaceRecord,
271
+ });
272
+ }
273
+ }
274
+ }
275
+ if (!ids) {
276
+ ids = await recoverWorkspaceIds(workspaceId);
277
+ }
278
+ await storage.saveWorkspaceUserIds(workspaceId, ids);
279
+ return ids;
280
+ };
281
+
282
+ export const buildWorkspaceEnv = (workspaceId) => {
283
+ const home = isMonoUser ? os.homedir() : path.join(workspaceHomeBase, workspaceId);
284
+ const user = isMonoUser ? os.userInfo().username : workspaceId;
285
+ return {
286
+ ...process.env,
287
+ HOME: home,
288
+ USER: user,
289
+ LOGNAME: user,
290
+ };
291
+ };
292
+
293
+ export const appendAuditLog = async (workspaceId, event, details = {}) => {
294
+ try {
295
+ const entry = {
296
+ ts: Date.now(),
297
+ event,
298
+ workspaceId,
299
+ ...details,
300
+ };
301
+ await storage.appendWorkspaceAuditEvent(workspaceId, entry);
302
+ } catch {
303
+ // Avoid failing requests on audit errors.
304
+ }
305
+ };
306
+
307
+ const allowedAuthTypes = new Set(["api_key", "auth_json_b64", "setup_token"]);
308
+ const allowedProviders = new Set(["codex", "claude"]);
309
+ const providerAuthTypes = {
310
+ codex: new Set(["api_key", "auth_json_b64"]),
311
+ claude: new Set(["api_key", "setup_token"]),
312
+ };
313
+
314
+ export const validateProvidersConfig = (providers) => {
315
+ if (!providers || typeof providers !== "object") {
316
+ return "providers is required.";
317
+ }
318
+ let enabledCount = 0;
319
+ for (const [provider, config] of Object.entries(providers)) {
320
+ if (!allowedProviders.has(provider)) {
321
+ return `Unknown provider ${provider}.`;
322
+ }
323
+ if (!config || typeof config !== "object") {
324
+ return `Invalid provider config for ${provider}.`;
325
+ }
326
+ if (typeof config.enabled !== "boolean") {
327
+ return `Provider ${provider} must include enabled boolean.`;
328
+ }
329
+ if (config.enabled) {
330
+ enabledCount += 1;
331
+ }
332
+ if (config.enabled && !config.auth) {
333
+ return `Provider ${provider} auth is required when enabled.`;
334
+ }
335
+ if (config.auth != null) {
336
+ if (typeof config.auth !== "object") {
337
+ return `Provider ${provider} auth must be an object.`;
338
+ }
339
+ const { type, value } = config.auth;
340
+ if (!allowedAuthTypes.has(type)) {
341
+ return `Provider ${provider} auth type is invalid.`;
342
+ }
343
+ const providerTypes = providerAuthTypes[provider];
344
+ if (providerTypes && !providerTypes.has(type)) {
345
+ return `Provider ${provider} auth type ${type} is not supported.`;
346
+ }
347
+ if (typeof value !== "string" || !value.trim()) {
348
+ return `Provider ${provider} auth value is required.`;
349
+ }
350
+ }
351
+ }
352
+ if (enabledCount === 0) {
353
+ return "At least one provider must be enabled.";
354
+ }
355
+ return null;
356
+ };
357
+
358
+ export const sanitizeProvidersForResponse = (providers = {}) => {
359
+ const sanitized = {};
360
+ for (const [provider, config] of Object.entries(providers || {})) {
361
+ if (!config || typeof config !== "object") {
362
+ continue;
363
+ }
364
+ sanitized[provider] = {
365
+ enabled: Boolean(config.enabled),
366
+ auth: config.auth?.type ? { type: config.auth.type } : null,
367
+ };
368
+ }
369
+ return sanitized;
370
+ };
371
+
372
+ const hasAuthValue = (auth) =>
373
+ Boolean(auth && typeof auth.value === "string" && auth.value.trim());
374
+
375
+ export const mergeProvidersForUpdate = (existingProviders = {}, incomingProviders = {}) => {
376
+ const merged = { ...existingProviders };
377
+ for (const [provider, config] of Object.entries(incomingProviders)) {
378
+ if (!config || typeof config !== "object") {
379
+ continue;
380
+ }
381
+ const previous = existingProviders?.[provider] || {};
382
+ const previousAuthType = previous?.auth?.type || null;
383
+ const incomingAuthType = config.auth?.type || previousAuthType || null;
384
+ const authTypeChanged =
385
+ incomingAuthType && previousAuthType && incomingAuthType !== previousAuthType;
386
+ const nextAuthValue = hasAuthValue(config.auth)
387
+ ? config.auth.value
388
+ : previous?.auth?.value || "";
389
+ if ((authTypeChanged || config.enabled) && !nextAuthValue) {
390
+ throw new Error(`Provider ${provider} auth value is required.`);
391
+ }
392
+ merged[provider] = {
393
+ enabled: Boolean(config.enabled),
394
+ auth: incomingAuthType
395
+ ? {
396
+ type: incomingAuthType,
397
+ value: nextAuthValue,
398
+ }
399
+ : null,
400
+ };
401
+ }
402
+ return merged;
403
+ };
404
+
405
+ export const listEnabledProviders = (providers) =>
406
+ Object.entries(providers || {})
407
+ .filter(([, config]) => config?.enabled)
408
+ .map(([name]) => name);
409
+
410
+ export const pickDefaultProvider = (providers) => {
411
+ if (!providers || providers.length === 0) {
412
+ return null;
413
+ }
414
+ if (providers.includes("codex")) {
415
+ return "codex";
416
+ }
417
+ return providers[0];
418
+ };
419
+
420
+ export const ensureWorkspaceUser = async (workspaceId, homeDirPath, ids = null) => {
421
+ if (isMonoUser) {
422
+ return;
423
+ }
424
+ try {
425
+ await runCommandOutput("getent", ["passwd", workspaceId]);
426
+ return;
427
+ } catch {
428
+ // continue
429
+ }
430
+ if (ids?.gid) {
431
+ try {
432
+ await runCommandOutput("getent", ["group", String(ids.gid)]);
433
+ } catch {
434
+ await runCommand("groupadd", ["-g", String(ids.gid), workspaceId]);
435
+ }
436
+ }
437
+ const userArgs = ["-m", "-d", homeDirPath, "-s", "/bin/bash"];
438
+ if (ids?.uid) {
439
+ userArgs.push("-u", String(ids.uid));
440
+ }
441
+ if (ids?.gid) {
442
+ userArgs.push("-g", String(ids.gid));
443
+ }
444
+ userArgs.push(workspaceId);
445
+ await runCommand("useradd", userArgs);
446
+ };
447
+
448
+ export const ensureWorkspaceUserExists = async (workspaceId) => {
449
+ if (isMonoUser) {
450
+ return;
451
+ }
452
+ if (workspaceUserExistsCache.get(workspaceId)) {
453
+ return;
454
+ }
455
+ const ids = await getWorkspaceUserIds(workspaceId);
456
+ try {
457
+ await runCommandOutput("getent", ["passwd", workspaceId]);
458
+ workspaceUserExistsCache.set(workspaceId, true);
459
+ return;
460
+ } catch {
461
+ // continue
462
+ }
463
+ try {
464
+ await runRootCommand([
465
+ "create-workspace",
466
+ "--workspace-id",
467
+ workspaceId,
468
+ "--uid",
469
+ String(ids.uid),
470
+ "--gid",
471
+ String(ids.gid),
472
+ ]);
473
+ } catch (error) {
474
+ const homeDir = getWorkspacePaths(workspaceId).homeDir;
475
+ await ensureWorkspaceUser(workspaceId, homeDir, ids);
476
+ }
477
+ const workspaceRecord = await storage.getWorkspace(workspaceId);
478
+ if (workspaceRecord?.providers) {
479
+ await writeWorkspaceProviderAuth(workspaceId, workspaceRecord.providers);
480
+ }
481
+ await storage.saveWorkspaceUserIds(workspaceId, ids);
482
+ await appendAuditLog(workspaceId, "workspace_user_recreated", {
483
+ uid: ids.uid,
484
+ gid: ids.gid,
485
+ });
486
+ await appendAuditLog(workspaceId, "workspace_auth_reconciled_after_recreate");
487
+ workspaceUserExistsCache.set(workspaceId, true);
488
+ };
489
+
490
+ const allocateWorkspaceIds = async () => {
491
+ const min = Math.max(1, workspaceUidMin);
492
+ const max = Math.max(min, workspaceUidMax);
493
+ const attempts = 1000;
494
+ for (let i = 0; i < attempts; i += 1) {
495
+ const candidate = await storage.getNextWorkspaceUid();
496
+ if (candidate < min || candidate > max) {
497
+ continue;
498
+ }
499
+ try {
500
+ await runCommandOutput("getent", ["passwd", String(candidate)]);
501
+ continue;
502
+ } catch {
503
+ // free uid
504
+ }
505
+ return { uid: candidate, gid: candidate };
506
+ }
507
+ throw new Error("Unable to allocate a workspace uid/gid.");
508
+ };
509
+
510
+ const hashWorkspaceSecret = (workspaceSecret) =>
511
+ crypto.createHash("sha256").update(String(workspaceSecret || ""), "utf8").digest("hex");
512
+
513
+ const compareWorkspaceSecretHash = (workspaceSecret, expectedHash) => {
514
+ if (typeof expectedHash !== "string" || !expectedHash) {
515
+ return false;
516
+ }
517
+ const computed = hashWorkspaceSecret(workspaceSecret);
518
+ const computedBuffer = Buffer.from(computed, "hex");
519
+ const expectedBuffer = Buffer.from(expectedHash, "hex");
520
+ if (computedBuffer.length !== expectedBuffer.length) {
521
+ return false;
522
+ }
523
+ return crypto.timingSafeEqual(computedBuffer, expectedBuffer);
524
+ };
525
+
526
+ const toWorkspaceConfigPayload = (record) => ({
527
+ workspaceId: record.workspaceId,
528
+ providers: record.providers || {},
529
+ uid: Number.isFinite(record.uid) ? record.uid : null,
530
+ gid: Number.isFinite(record.gid) ? record.gid : null,
531
+ createdAt: Number.isFinite(record.createdAt) ? record.createdAt : null,
532
+ updatedAt: Number.isFinite(record.updatedAt) ? record.updatedAt : null,
533
+ });
534
+
535
+ const persistWorkspaceRecord = async ({
536
+ workspaceId,
537
+ providers,
538
+ ids = null,
539
+ workspaceSecretHash,
540
+ existing = null,
541
+ }) => {
542
+ const now = Date.now();
543
+ const payload = {
544
+ workspaceId,
545
+ providers: providers || existing?.providers || {},
546
+ uid: Number.isFinite(ids?.uid) ? ids.uid : existing?.uid ?? null,
547
+ gid: Number.isFinite(ids?.gid) ? ids.gid : existing?.gid ?? null,
548
+ workspaceSecretHash:
549
+ typeof workspaceSecretHash === "string" && workspaceSecretHash
550
+ ? workspaceSecretHash
551
+ : existing?.workspaceSecretHash || null,
552
+ createdAt: Number.isFinite(existing?.createdAt) ? existing.createdAt : now,
553
+ updatedAt: now,
554
+ };
555
+ await storage.saveWorkspace(workspaceId, payload);
556
+ return payload;
557
+ };
558
+
559
+ const getWorkspaceRecord = async (workspaceId) => {
560
+ const record = await storage.getWorkspace(workspaceId);
561
+ if (!record || typeof record !== "object") {
562
+ throw new Error("Workspace not found.");
563
+ }
564
+ return record;
565
+ };
566
+
567
+ const recoverWorkspaceIds = async (workspaceId) => {
568
+ if (isMonoUser) {
569
+ const uid = typeof process.getuid === "function" ? process.getuid() : os.userInfo().uid;
570
+ const gid = typeof process.getgid === "function" ? process.getgid() : os.userInfo().gid;
571
+ return { uid, gid };
572
+ }
573
+ const homeDir = path.join(workspaceHomeBase, workspaceId);
574
+ const workspaceRecord = await storage.getWorkspace(workspaceId);
575
+ let uid = Number.isFinite(workspaceRecord?.uid) ? Number(workspaceRecord.uid) : null;
576
+ let gid = Number.isFinite(workspaceRecord?.gid) ? Number(workspaceRecord.gid) : null;
577
+ if (!Number.isFinite(uid) || !Number.isFinite(gid)) {
578
+ try {
579
+ const output = await runAsCommandOutput(workspaceId, "/usr/bin/stat", [
580
+ "-c",
581
+ "%u\t%g",
582
+ homeDir,
583
+ ]);
584
+ const [uidRaw, gidRaw] = output.trim().split("\t");
585
+ if (!Number.isFinite(uid)) {
586
+ uid = Number(uidRaw);
587
+ }
588
+ if (!Number.isFinite(gid)) {
589
+ gid = Number(gidRaw);
590
+ }
591
+ } catch {
592
+ try {
593
+ const output = await runCommandOutput("/usr/bin/stat", [
594
+ "-c",
595
+ "%u\t%g",
596
+ homeDir,
597
+ ]);
598
+ const [uidRaw, gidRaw] = output.trim().split("\t");
599
+ if (!Number.isFinite(uid)) {
600
+ uid = Number(uidRaw);
601
+ }
602
+ if (!Number.isFinite(gid)) {
603
+ gid = Number(gidRaw);
604
+ }
605
+ } catch {
606
+ // ignore
607
+ }
608
+ }
609
+ }
610
+ if (!Number.isFinite(uid) || !Number.isFinite(gid)) {
611
+ throw new Error("Workspace user ids unavailable.");
612
+ }
613
+ const ids = { uid, gid };
614
+ await ensureWorkspaceUser(workspaceId, homeDir, ids);
615
+ if (workspaceRecord) {
616
+ await persistWorkspaceRecord({
617
+ workspaceId,
618
+ providers: workspaceRecord.providers || {},
619
+ ids,
620
+ existing: workspaceRecord,
621
+ });
622
+ }
623
+ await appendAuditLog(workspaceId, "workspace_user_rehydrated", {
624
+ uid: ids.uid,
625
+ gid: ids.gid,
626
+ });
627
+ return ids;
628
+ };
629
+
630
+ const ensureWorkspaceDirs = async (workspaceId) => {
631
+ const paths = getWorkspacePaths(workspaceId);
632
+ await runAsCommand(workspaceId, "/bin/mkdir", ["-p", paths.sessionsDir]);
633
+ await runAsCommand(workspaceId, "/bin/chmod", ["700", paths.sessionsDir]);
634
+ const sshPaths = getWorkspaceSshPaths(paths.homeDir);
635
+ await ensureWorkspaceDir(workspaceId, sshPaths.sshDir, 0o700);
636
+ return paths;
637
+ };
638
+
639
+ const decodeBase64 = (value) => {
640
+ if (!value) {
641
+ return "";
642
+ }
643
+ try {
644
+ return Buffer.from(value, "base64").toString("utf8");
645
+ } catch (error) {
646
+ throw new Error("Invalid base64 payload.");
647
+ }
648
+ };
649
+
650
+ const isObject = (value) => value != null && typeof value === "object" && !Array.isArray(value);
651
+
652
+ const validateCodexAuthJson = (raw) => {
653
+ let parsed;
654
+ try {
655
+ parsed = JSON.parse(raw);
656
+ } catch (error) {
657
+ throw new Error("Invalid Codex auth.json payload.");
658
+ }
659
+ if (!isObject(parsed)) {
660
+ throw new Error("Invalid Codex auth.json payload.");
661
+ }
662
+ if (!Object.prototype.hasOwnProperty.call(parsed, "OPENAI_API_KEY")) {
663
+ throw new Error("Invalid Codex auth.json payload.");
664
+ }
665
+ if (!isObject(parsed.tokens)) {
666
+ throw new Error("Invalid Codex auth.json payload.");
667
+ }
668
+ const requiredTokenFields = ["id_token", "access_token", "refresh_token", "account_id"];
669
+ for (const field of requiredTokenFields) {
670
+ if (typeof parsed.tokens[field] !== "string" || !parsed.tokens[field]) {
671
+ throw new Error("Invalid Codex auth.json payload.");
672
+ }
673
+ }
674
+ if (typeof parsed.last_refresh !== "string" || !parsed.last_refresh) {
675
+ throw new Error("Invalid Codex auth.json payload.");
676
+ }
677
+ return parsed;
678
+ };
679
+
680
+ const writeWorkspaceProviderAuth = async (workspaceId, providers) => {
681
+ const workspaceHome = getWorkspacePaths(workspaceId).homeDir;
682
+ const authPaths = getWorkspaceAuthPaths(workspaceHome);
683
+
684
+ const codexConfig = providers?.codex;
685
+ if (codexConfig?.enabled && codexConfig.auth) {
686
+ await ensureWorkspaceDir(workspaceId, authPaths.codexDir, 0o700);
687
+ if (codexConfig.auth.type === "api_key") {
688
+ const payload = JSON.stringify({ OPENAI_API_KEY: codexConfig.auth.value }, null, 2);
689
+ await writeWorkspaceFile(workspaceId, authPaths.codexAuthPath, payload, 0o600);
690
+ } else if (codexConfig.auth.type === "auth_json_b64") {
691
+ const decoded = decodeBase64(codexConfig.auth.value);
692
+ validateCodexAuthJson(decoded);
693
+ await writeWorkspaceFile(workspaceId, authPaths.codexAuthPath, decoded, 0o600);
694
+ }
695
+ }
696
+
697
+ const claudeConfig = providers?.claude;
698
+ if (claudeConfig?.enabled && claudeConfig.auth) {
699
+ await runAsCommand(workspaceId, "/bin/rm", ["-f", authPaths.claudeAuthPath]);
700
+ await runAsCommand(workspaceId, "/bin/rm", ["-f", authPaths.claudeCredentialsPath]);
701
+ if (claudeConfig.auth.type === "api_key") {
702
+ const payload = JSON.stringify({ primaryApiKey: claudeConfig.auth.value }, null, 2);
703
+ await writeWorkspaceFile(workspaceId, authPaths.claudeAuthPath, payload, 0o600);
704
+ } else if (claudeConfig.auth.type === "setup_token") {
705
+ await ensureWorkspaceDir(workspaceId, authPaths.claudeDir, 0o700);
706
+ const payload = JSON.stringify(
707
+ {
708
+ claudeAiOauth: {
709
+ accessToken: claudeConfig.auth.value,
710
+ refreshToken: "dummy",
711
+ expiresAt: 1969350365482,
712
+ scopes: [
713
+ "user:inference",
714
+ "user:mcp_servers",
715
+ "user:profile",
716
+ "user:sessions:claude_code",
717
+ ],
718
+ subscriptionType: "pro",
719
+ rateLimitTier: "default_claude_ai",
720
+ },
721
+ },
722
+ null,
723
+ 2
724
+ );
725
+ await writeWorkspaceFile(workspaceId, authPaths.claudeCredentialsPath, payload, 0o600);
726
+ }
727
+ }
728
+ };
729
+
730
+ export const readWorkspaceConfig = async (workspaceId) => {
731
+ const record = await getWorkspaceRecord(workspaceId);
732
+ return toWorkspaceConfigPayload(record);
733
+ };
734
+
735
+ export const verifyWorkspaceSecret = async (workspaceId, workspaceSecret) => {
736
+ const record = await getWorkspaceRecord(workspaceId);
737
+ return compareWorkspaceSecretHash(workspaceSecret, record.workspaceSecretHash);
738
+ };
739
+
740
+ export const rotateWorkspaceSecret = async (workspaceId, options = {}) => {
741
+ if (!workspaceIdPattern.test(workspaceId)) {
742
+ throw new Error("Invalid workspaceId.");
743
+ }
744
+ const record = await getWorkspaceRecord(workspaceId);
745
+ const provided =
746
+ typeof options?.workspaceSecret === "string" ? options.workspaceSecret.trim() : "";
747
+ const nextSecret = provided || crypto.randomBytes(32).toString("hex");
748
+ await persistWorkspaceRecord({
749
+ workspaceId,
750
+ providers: record.providers || {},
751
+ ids: null,
752
+ workspaceSecretHash: hashWorkspaceSecret(nextSecret),
753
+ existing: record,
754
+ });
755
+ await appendAuditLog(workspaceId, "workspace_secret_rotated", {
756
+ actor: typeof options?.actor === "string" && options.actor ? options.actor : "system",
757
+ });
758
+ return { workspaceId, workspaceSecret: nextSecret };
759
+ };
760
+
761
+ export const ensureDefaultMonoWorkspace = async () => {
762
+ if (!isMonoUser) {
763
+ return;
764
+ }
765
+ const workspaceId = "default";
766
+ await ensureWorkspaceDirs(workspaceId);
767
+ const ids = await getWorkspaceUserIds(workspaceId);
768
+ const existing = await storage.getWorkspace(workspaceId);
769
+ if (!existing) {
770
+ const providers = {
771
+ codex: { enabled: true, auth: null },
772
+ claude: { enabled: true, auth: null },
773
+ };
774
+ await persistWorkspaceRecord({
775
+ workspaceId,
776
+ providers,
777
+ ids,
778
+ workspaceSecretHash: hashWorkspaceSecret("default"),
779
+ existing: null,
780
+ });
781
+ await appendAuditLog(workspaceId, "workspace_created");
782
+ }
783
+ };
784
+
785
+ export const createWorkspace = async (providers) => {
786
+ const validationError = validateProvidersConfig(providers);
787
+ if (validationError) {
788
+ throw new Error(validationError);
789
+ }
790
+ if (isMonoUser) {
791
+ const workspaceId = "default";
792
+ await ensureWorkspaceDirs(workspaceId);
793
+ const secret = "default";
794
+ const ids = await getWorkspaceUserIds(workspaceId);
795
+ const existing = await storage.getWorkspace(workspaceId);
796
+ await writeWorkspaceProviderAuth(workspaceId, providers);
797
+ await persistWorkspaceRecord({
798
+ workspaceId,
799
+ providers,
800
+ ids,
801
+ workspaceSecretHash: hashWorkspaceSecret(secret),
802
+ existing,
803
+ });
804
+ await appendAuditLog(workspaceId, "workspace_created");
805
+ return { workspaceId, workspaceSecret: secret };
806
+ }
807
+ while (true) {
808
+ const workspaceId = generateId("w");
809
+ if (!workspaceIdPattern.test(workspaceId)) {
810
+ continue;
811
+ }
812
+ if (await workspaceUserExists(workspaceId)) {
813
+ continue;
814
+ }
815
+ const ids = await allocateWorkspaceIds();
816
+ await runRootCommand([
817
+ "create-workspace",
818
+ "--workspace-id",
819
+ workspaceId,
820
+ "--uid",
821
+ String(ids.uid),
822
+ "--gid",
823
+ String(ids.gid),
824
+ ]);
825
+ await storage.saveWorkspaceUserIds(workspaceId, ids);
826
+ const secret = crypto.randomBytes(32).toString("hex");
827
+ await writeWorkspaceProviderAuth(workspaceId, providers);
828
+ await persistWorkspaceRecord({
829
+ workspaceId,
830
+ providers,
831
+ ids,
832
+ workspaceSecretHash: hashWorkspaceSecret(secret),
833
+ });
834
+ await appendAuditLog(workspaceId, "workspace_created");
835
+ return { workspaceId, workspaceSecret: secret };
836
+ }
837
+ };
838
+
839
+ export const updateWorkspace = async (workspaceId, providers) => {
840
+ const validationError = validateProvidersConfig(providers);
841
+ if (validationError) {
842
+ throw new Error(validationError);
843
+ }
844
+ const ids = await getWorkspaceUserIds(workspaceId);
845
+ const existing = await storage.getWorkspace(workspaceId);
846
+ if (!existing) {
847
+ throw new Error("Workspace not found.");
848
+ }
849
+ await writeWorkspaceProviderAuth(workspaceId, providers);
850
+ const payload = await persistWorkspaceRecord({
851
+ workspaceId,
852
+ providers,
853
+ ids,
854
+ existing,
855
+ });
856
+ await appendAuditLog(workspaceId, "workspace_updated");
857
+ return toWorkspaceConfigPayload(payload);
858
+ };