aicodeman 1.4.1 → 1.5.0
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/README.md +20 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +168 -0
- package/dist/cli.js.map +1 -1
- package/dist/config/multiuser.d.ts +38 -0
- package/dist/config/multiuser.d.ts.map +1 -0
- package/dist/config/multiuser.js +58 -0
- package/dist/config/multiuser.js.map +1 -0
- package/dist/cron/cron-service.d.ts +1 -1
- package/dist/cron/cron-service.d.ts.map +1 -1
- package/dist/cron/cron-service.js +31 -4
- package/dist/cron/cron-service.js.map +1 -1
- package/dist/mux-interface.d.ts +6 -0
- package/dist/mux-interface.d.ts.map +1 -1
- package/dist/plan-orchestrator.d.ts +8 -1
- package/dist/plan-orchestrator.d.ts.map +1 -1
- package/dist/plan-orchestrator.js +20 -1
- package/dist/plan-orchestrator.js.map +1 -1
- package/dist/push-store.d.ts +17 -5
- package/dist/push-store.d.ts.map +1 -1
- package/dist/push-store.js +3 -1
- package/dist/push-store.js.map +1 -1
- package/dist/session-cli-builder.d.ts +1 -1
- package/dist/session-cli-builder.d.ts.map +1 -1
- package/dist/session-cli-builder.js +7 -2
- package/dist/session-cli-builder.js.map +1 -1
- package/dist/session.d.ts +9 -0
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +19 -2
- package/dist/session.js.map +1 -1
- package/dist/tmux-manager.d.ts +15 -1
- package/dist/tmux-manager.d.ts.map +1 -1
- package/dist/tmux-manager.js +21 -6
- package/dist/tmux-manager.js.map +1 -1
- package/dist/tunnel-manager.d.ts +17 -0
- package/dist/tunnel-manager.d.ts.map +1 -1
- package/dist/tunnel-manager.js +48 -8
- package/dist/tunnel-manager.js.map +1 -1
- package/dist/types/api.d.ts +10 -0
- package/dist/types/api.d.ts.map +1 -1
- package/dist/types/api.js +20 -0
- package/dist/types/api.js.map +1 -1
- package/dist/types/cron.d.ts +2 -0
- package/dist/types/cron.d.ts.map +1 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +1 -0
- package/dist/types/index.js.map +1 -1
- package/dist/types/session.d.ts +8 -1
- package/dist/types/session.d.ts.map +1 -1
- package/dist/types/session.js.map +1 -1
- package/dist/types/user.d.ts +59 -0
- package/dist/types/user.d.ts.map +1 -0
- package/dist/types/user.js +12 -0
- package/dist/types/user.js.map +1 -0
- package/dist/user-store.d.ts +139 -0
- package/dist/user-store.d.ts.map +1 -0
- package/dist/user-store.js +433 -0
- package/dist/user-store.js.map +1 -0
- package/dist/web/admin-audit.d.ts +17 -0
- package/dist/web/admin-audit.d.ts.map +1 -0
- package/dist/web/admin-audit.js +19 -0
- package/dist/web/admin-audit.js.map +1 -0
- package/dist/web/middleware/auth.d.ts +8 -0
- package/dist/web/middleware/auth.d.ts.map +1 -1
- package/dist/web/middleware/auth.js +250 -51
- package/dist/web/middleware/auth.js.map +1 -1
- package/dist/web/ports/auth-port.d.ts +13 -0
- package/dist/web/ports/auth-port.d.ts.map +1 -1
- package/dist/web/ports/config-port.d.ts +4 -1
- package/dist/web/ports/config-port.d.ts.map +1 -1
- package/dist/web/ports/infra-port.d.ts +4 -1
- package/dist/web/ports/infra-port.d.ts.map +1 -1
- package/dist/web/public/admin-ui.js +260 -0
- package/dist/web/public/admin-ui.js.br +0 -0
- package/dist/web/public/admin-ui.js.gz +0 -0
- package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
- package/dist/web/public/app.6c4b9893.js.gz +0 -0
- package/dist/web/public/{constants.48da7f03.js → constants.5ce97313.js} +3 -0
- package/dist/web/public/constants.5ce97313.js.br +0 -0
- package/dist/web/public/{constants.48da7f03.js.gz → constants.5ce97313.js.gz} +0 -0
- package/dist/web/public/cron-ui.js.gz +0 -0
- package/dist/web/public/image-input.ee16ad88.js.gz +0 -0
- package/dist/web/public/index.html +5 -3
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/input-cjk.63794d0b.js.gz +0 -0
- package/dist/web/public/keyboard-accessory.3f91acef.js.gz +0 -0
- package/dist/web/public/mobile-handlers.b5a667ef.js.gz +0 -0
- package/dist/web/public/mobile.0644e3ac.css.gz +0 -0
- package/dist/web/public/notification-manager.ca9dc40d.js.gz +0 -0
- package/dist/web/public/orchestrator-panel.js.gz +0 -0
- package/dist/web/public/panels-ui.3c9b0465.js.gz +0 -0
- package/dist/web/public/ralph-panel.6de2d0f8.js.gz +0 -0
- package/dist/web/public/ralph-wizard.13a1831e.js.gz +0 -0
- package/dist/web/public/respawn-ui.2d249da9.js.gz +0 -0
- package/dist/web/public/sanitize-html.bc7078d6.js.gz +0 -0
- package/dist/web/public/session-ui.4851c61a.js.gz +0 -0
- package/dist/web/public/settings-ui.29214cf1.js.gz +0 -0
- package/dist/web/public/styles.9e6cdb9d.css +1 -0
- package/dist/web/public/styles.9e6cdb9d.css.br +0 -0
- package/dist/web/public/styles.9e6cdb9d.css.gz +0 -0
- package/dist/web/public/subagent-windows.69b60c35.js.gz +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/public/terminal-ui.efda6083.js.gz +0 -0
- package/dist/web/public/ultracode-panel.js.gz +0 -0
- package/dist/web/public/ultracode-windows.js.gz +0 -0
- package/dist/web/public/upload.html.gz +0 -0
- package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
- package/dist/web/public/vendor/marked.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-zerolag-input.137ad9f0.js.gz +0 -0
- package/dist/web/public/vendor/xterm.css.gz +0 -0
- package/dist/web/public/vendor/xterm.min.js.gz +0 -0
- package/dist/web/public/voice-input.085e9e73.js.gz +0 -0
- package/dist/web/route-helpers.d.ts +78 -1
- package/dist/web/route-helpers.d.ts.map +1 -1
- package/dist/web/route-helpers.js +172 -3
- package/dist/web/route-helpers.js.map +1 -1
- package/dist/web/routes/admin-routes.d.ts +20 -0
- package/dist/web/routes/admin-routes.d.ts.map +1 -0
- package/dist/web/routes/admin-routes.js +193 -0
- package/dist/web/routes/admin-routes.js.map +1 -0
- package/dist/web/routes/case-routes.d.ts.map +1 -1
- package/dist/web/routes/case-routes.js +137 -59
- package/dist/web/routes/case-routes.js.map +1 -1
- package/dist/web/routes/clipboard-routes.d.ts +2 -2
- package/dist/web/routes/clipboard-routes.d.ts.map +1 -1
- package/dist/web/routes/clipboard-routes.js +10 -0
- package/dist/web/routes/clipboard-routes.js.map +1 -1
- package/dist/web/routes/cron-routes.d.ts.map +1 -1
- package/dist/web/routes/cron-routes.js +57 -9
- package/dist/web/routes/cron-routes.js.map +1 -1
- package/dist/web/routes/file-routes.d.ts.map +1 -1
- package/dist/web/routes/file-routes.js +26 -20
- package/dist/web/routes/file-routes.js.map +1 -1
- package/dist/web/routes/index.d.ts +2 -0
- package/dist/web/routes/index.d.ts.map +1 -1
- package/dist/web/routes/index.js +2 -0
- package/dist/web/routes/index.js.map +1 -1
- package/dist/web/routes/me-routes.d.ts +16 -0
- package/dist/web/routes/me-routes.d.ts.map +1 -0
- package/dist/web/routes/me-routes.js +73 -0
- package/dist/web/routes/me-routes.js.map +1 -0
- package/dist/web/routes/mux-routes.d.ts.map +1 -1
- package/dist/web/routes/mux-routes.js +23 -5
- package/dist/web/routes/mux-routes.js.map +1 -1
- package/dist/web/routes/orchestrator-routes.d.ts.map +1 -1
- package/dist/web/routes/orchestrator-routes.js +43 -11
- package/dist/web/routes/orchestrator-routes.js.map +1 -1
- package/dist/web/routes/plan-routes.d.ts.map +1 -1
- package/dist/web/routes/plan-routes.js +28 -9
- package/dist/web/routes/plan-routes.js.map +1 -1
- package/dist/web/routes/push-routes.d.ts.map +1 -1
- package/dist/web/routes/push-routes.js +4 -0
- package/dist/web/routes/push-routes.js.map +1 -1
- package/dist/web/routes/ralph-routes.d.ts.map +1 -1
- package/dist/web/routes/ralph-routes.js +21 -17
- package/dist/web/routes/ralph-routes.js.map +1 -1
- package/dist/web/routes/respawn-routes.d.ts.map +1 -1
- package/dist/web/routes/respawn-routes.js +23 -14
- package/dist/web/routes/respawn-routes.js.map +1 -1
- package/dist/web/routes/scheduled-routes.d.ts.map +1 -1
- package/dist/web/routes/scheduled-routes.js +22 -6
- package/dist/web/routes/scheduled-routes.js.map +1 -1
- package/dist/web/routes/search-routes.d.ts.map +1 -1
- package/dist/web/routes/search-routes.js +9 -3
- package/dist/web/routes/search-routes.js.map +1 -1
- package/dist/web/routes/session-routes.d.ts.map +1 -1
- package/dist/web/routes/session-routes.js +188 -61
- package/dist/web/routes/session-routes.js.map +1 -1
- package/dist/web/routes/system-routes.d.ts.map +1 -1
- package/dist/web/routes/system-routes.js +108 -23
- package/dist/web/routes/system-routes.js.map +1 -1
- package/dist/web/routes/ws-routes.d.ts.map +1 -1
- package/dist/web/routes/ws-routes.js +11 -0
- package/dist/web/routes/ws-routes.js.map +1 -1
- package/dist/web/server.d.ts +12 -0
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +187 -11
- package/dist/web/server.js.map +1 -1
- package/dist/web/sse-events.d.ts +6 -0
- package/dist/web/sse-events.d.ts.map +1 -1
- package/dist/web/sse-events.js +7 -0
- package/dist/web/sse-events.js.map +1 -1
- package/dist/web/sse-stream-manager.d.ts +32 -2
- package/dist/web/sse-stream-manager.d.ts.map +1 -1
- package/dist/web/sse-stream-manager.js +51 -4
- package/dist/web/sse-stream-manager.js.map +1 -1
- package/package.json +1 -1
- package/dist/web/public/constants.48da7f03.js.br +0 -0
- package/dist/web/public/styles.ff43f80f.css +0 -1
- package/dist/web/public/styles.ff43f80f.css.br +0 -0
- package/dist/web/public/styles.ff43f80f.css.gz +0 -0
|
@@ -13,7 +13,9 @@ import { ApiErrorCode, createErrorResponse, getErrorMessage, } from '../../types
|
|
|
13
13
|
import { Session, isAltScreenStripMode } from '../../session.js';
|
|
14
14
|
import { SseEvent } from '../sse-events.js';
|
|
15
15
|
import { CreateSessionSchema, SessionNameSchema, SessionColorSchema, RunPromptSchema, SessionInputWithLimitSchema, ResizeSchema, AutoClearSchema, AutoCompactSchema, AutoResumeSchema, ImageWatcherSchema, FlickerFilterSchema, QuickRunSchema, QuickStartSchema, InteractiveStartSchema, } from '../schemas.js';
|
|
16
|
-
import { autoConfigureRalph, CASES_DIR, findSessionOrFail, parseBody, persistAndBroadcastSession, SETTINGS_PATH, validatePathWithinBase, } from '../route-helpers.js';
|
|
16
|
+
import { autoConfigureRalph, canAccessOwned, CASES_DIR, findSessionOrFail, getAuthUser, isAdmin, isWorkingDirAllowed, ownerFor, parseBody, persistAndBroadcastSession, resolveCasesDir, sessionCapacityMessage, SETTINGS_PATH, validatePathWithinBase, } from '../route-helpers.js';
|
|
17
|
+
import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../../user-store.js';
|
|
18
|
+
import { isMultiUserMode } from '../../config/multiuser.js';
|
|
17
19
|
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
|
18
20
|
import { writeHooksConfig, updateCaseModel, stripCaseEnvKeys, applyStatusLineConfig, refreshStaleHookSecret, } from '../../hooks-config.js';
|
|
19
21
|
import { generateClaudeMd } from '../../templates/claude-md.js';
|
|
@@ -21,7 +23,6 @@ import { imageWatcher } from '../../image-watcher.js';
|
|
|
21
23
|
import { convertHeicToJpeg } from '../heic-jpeg-converter.js';
|
|
22
24
|
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
|
23
25
|
import { mergeUnifiedSessions, filterAndPaginate, } from '../../services/unified-session-service.js';
|
|
24
|
-
import { MAX_CONCURRENT_SESSIONS } from '../../config/map-limits.js';
|
|
25
26
|
import { RunSummaryTracker } from '../../run-summary.js';
|
|
26
27
|
import { MAX_INPUT_LENGTH, MAX_SESSION_NAME_LENGTH } from '../../config/terminal-limits.js';
|
|
27
28
|
import { MAX_PASTE_IMAGE_BYTES } from '../../config/buffer-limits.js';
|
|
@@ -197,6 +198,25 @@ export function consumePasteToken(key, now = Date.now()) {
|
|
|
197
198
|
export function _resetPasteRateBuckets() {
|
|
198
199
|
pasteRateBuckets.clear();
|
|
199
200
|
}
|
|
201
|
+
/**
|
|
202
|
+
* Security (multi-user §6.3): the Claude-only permission-mode downgrade does not
|
|
203
|
+
* cover the other CLIs' bypass switches. Codex `--dangerously-bypass-approvals-and-sandbox`
|
|
204
|
+
* and Gemini `--approval-mode yolo` disable the safety classifier the non-granted-user
|
|
205
|
+
* downgrade is meant to keep on, so clamp them for a non-granted owner. buildGeminiCommand
|
|
206
|
+
* defaults an ABSENT approvalMode to yolo, so the gemini config must be MATERIALIZED
|
|
207
|
+
* (auto_edit) even when the request sent none. No-op in single-user mode / for a granted
|
|
208
|
+
* owner (canUsernameRunPrivilegedCommands returns true when !isMultiUserMode()).
|
|
209
|
+
*/
|
|
210
|
+
async function clampExternalCliBypassForOwner(owner, codexConfig, geminiConfig) {
|
|
211
|
+
const granted = await canUsernameRunPrivilegedCommands(owner);
|
|
212
|
+
if (granted)
|
|
213
|
+
return { codexConfig, geminiConfig };
|
|
214
|
+
// Non-granted: force codex bypass off (only meaningful when a config was sent) and
|
|
215
|
+
// materialize gemini to auto_edit (clamps an explicit 'yolo' and the yolo default).
|
|
216
|
+
const clampedCodex = codexConfig ? { ...codexConfig, dangerouslyBypassApprovals: false } : codexConfig;
|
|
217
|
+
const clampedGemini = { ...(geminiConfig ?? {}), approvalMode: 'auto_edit' };
|
|
218
|
+
return { codexConfig: clampedCodex, geminiConfig: clampedGemini };
|
|
219
|
+
}
|
|
200
220
|
export function registerSessionRoutes(app, ctx) {
|
|
201
221
|
// ═══════════════════════════════════════════════════════════════
|
|
202
222
|
// Auth
|
|
@@ -215,17 +235,36 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
215
235
|
// Session CRUD (list, create, rename, color, delete, detail)
|
|
216
236
|
// ═══════════════════════════════════════════════════════════════
|
|
217
237
|
// ========== Session Listing ==========
|
|
218
|
-
app.get('/api/sessions', async () => {
|
|
219
|
-
|
|
238
|
+
app.get('/api/sessions', async (req) => {
|
|
239
|
+
const list = ctx.getLightSessionsState();
|
|
240
|
+
if (!isMultiUserMode())
|
|
241
|
+
return list;
|
|
242
|
+
const user = getAuthUser(req);
|
|
243
|
+
if (user.role === 'admin')
|
|
244
|
+
return list;
|
|
245
|
+
return list.filter((s) => canAccessOwned(user, s.owner));
|
|
220
246
|
});
|
|
221
247
|
// ========== Session Creation ==========
|
|
222
248
|
app.post('/api/sessions', async (req) => {
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
249
|
+
const owner = ownerFor(req);
|
|
250
|
+
// Global + per-user session cap.
|
|
251
|
+
const capMsg = sessionCapacityMessage(ctx.sessions, owner);
|
|
252
|
+
if (capMsg)
|
|
253
|
+
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, capMsg);
|
|
227
254
|
const body = parseBody(CreateSessionSchema, req.body);
|
|
228
255
|
const workingDir = body.workingDir || process.cwd();
|
|
256
|
+
// Multi-user: shell mode is arbitrary command execution as the host account,
|
|
257
|
+
// gated behind the same grant as bypass (section 6.3). Resolve the owner's grant
|
|
258
|
+
// from the store so a GRANTED regular user is not wrongly denied (AuthUser role alone can't tell).
|
|
259
|
+
if (body.mode === 'shell' && !(await canUsernameRunPrivilegedCommands(owner))) {
|
|
260
|
+
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Shell sessions require the can-bypass-permissions grant');
|
|
261
|
+
}
|
|
262
|
+
// Multi-user linchpin (section 6.2): a non-admin's workingDir must resolve
|
|
263
|
+
// inside their own case space. Enforced BEFORE any disk-mutating call below so
|
|
264
|
+
// a foreign path can never be written into.
|
|
265
|
+
if (!isWorkingDirAllowed(getAuthUser(req), workingDir)) {
|
|
266
|
+
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'workingDir is outside your workspace');
|
|
267
|
+
}
|
|
229
268
|
// Validate workingDir exists and is a directory
|
|
230
269
|
if (body.workingDir) {
|
|
231
270
|
try {
|
|
@@ -243,15 +282,17 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
243
282
|
// For keys the caller is actively setting, strip any stale disk entry a prior
|
|
244
283
|
// Codeman version may have written. Scope limited to:
|
|
245
284
|
// - Claude mode (OpenCode/Codex/Gemini don't read .claude/settings.local.json)
|
|
246
|
-
// - workingDir inside CASES_DIR (Codeman's managed
|
|
247
|
-
// .claude/settings.local.json in arbitrary user
|
|
248
|
-
// can target,
|
|
285
|
+
// - workingDir inside CASES_DIR / the per-user case space (Codeman's managed
|
|
286
|
+
// territory — we never mutate .claude/settings.local.json in arbitrary user
|
|
287
|
+
// repos that POST /api/sessions can target, as those may have hand-authored
|
|
288
|
+
// values).
|
|
289
|
+
const managedCasesBase = resolveCasesDir(getAuthUser(req));
|
|
249
290
|
const canStripDisk = body.mode !== 'opencode' &&
|
|
250
291
|
body.mode !== 'codex' &&
|
|
251
292
|
body.mode !== 'gemini' &&
|
|
252
293
|
body.envOverrides &&
|
|
253
294
|
Object.keys(body.envOverrides).length > 0 &&
|
|
254
|
-
workingDir.startsWith(CASES_DIR + '/');
|
|
295
|
+
(workingDir.startsWith(CASES_DIR + '/') || workingDir.startsWith(managedCasesBase + '/'));
|
|
255
296
|
if (canStripDisk) {
|
|
256
297
|
await stripCaseEnvKeys(workingDir, Object.keys(body.envOverrides));
|
|
257
298
|
}
|
|
@@ -344,6 +385,10 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
344
385
|
? modelConfig?.defaultModel || undefined
|
|
345
386
|
: undefined;
|
|
346
387
|
const claudeModeConfig = await ctx.getClaudeModeConfig();
|
|
388
|
+
// Section 6.3: force non-granted users to a classifier-guarded mode.
|
|
389
|
+
const effectiveClaudeMode = await resolveClaudeModeForUsername(claudeModeConfig.claudeMode, owner);
|
|
390
|
+
// Section 6.3: clamp Codex/Gemini bypass switches for a non-granted owner (no-op single-user/granted).
|
|
391
|
+
const { codexConfig: gatedCodexConfig, geminiConfig: gatedGeminiConfig } = await clampExternalCliBypassForOwner(owner, body.codexConfig, body.geminiConfig);
|
|
347
392
|
const terminalHistoryConfig = await ctx.getTerminalHistoryConfig();
|
|
348
393
|
const session = new Session({
|
|
349
394
|
workingDir,
|
|
@@ -353,15 +398,16 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
353
398
|
useMux: true,
|
|
354
399
|
niceConfig: globalNice,
|
|
355
400
|
model,
|
|
356
|
-
claudeMode:
|
|
401
|
+
claudeMode: effectiveClaudeMode,
|
|
357
402
|
allowedTools: claudeModeConfig.allowedTools,
|
|
358
403
|
openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined,
|
|
359
|
-
codexConfig: mode === 'codex' ?
|
|
360
|
-
geminiConfig: mode === 'gemini' ?
|
|
404
|
+
codexConfig: mode === 'codex' ? gatedCodexConfig : undefined,
|
|
405
|
+
geminiConfig: mode === 'gemini' ? gatedGeminiConfig : undefined,
|
|
361
406
|
resumeSessionId: validatedResumeId,
|
|
362
407
|
envOverrides: body.envOverrides,
|
|
363
408
|
effort: body.effort,
|
|
364
409
|
tmuxHistoryLimit: terminalHistoryConfig.tmuxHistoryLimit,
|
|
410
|
+
owner,
|
|
365
411
|
});
|
|
366
412
|
ctx.addSession(session);
|
|
367
413
|
ctx.store.incrementSessionsCreated();
|
|
@@ -378,7 +424,7 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
378
424
|
app.put('/api/sessions/:id/name', async (req) => {
|
|
379
425
|
const { id } = req.params;
|
|
380
426
|
const body = parseBody(SessionNameSchema, req.body, 'Invalid request body');
|
|
381
|
-
const session = findSessionOrFail(ctx, id);
|
|
427
|
+
const session = findSessionOrFail(ctx, id, req);
|
|
382
428
|
const name = String(body.name || '').slice(0, MAX_SESSION_NAME_LENGTH);
|
|
383
429
|
session.name = name;
|
|
384
430
|
// Also update the mux session name if applicable
|
|
@@ -390,7 +436,7 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
390
436
|
app.put('/api/sessions/:id/color', async (req) => {
|
|
391
437
|
const { id } = req.params;
|
|
392
438
|
const body = parseBody(SessionColorSchema, req.body, 'Invalid request body');
|
|
393
|
-
const session = findSessionOrFail(ctx, id);
|
|
439
|
+
const session = findSessionOrFail(ctx, id, req);
|
|
394
440
|
const validColors = ['default', 'red', 'orange', 'yellow', 'green', 'blue', 'purple', 'pink'];
|
|
395
441
|
if (!validColors.includes(body.color)) {
|
|
396
442
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid color');
|
|
@@ -404,15 +450,19 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
404
450
|
const { id } = req.params;
|
|
405
451
|
const query = req.query;
|
|
406
452
|
const killMux = query.killMux !== 'false'; // Default to true
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
await ctx.cleanupSession(id, killMux, 'user_delete');
|
|
453
|
+
// Security: owner-scoped lookup 404s foreign/missing sessions uniformly (no existence leak, no cross-user kill).
|
|
454
|
+
const session = findSessionOrFail(ctx, id, req);
|
|
455
|
+
await ctx.cleanupSession(session.id, killMux, 'user_delete');
|
|
411
456
|
return {};
|
|
412
457
|
});
|
|
413
458
|
// ========== Delete All Sessions ==========
|
|
414
|
-
app.delete('/api/sessions', async () => {
|
|
415
|
-
|
|
459
|
+
app.delete('/api/sessions', async (req) => {
|
|
460
|
+
// Security: scope the bulk sweep to sessions the caller can access — a non-admin
|
|
461
|
+
// must not wipe other users' sessions (canAccessOwned is allow-all for admin/single-user).
|
|
462
|
+
const user = getAuthUser(req);
|
|
463
|
+
const sessionIds = Array.from(ctx.sessions.values())
|
|
464
|
+
.filter((s) => canAccessOwned(user, s.owner))
|
|
465
|
+
.map((s) => s.id);
|
|
416
466
|
let killed = 0;
|
|
417
467
|
for (const id of sessionIds) {
|
|
418
468
|
if (ctx.sessions.has(id)) {
|
|
@@ -425,7 +475,7 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
425
475
|
// ========== Get Session Detail ==========
|
|
426
476
|
app.get('/api/sessions/:id', async (req) => {
|
|
427
477
|
const { id } = req.params;
|
|
428
|
-
const session = findSessionOrFail(ctx, id);
|
|
478
|
+
const session = findSessionOrFail(ctx, id, req);
|
|
429
479
|
// Use light state (no full buffers) — terminal buffer available via /terminal endpoint.
|
|
430
480
|
// Full buffers were 2-3MB and caused slowness when polled frequently (e.g. Ralph wizard).
|
|
431
481
|
return ctx.getSessionStateWithRespawn(session);
|
|
@@ -436,7 +486,7 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
436
486
|
// ========== Get Session Output ==========
|
|
437
487
|
app.get('/api/sessions/:id/output', async (req) => {
|
|
438
488
|
const { id } = req.params;
|
|
439
|
-
const session = findSessionOrFail(ctx, id);
|
|
489
|
+
const session = findSessionOrFail(ctx, id, req);
|
|
440
490
|
return {
|
|
441
491
|
success: true,
|
|
442
492
|
data: {
|
|
@@ -449,7 +499,7 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
449
499
|
// ========== Get Ralph State ==========
|
|
450
500
|
app.get('/api/sessions/:id/ralph-state', async (req) => {
|
|
451
501
|
const { id } = req.params;
|
|
452
|
-
const session = findSessionOrFail(ctx, id);
|
|
502
|
+
const session = findSessionOrFail(ctx, id, req);
|
|
453
503
|
return {
|
|
454
504
|
success: true,
|
|
455
505
|
data: {
|
|
@@ -462,7 +512,7 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
462
512
|
// ========== Get Run Summary ==========
|
|
463
513
|
app.get('/api/sessions/:id/run-summary', async (req) => {
|
|
464
514
|
const { id } = req.params;
|
|
465
|
-
const session = findSessionOrFail(ctx, id);
|
|
515
|
+
const session = findSessionOrFail(ctx, id, req);
|
|
466
516
|
const tracker = ctx.runSummaryTrackers.get(id);
|
|
467
517
|
if (!tracker) {
|
|
468
518
|
// Create a fresh tracker if one doesn't exist (shouldn't happen normally)
|
|
@@ -477,7 +527,7 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
477
527
|
// ========== Get Active Tools ==========
|
|
478
528
|
app.get('/api/sessions/:id/active-tools', async (req) => {
|
|
479
529
|
const { id } = req.params;
|
|
480
|
-
const session = findSessionOrFail(ctx, id);
|
|
530
|
+
const session = findSessionOrFail(ctx, id, req);
|
|
481
531
|
return {
|
|
482
532
|
success: true,
|
|
483
533
|
data: {
|
|
@@ -492,7 +542,7 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
492
542
|
app.post('/api/sessions/:id/run', async (req) => {
|
|
493
543
|
const { id } = req.params;
|
|
494
544
|
const { prompt } = parseBody(RunPromptSchema, req.body);
|
|
495
|
-
const session = findSessionOrFail(ctx, id);
|
|
545
|
+
const session = findSessionOrFail(ctx, id, req);
|
|
496
546
|
if (session.isBusy()) {
|
|
497
547
|
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
|
498
548
|
}
|
|
@@ -514,7 +564,7 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
514
564
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
|
515
565
|
}
|
|
516
566
|
const { clearBreaker } = bodyResult.data;
|
|
517
|
-
const session = findSessionOrFail(ctx, id);
|
|
567
|
+
const session = findSessionOrFail(ctx, id, req);
|
|
518
568
|
if (session.isBusy()) {
|
|
519
569
|
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
|
520
570
|
}
|
|
@@ -563,7 +613,7 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
563
613
|
// ========== Start Shell Mode ==========
|
|
564
614
|
app.post('/api/sessions/:id/shell', async (req) => {
|
|
565
615
|
const { id } = req.params;
|
|
566
|
-
const session = findSessionOrFail(ctx, id);
|
|
616
|
+
const session = findSessionOrFail(ctx, id, req);
|
|
567
617
|
if (session.isBusy()) {
|
|
568
618
|
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
|
569
619
|
}
|
|
@@ -592,7 +642,7 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
592
642
|
app.post('/api/sessions/:id/input', async (req) => {
|
|
593
643
|
const { id } = req.params;
|
|
594
644
|
const { input, useMux, seq, clientId } = parseBody(SessionInputWithLimitSchema, req.body);
|
|
595
|
-
const session = findSessionOrFail(ctx, id);
|
|
645
|
+
const session = findSessionOrFail(ctx, id, req);
|
|
596
646
|
const inputStr = String(input);
|
|
597
647
|
if (inputStr.length > MAX_INPUT_LENGTH) {
|
|
598
648
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Input exceeds maximum length (${MAX_INPUT_LENGTH} bytes)`);
|
|
@@ -642,7 +692,7 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
642
692
|
if (!hex) {
|
|
643
693
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Key not allowed: ${key}`);
|
|
644
694
|
}
|
|
645
|
-
const session = findSessionOrFail(ctx, id);
|
|
695
|
+
const session = findSessionOrFail(ctx, id, req);
|
|
646
696
|
const muxName = session.muxName;
|
|
647
697
|
if (!muxName) {
|
|
648
698
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'No tmux session');
|
|
@@ -669,7 +719,7 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
669
719
|
app.post('/api/sessions/:id/resize', async (req) => {
|
|
670
720
|
const { id } = req.params;
|
|
671
721
|
const { cols, rows, viewportType, force } = parseBody(ResizeSchema, req.body);
|
|
672
|
-
const session = findSessionOrFail(ctx, id);
|
|
722
|
+
const session = findSessionOrFail(ctx, id, req);
|
|
673
723
|
session.resize(cols, rows, { viewportType, force });
|
|
674
724
|
return {};
|
|
675
725
|
});
|
|
@@ -755,7 +805,7 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
755
805
|
}
|
|
756
806
|
app.get('/api/sessions/:id/last-response', async (req) => {
|
|
757
807
|
const { id } = req.params;
|
|
758
|
-
const session = findSessionOrFail(ctx, id);
|
|
808
|
+
const session = findSessionOrFail(ctx, id, req);
|
|
759
809
|
// Codex sessions don't write to ~/.claude/projects — their transcripts
|
|
760
810
|
// live in ~/.codex/sessions/**. Branch to a Codex-specific reader so the
|
|
761
811
|
// response-viewer works for Codex panes too.
|
|
@@ -1182,7 +1232,7 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
1182
1232
|
app.get('/api/sessions/:id/terminal', async (req) => {
|
|
1183
1233
|
const { id } = req.params;
|
|
1184
1234
|
const query = req.query;
|
|
1185
|
-
const session = findSessionOrFail(ctx, id);
|
|
1235
|
+
const session = findSessionOrFail(ctx, id, req);
|
|
1186
1236
|
// `full=1` is the EXPLICIT full-reload signal (COD-47): the browser reloaded
|
|
1187
1237
|
// the page and wants the whole scroll history back, so we capture the ENTIRE
|
|
1188
1238
|
// tmux scrollback and the user gets back history that scrolled off Codeman's
|
|
@@ -1301,7 +1351,7 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
1301
1351
|
app.post('/api/sessions/:id/auto-clear', async (req) => {
|
|
1302
1352
|
const { id } = req.params;
|
|
1303
1353
|
const body = parseBody(AutoClearSchema, req.body, 'Invalid request body');
|
|
1304
|
-
const session = findSessionOrFail(ctx, id);
|
|
1354
|
+
const session = findSessionOrFail(ctx, id, req);
|
|
1305
1355
|
session.setAutoClear(body.enabled, body.threshold);
|
|
1306
1356
|
persistAndBroadcastSession(ctx, session);
|
|
1307
1357
|
return {
|
|
@@ -1318,7 +1368,7 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
1318
1368
|
app.post('/api/sessions/:id/auto-compact', async (req) => {
|
|
1319
1369
|
const { id } = req.params;
|
|
1320
1370
|
const body = parseBody(AutoCompactSchema, req.body, 'Invalid request body');
|
|
1321
|
-
const session = findSessionOrFail(ctx, id);
|
|
1371
|
+
const session = findSessionOrFail(ctx, id, req);
|
|
1322
1372
|
session.setAutoCompact(body.enabled, body.threshold, body.prompt);
|
|
1323
1373
|
persistAndBroadcastSession(ctx, session);
|
|
1324
1374
|
return {
|
|
@@ -1336,7 +1386,7 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
1336
1386
|
app.post('/api/sessions/:id/auto-resume', async (req) => {
|
|
1337
1387
|
const { id } = req.params;
|
|
1338
1388
|
const body = parseBody(AutoResumeSchema, req.body, 'Invalid request body');
|
|
1339
|
-
const session = findSessionOrFail(ctx, id);
|
|
1389
|
+
const session = findSessionOrFail(ctx, id, req);
|
|
1340
1390
|
session.setAutoResume(body.enabled);
|
|
1341
1391
|
persistAndBroadcastSession(ctx, session);
|
|
1342
1392
|
return {
|
|
@@ -1353,7 +1403,7 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
1353
1403
|
app.post('/api/sessions/:id/image-watcher', async (req) => {
|
|
1354
1404
|
const { id } = req.params;
|
|
1355
1405
|
const body = parseBody(ImageWatcherSchema, req.body, 'Invalid request body');
|
|
1356
|
-
const session = findSessionOrFail(ctx, id);
|
|
1406
|
+
const session = findSessionOrFail(ctx, id, req);
|
|
1357
1407
|
if (body.enabled) {
|
|
1358
1408
|
imageWatcher.watchSession(session.id, session.workingDir);
|
|
1359
1409
|
}
|
|
@@ -1374,7 +1424,7 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
1374
1424
|
app.post('/api/sessions/:id/flicker-filter', async (req) => {
|
|
1375
1425
|
const { id } = req.params;
|
|
1376
1426
|
const body = parseBody(FlickerFilterSchema, req.body, 'Invalid request body');
|
|
1377
|
-
const session = findSessionOrFail(ctx, id);
|
|
1427
|
+
const session = findSessionOrFail(ctx, id, req);
|
|
1378
1428
|
session.flickerFilterEnabled = body.enabled;
|
|
1379
1429
|
persistAndBroadcastSession(ctx, session);
|
|
1380
1430
|
return {
|
|
@@ -1389,15 +1439,19 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
1389
1439
|
// ═══════════════════════════════════════════════════════════════
|
|
1390
1440
|
// ========== Quick Run ==========
|
|
1391
1441
|
app.post('/api/run', async (req) => {
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1442
|
+
const runOwner = ownerFor(req);
|
|
1443
|
+
const capMsg = sessionCapacityMessage(ctx.sessions, runOwner);
|
|
1444
|
+
if (capMsg)
|
|
1445
|
+
return createErrorResponse(ApiErrorCode.SESSION_BUSY, capMsg);
|
|
1396
1446
|
const { prompt, workingDir, envOverrides: runEnvOverrides, } = parseBody(QuickRunSchema, req.body, 'Invalid request body');
|
|
1397
1447
|
if (!prompt.trim()) {
|
|
1398
1448
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'prompt is required');
|
|
1399
1449
|
}
|
|
1400
1450
|
const dir = workingDir || process.cwd();
|
|
1451
|
+
// Multi-user: confine a non-admin's one-shot working dir to their space.
|
|
1452
|
+
if (!isWorkingDirAllowed(getAuthUser(req), dir)) {
|
|
1453
|
+
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'workingDir is outside your workspace');
|
|
1454
|
+
}
|
|
1401
1455
|
// Validate workingDir exists and is a directory
|
|
1402
1456
|
if (workingDir) {
|
|
1403
1457
|
try {
|
|
@@ -1410,7 +1464,17 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
1410
1464
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir does not exist');
|
|
1411
1465
|
}
|
|
1412
1466
|
}
|
|
1413
|
-
|
|
1467
|
+
// Section 6.3: the one-shot spawn path (runPrompt/buildPromptArgs) respects the
|
|
1468
|
+
// session's claudeMode, so resolve it for the owner (bypass -> auto for non-granted).
|
|
1469
|
+
const runClaudeModeConfig = await ctx.getClaudeModeConfig();
|
|
1470
|
+
const runClaudeMode = await resolveClaudeModeForUsername(runClaudeModeConfig.claudeMode, runOwner);
|
|
1471
|
+
const session = new Session({
|
|
1472
|
+
workingDir: dir,
|
|
1473
|
+
envOverrides: runEnvOverrides,
|
|
1474
|
+
claudeMode: runClaudeMode,
|
|
1475
|
+
allowedTools: runClaudeModeConfig.allowedTools,
|
|
1476
|
+
owner: runOwner,
|
|
1477
|
+
});
|
|
1414
1478
|
ctx.addSession(session);
|
|
1415
1479
|
ctx.store.incrementSessionsCreated();
|
|
1416
1480
|
ctx.persistSessionState(session);
|
|
@@ -1437,11 +1501,16 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
1437
1501
|
});
|
|
1438
1502
|
// ========== Quick Start ==========
|
|
1439
1503
|
app.post('/api/quick-start', async (req) => {
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1504
|
+
const owner = ownerFor(req);
|
|
1505
|
+
const capMsg = sessionCapacityMessage(ctx.sessions, owner);
|
|
1506
|
+
if (capMsg)
|
|
1507
|
+
return createErrorResponse(ApiErrorCode.SESSION_BUSY, capMsg);
|
|
1444
1508
|
const { caseName = 'testcase', sessionName, mode = 'claude', openCodeConfig, codexConfig, geminiConfig, envOverrides, effort, } = parseBody(QuickStartSchema, req.body);
|
|
1509
|
+
// Multi-user: shell mode is arbitrary host-account execution, gated by the grant.
|
|
1510
|
+
// Resolve the owner's grant from the store so a GRANTED regular user is not wrongly denied.
|
|
1511
|
+
if (mode === 'shell' && !(await canUsernameRunPrivilegedCommands(owner))) {
|
|
1512
|
+
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Shell sessions require the can-bypass-permissions grant');
|
|
1513
|
+
}
|
|
1445
1514
|
// Resolve the remote case FIRST — the CLI executes on the REMOTE host over ssh,
|
|
1446
1515
|
// so the LOCAL availability gates below (isCodexAvailable() etc.) don't apply and
|
|
1447
1516
|
// would wrongly reject a machine that hasn't got the CLI installed locally.
|
|
@@ -1449,11 +1518,16 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
1449
1518
|
let docker = undefined;
|
|
1450
1519
|
let dockerResumeId;
|
|
1451
1520
|
let casePath = null;
|
|
1521
|
+
// Security: fold ownership INTO the match (don't early-return) so a NON-OWNED
|
|
1522
|
+
// same-named remote/docker case is skipped and control falls through to the caller's
|
|
1523
|
+
// own LOCAL case — remote/docker names are globally unique but local names are
|
|
1524
|
+
// per-user, so a name collision must not shadow the caller's own case. canAccessOwned
|
|
1525
|
+
// is allow-all for admins/single-user, so flag-OFF stays byte-identical.
|
|
1452
1526
|
const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
|
|
1453
|
-
const remoteCase = remoteCases.find((item) => item.name === caseName);
|
|
1527
|
+
const remoteCase = remoteCases.find((item) => item.name === caseName && canAccessOwned(getAuthUser(req), item.owner));
|
|
1454
1528
|
const dockerCase = remoteCase
|
|
1455
1529
|
? undefined
|
|
1456
|
-
: (await readDockerCases(CODEMAN_CONFIG_DIR)).find((item) => item.name === caseName);
|
|
1530
|
+
: (await readDockerCases(CODEMAN_CONFIG_DIR)).find((item) => item.name === caseName && canAccessOwned(getAuthUser(req), item.owner));
|
|
1457
1531
|
if (remoteCase) {
|
|
1458
1532
|
const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === remoteCase.hostId);
|
|
1459
1533
|
if (!host)
|
|
@@ -1483,7 +1557,7 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
1483
1557
|
// Docker case: the CLI executes INSIDE a container via local tmux + `docker
|
|
1484
1558
|
// exec`, so the LOCAL availability gates below don't apply. Mirror the remote
|
|
1485
1559
|
// branch's rejection of per-session config that would not cross into the
|
|
1486
|
-
// container (it would silently no-op).
|
|
1560
|
+
// container (it would silently no-op). (Ownership is enforced in the .find above.)
|
|
1487
1561
|
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
|
|
1488
1562
|
if (!host)
|
|
1489
1563
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
|
|
@@ -1561,7 +1635,11 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
1561
1635
|
catch {
|
|
1562
1636
|
// File missing or unparseable — treat as empty registry
|
|
1563
1637
|
}
|
|
1564
|
-
|
|
1638
|
+
// Multi-user: the linked-cases registry is ownerless/global, so only admins may
|
|
1639
|
+
// resolve a name to an arbitrary linked path. A non-admin resolves inside their
|
|
1640
|
+
// OWN case space only (single-user: isAdmin true, so linked cases still honoured).
|
|
1641
|
+
const linked = isAdmin(req) ? linkedCases[caseName] : undefined;
|
|
1642
|
+
casePath = linked || validatePathWithinBase(caseName, resolveCasesDir(getAuthUser(req)));
|
|
1565
1643
|
if (!casePath) {
|
|
1566
1644
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
|
|
1567
1645
|
}
|
|
@@ -1569,6 +1647,14 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
1569
1647
|
// By this point casePath is guaranteed non-null: for remote cases it was set from remoteCase.remotePath,
|
|
1570
1648
|
// for local cases the !casePath guard above returned early. TypeScript can't narrow across the if/else.
|
|
1571
1649
|
const resolvedCasePath = casePath;
|
|
1650
|
+
// Multi-user linchpin (section 6.2): confine the resolved workingDir to the caller's
|
|
1651
|
+
// own case space BEFORE any mkdir/scaffold below creates or mutates it. Applies to
|
|
1652
|
+
// LOCAL and DOCKER cases (docker.hostWorkspacePath is a real host dir the file routes
|
|
1653
|
+
// trust); skipped for REMOTE, whose path is an ssh path that would spuriously fail
|
|
1654
|
+
// realpath confinement. No-op for admins / single-user mode.
|
|
1655
|
+
if (!remote && !isWorkingDirAllowed(getAuthUser(req), resolvedCasePath)) {
|
|
1656
|
+
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'case path is outside your workspace');
|
|
1657
|
+
}
|
|
1572
1658
|
// Create case folder and CLAUDE.md if it doesn't exist (only for non-linked, non-remote,
|
|
1573
1659
|
// non-docker cases — docker workspaces are scaffolded in their own block below)
|
|
1574
1660
|
if (!remote && !docker && !existsSync(resolvedCasePath)) {
|
|
@@ -1642,6 +1728,9 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
1642
1728
|
? qsModelConfig?.defaultModel || undefined
|
|
1643
1729
|
: undefined;
|
|
1644
1730
|
const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
|
|
1731
|
+
const qsEffectiveClaudeMode = await resolveClaudeModeForUsername(qsClaudeModeConfig.claudeMode, owner);
|
|
1732
|
+
// Section 6.3: clamp Codex/Gemini bypass switches for a non-granted owner (no-op single-user/granted).
|
|
1733
|
+
const { codexConfig: qsGatedCodexConfig, geminiConfig: qsGatedGeminiConfig } = await clampExternalCliBypassForOwner(owner, codexConfig, geminiConfig);
|
|
1645
1734
|
const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig();
|
|
1646
1735
|
const session = new Session({
|
|
1647
1736
|
workingDir: resolvedCasePath,
|
|
@@ -1651,11 +1740,12 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
1651
1740
|
mode: mode,
|
|
1652
1741
|
niceConfig: niceConfig,
|
|
1653
1742
|
model: qsModel,
|
|
1654
|
-
claudeMode:
|
|
1743
|
+
claudeMode: qsEffectiveClaudeMode,
|
|
1655
1744
|
allowedTools: qsClaudeModeConfig.allowedTools,
|
|
1745
|
+
owner,
|
|
1656
1746
|
openCodeConfig: mode === 'opencode' ? openCodeConfig : undefined,
|
|
1657
|
-
codexConfig: mode === 'codex' ?
|
|
1658
|
-
geminiConfig: mode === 'gemini' ?
|
|
1747
|
+
codexConfig: mode === 'codex' ? qsGatedCodexConfig : undefined,
|
|
1748
|
+
geminiConfig: mode === 'gemini' ? qsGatedGeminiConfig : undefined,
|
|
1659
1749
|
envOverrides,
|
|
1660
1750
|
effort,
|
|
1661
1751
|
remote,
|
|
@@ -1993,6 +2083,12 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
1993
2083
|
const query = req.query;
|
|
1994
2084
|
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
|
|
1995
2085
|
const headBuf = Buffer.alloc(16384);
|
|
2086
|
+
// Multi-user: this scans the host-wide ~/.claude/projects tree, so a non-admin
|
|
2087
|
+
// must only see history whose decoded workingDir is inside their own case space.
|
|
2088
|
+
// Do NOT trust the caller-supplied projectKey — confine on the decoded path.
|
|
2089
|
+
// No-op for admins / single-user mode.
|
|
2090
|
+
const user = getAuthUser(req);
|
|
2091
|
+
const scopeHistory = isMultiUserMode() && user.role !== 'admin';
|
|
1996
2092
|
// Single-folder drill-down: when projectKey is provided, scan only that
|
|
1997
2093
|
// directory, bypass the 50-cap, and honor offset/limit pagination.
|
|
1998
2094
|
if (query.projectKey) {
|
|
@@ -2003,12 +2099,15 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
2003
2099
|
const offset = Math.max(0, parseInt(query.offset || '0', 10) || 0);
|
|
2004
2100
|
const limit = Math.min(100, Math.max(1, parseInt(query.limit || '20', 10) || 20));
|
|
2005
2101
|
const projPath = join(projectsDir, query.projectKey);
|
|
2006
|
-
|
|
2102
|
+
let all = await scanProjectDir(projPath, query.projectKey, headBuf);
|
|
2103
|
+
// Confine to the caller's workspace (a projectKey maps to a single foreign cwd).
|
|
2104
|
+
if (scopeHistory)
|
|
2105
|
+
all = all.filter((r) => isWorkingDirAllowed(user, r.workingDir));
|
|
2007
2106
|
all.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
|
|
2008
2107
|
return { sessions: all.slice(offset, offset + limit), total: all.length };
|
|
2009
2108
|
}
|
|
2010
2109
|
// Global overview: scan all projects, return up to 50 most-recent sessions.
|
|
2011
|
-
|
|
2110
|
+
let results = [];
|
|
2012
2111
|
try {
|
|
2013
2112
|
const projectDirs = await fs.readdir(projectsDir);
|
|
2014
2113
|
for (const projDir of projectDirs) {
|
|
@@ -2020,6 +2119,9 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
2020
2119
|
catch {
|
|
2021
2120
|
// Projects dir may not exist
|
|
2022
2121
|
}
|
|
2122
|
+
// Multi-user: drop rows outside the non-admin caller's own case space.
|
|
2123
|
+
if (scopeHistory)
|
|
2124
|
+
results = results.filter((r) => isWorkingDirAllowed(user, r.workingDir));
|
|
2023
2125
|
results.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
|
|
2024
2126
|
return { sessions: results.slice(0, 50) };
|
|
2025
2127
|
});
|
|
@@ -2116,7 +2218,32 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
2116
2218
|
catch {
|
|
2117
2219
|
// Mux stats are optional.
|
|
2118
2220
|
}
|
|
2119
|
-
|
|
2221
|
+
// Multi-user: a non-admin only sees their own sessions; host-wide transcript
|
|
2222
|
+
// history (not tied to an owned session) is admin-only.
|
|
2223
|
+
let sLive = live;
|
|
2224
|
+
let sPersisted = persisted;
|
|
2225
|
+
let sLifecycle = lifecycle;
|
|
2226
|
+
let sHistory = history;
|
|
2227
|
+
const uUser = getAuthUser(req);
|
|
2228
|
+
if (isMultiUserMode() && uUser.role !== 'admin') {
|
|
2229
|
+
const ownedLive = new Set([...ctx.sessions.values()].filter((s) => canAccessOwned(uUser, s.owner)).map((s) => s.id));
|
|
2230
|
+
const stored = ctx.store.getState().sessions;
|
|
2231
|
+
const ownedPersisted = new Set(Object.values(stored)
|
|
2232
|
+
.filter((p) => canAccessOwned(uUser, p.owner))
|
|
2233
|
+
.map((p) => p.id));
|
|
2234
|
+
const isOwned = (id) => ownedLive.has(id) || ownedPersisted.has(id);
|
|
2235
|
+
sLive = live.filter((l) => isOwned(l.id));
|
|
2236
|
+
sPersisted = persisted.filter((p) => isOwned(p.id));
|
|
2237
|
+
sLifecycle = lifecycle.filter((e) => isOwned(e.sessionId));
|
|
2238
|
+
sHistory = [];
|
|
2239
|
+
}
|
|
2240
|
+
const merged = mergeUnifiedSessions({
|
|
2241
|
+
live: sLive,
|
|
2242
|
+
persisted: sPersisted,
|
|
2243
|
+
lifecycle: sLifecycle,
|
|
2244
|
+
history: sHistory,
|
|
2245
|
+
mux,
|
|
2246
|
+
});
|
|
2120
2247
|
const offset = query.offset !== undefined ? parseInt(query.offset, 10) : undefined;
|
|
2121
2248
|
const limit = query.limit !== undefined ? parseInt(query.limit, 10) : undefined;
|
|
2122
2249
|
return filterAndPaginate(merged, {
|
|
@@ -2173,7 +2300,7 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
2173
2300
|
reply.header('Retry-After', '60');
|
|
2174
2301
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Rate limit exceeded (30 uploads/min per session)');
|
|
2175
2302
|
}
|
|
2176
|
-
const session = findSessionOrFail(ctx, id);
|
|
2303
|
+
const session = findSessionOrFail(ctx, id, req);
|
|
2177
2304
|
if (!req.isMultipart()) {
|
|
2178
2305
|
reply.code(400);
|
|
2179
2306
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Expected multipart/form-data');
|