aicodeman 1.4.1 → 1.5.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/README.md +30 -8
- package/README.zh-CN.md +289 -92
- 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/docker-export.d.ts +13 -0
- package/dist/docker-export.d.ts.map +1 -1
- package/dist/docker-export.js +55 -8
- package/dist/docker-export.js.map +1 -1
- package/dist/docker-hosts.d.ts +36 -5
- package/dist/docker-hosts.d.ts.map +1 -1
- package/dist/docker-hosts.js +90 -16
- package/dist/docker-hosts.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 +55 -12
- 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 +9 -2
- package/dist/types/session.d.ts.map +1 -1
- package/dist/types/session.js +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 → app.1ccca158.js} +1 -1
- package/dist/web/public/app.1ccca158.js.br +0 -0
- package/dist/web/public/app.1ccca158.js.gz +0 -0
- package/dist/web/public/{constants.48da7f03.js → constants.ad3a8031.js} +4 -0
- package/dist/web/public/constants.ad3a8031.js.br +0 -0
- package/dist/web/public/{constants.48da7f03.js.gz → constants.ad3a8031.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 +11 -9
- 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.597136a6.css +1 -0
- package/dist/web/public/mobile.597136a6.css.br +0 -0
- package/dist/web/public/mobile.597136a6.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.e5d4f9b5.js +60 -0
- package/dist/web/public/session-ui.e5d4f9b5.js.br +0 -0
- package/dist/web/public/session-ui.e5d4f9b5.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 +2 -2
- package/dist/web/routes/case-routes.d.ts.map +1 -1
- package/dist/web/routes/case-routes.js +193 -79
- 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/hook-event-routes.d.ts.map +1 -1
- package/dist/web/routes/hook-event-routes.js +9 -0
- package/dist/web/routes/hook-event-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 +219 -64
- 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/schemas.d.ts +1 -0
- package/dist/web/schemas.d.ts.map +1 -1
- package/dist/web/schemas.js +11 -0
- package/dist/web/schemas.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 +9 -0
- package/dist/web/sse-events.d.ts.map +1 -1
- package/dist/web/sse-events.js +10 -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/app.6c4b9893.js.br +0 -0
- package/dist/web/public/app.6c4b9893.js.gz +0 -0
- package/dist/web/public/constants.48da7f03.js.br +0 -0
- package/dist/web/public/mobile.0644e3ac.css +0 -1
- package/dist/web/public/mobile.0644e3ac.css.br +0 -0
- package/dist/web/public/mobile.0644e3ac.css.gz +0 -0
- package/dist/web/public/session-ui.4851c61a.js +0 -58
- package/dist/web/public/session-ui.4851c61a.js.br +0 -0
- package/dist/web/public/session-ui.4851c61a.js.gz +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,13 +23,12 @@ 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';
|
|
28
29
|
import { dataPath, getDataDir } from '../../config/instance.js';
|
|
29
30
|
import { checkRemoteTmuxAvailable, readRemoteCases, readRemoteHosts, toSessionRemote } from '../../remote-hosts.js';
|
|
30
|
-
import { checkDockerAvailable, checkDockerTmuxAvailable, ensureAgentBaseImage, DEFAULT_AGENT_IMAGE, readDockerCases, readDockerHosts, toSessionDocker, } from '../../docker-hosts.js';
|
|
31
|
+
import { checkDockerAvailable, checkDockerConfigDrift, checkDockerTmuxAvailable, ensureAgentBaseImage, DEFAULT_AGENT_IMAGE, persistDockerCaseClaudeSessionId, readDockerCases, readDockerHosts, toSessionDocker, } from '../../docker-hosts.js';
|
|
31
32
|
import { LRUMap } from '../../utils/lru-map.js';
|
|
32
33
|
// Path to linked-cases registry (same file used by case-routes resolveCasePath)
|
|
33
34
|
const LINKED_CASES_FILE = dataPath('linked-cases.json');
|
|
@@ -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.
|
|
@@ -771,6 +821,10 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
771
821
|
const activeId = await resolveActiveClaudeSessionIdFromHistory(session, projectsDir);
|
|
772
822
|
if (activeId && activeId !== session.claudeSessionId) {
|
|
773
823
|
session.adoptClaudeSessionId(activeId);
|
|
824
|
+
// Docker sessions: keep the case's resume seed following the live conversation.
|
|
825
|
+
if (session.docker) {
|
|
826
|
+
void persistDockerCaseClaudeSessionId(CODEMAN_CONFIG_DIR, session.docker.containerName, activeId).catch(() => { });
|
|
827
|
+
}
|
|
774
828
|
}
|
|
775
829
|
// The Claude conversation ID (used as JSONL filename)
|
|
776
830
|
const claudeSessionId = session.claudeSessionId || session.id;
|
|
@@ -1182,7 +1236,7 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
1182
1236
|
app.get('/api/sessions/:id/terminal', async (req) => {
|
|
1183
1237
|
const { id } = req.params;
|
|
1184
1238
|
const query = req.query;
|
|
1185
|
-
const session = findSessionOrFail(ctx, id);
|
|
1239
|
+
const session = findSessionOrFail(ctx, id, req);
|
|
1186
1240
|
// `full=1` is the EXPLICIT full-reload signal (COD-47): the browser reloaded
|
|
1187
1241
|
// the page and wants the whole scroll history back, so we capture the ENTIRE
|
|
1188
1242
|
// tmux scrollback and the user gets back history that scrolled off Codeman's
|
|
@@ -1301,7 +1355,7 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
1301
1355
|
app.post('/api/sessions/:id/auto-clear', async (req) => {
|
|
1302
1356
|
const { id } = req.params;
|
|
1303
1357
|
const body = parseBody(AutoClearSchema, req.body, 'Invalid request body');
|
|
1304
|
-
const session = findSessionOrFail(ctx, id);
|
|
1358
|
+
const session = findSessionOrFail(ctx, id, req);
|
|
1305
1359
|
session.setAutoClear(body.enabled, body.threshold);
|
|
1306
1360
|
persistAndBroadcastSession(ctx, session);
|
|
1307
1361
|
return {
|
|
@@ -1318,7 +1372,7 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
1318
1372
|
app.post('/api/sessions/:id/auto-compact', async (req) => {
|
|
1319
1373
|
const { id } = req.params;
|
|
1320
1374
|
const body = parseBody(AutoCompactSchema, req.body, 'Invalid request body');
|
|
1321
|
-
const session = findSessionOrFail(ctx, id);
|
|
1375
|
+
const session = findSessionOrFail(ctx, id, req);
|
|
1322
1376
|
session.setAutoCompact(body.enabled, body.threshold, body.prompt);
|
|
1323
1377
|
persistAndBroadcastSession(ctx, session);
|
|
1324
1378
|
return {
|
|
@@ -1336,7 +1390,7 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
1336
1390
|
app.post('/api/sessions/:id/auto-resume', async (req) => {
|
|
1337
1391
|
const { id } = req.params;
|
|
1338
1392
|
const body = parseBody(AutoResumeSchema, req.body, 'Invalid request body');
|
|
1339
|
-
const session = findSessionOrFail(ctx, id);
|
|
1393
|
+
const session = findSessionOrFail(ctx, id, req);
|
|
1340
1394
|
session.setAutoResume(body.enabled);
|
|
1341
1395
|
persistAndBroadcastSession(ctx, session);
|
|
1342
1396
|
return {
|
|
@@ -1353,7 +1407,7 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
1353
1407
|
app.post('/api/sessions/:id/image-watcher', async (req) => {
|
|
1354
1408
|
const { id } = req.params;
|
|
1355
1409
|
const body = parseBody(ImageWatcherSchema, req.body, 'Invalid request body');
|
|
1356
|
-
const session = findSessionOrFail(ctx, id);
|
|
1410
|
+
const session = findSessionOrFail(ctx, id, req);
|
|
1357
1411
|
if (body.enabled) {
|
|
1358
1412
|
imageWatcher.watchSession(session.id, session.workingDir);
|
|
1359
1413
|
}
|
|
@@ -1374,7 +1428,7 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
1374
1428
|
app.post('/api/sessions/:id/flicker-filter', async (req) => {
|
|
1375
1429
|
const { id } = req.params;
|
|
1376
1430
|
const body = parseBody(FlickerFilterSchema, req.body, 'Invalid request body');
|
|
1377
|
-
const session = findSessionOrFail(ctx, id);
|
|
1431
|
+
const session = findSessionOrFail(ctx, id, req);
|
|
1378
1432
|
session.flickerFilterEnabled = body.enabled;
|
|
1379
1433
|
persistAndBroadcastSession(ctx, session);
|
|
1380
1434
|
return {
|
|
@@ -1389,15 +1443,19 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
1389
1443
|
// ═══════════════════════════════════════════════════════════════
|
|
1390
1444
|
// ========== Quick Run ==========
|
|
1391
1445
|
app.post('/api/run', async (req) => {
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1446
|
+
const runOwner = ownerFor(req);
|
|
1447
|
+
const capMsg = sessionCapacityMessage(ctx.sessions, runOwner);
|
|
1448
|
+
if (capMsg)
|
|
1449
|
+
return createErrorResponse(ApiErrorCode.SESSION_BUSY, capMsg);
|
|
1396
1450
|
const { prompt, workingDir, envOverrides: runEnvOverrides, } = parseBody(QuickRunSchema, req.body, 'Invalid request body');
|
|
1397
1451
|
if (!prompt.trim()) {
|
|
1398
1452
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'prompt is required');
|
|
1399
1453
|
}
|
|
1400
1454
|
const dir = workingDir || process.cwd();
|
|
1455
|
+
// Multi-user: confine a non-admin's one-shot working dir to their space.
|
|
1456
|
+
if (!isWorkingDirAllowed(getAuthUser(req), dir)) {
|
|
1457
|
+
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'workingDir is outside your workspace');
|
|
1458
|
+
}
|
|
1401
1459
|
// Validate workingDir exists and is a directory
|
|
1402
1460
|
if (workingDir) {
|
|
1403
1461
|
try {
|
|
@@ -1410,7 +1468,17 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
1410
1468
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir does not exist');
|
|
1411
1469
|
}
|
|
1412
1470
|
}
|
|
1413
|
-
|
|
1471
|
+
// Section 6.3: the one-shot spawn path (runPrompt/buildPromptArgs) respects the
|
|
1472
|
+
// session's claudeMode, so resolve it for the owner (bypass -> auto for non-granted).
|
|
1473
|
+
const runClaudeModeConfig = await ctx.getClaudeModeConfig();
|
|
1474
|
+
const runClaudeMode = await resolveClaudeModeForUsername(runClaudeModeConfig.claudeMode, runOwner);
|
|
1475
|
+
const session = new Session({
|
|
1476
|
+
workingDir: dir,
|
|
1477
|
+
envOverrides: runEnvOverrides,
|
|
1478
|
+
claudeMode: runClaudeMode,
|
|
1479
|
+
allowedTools: runClaudeModeConfig.allowedTools,
|
|
1480
|
+
owner: runOwner,
|
|
1481
|
+
});
|
|
1414
1482
|
ctx.addSession(session);
|
|
1415
1483
|
ctx.store.incrementSessionsCreated();
|
|
1416
1484
|
ctx.persistSessionState(session);
|
|
@@ -1437,11 +1505,16 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
1437
1505
|
});
|
|
1438
1506
|
// ========== Quick Start ==========
|
|
1439
1507
|
app.post('/api/quick-start', async (req) => {
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1508
|
+
const owner = ownerFor(req);
|
|
1509
|
+
const capMsg = sessionCapacityMessage(ctx.sessions, owner);
|
|
1510
|
+
if (capMsg)
|
|
1511
|
+
return createErrorResponse(ApiErrorCode.SESSION_BUSY, capMsg);
|
|
1512
|
+
const { caseName = 'testcase', sessionName, mode = 'claude', modelOverride, openCodeConfig, codexConfig, geminiConfig, envOverrides, effort, } = parseBody(QuickStartSchema, req.body);
|
|
1513
|
+
// Multi-user: shell mode is arbitrary host-account execution, gated by the grant.
|
|
1514
|
+
// Resolve the owner's grant from the store so a GRANTED regular user is not wrongly denied.
|
|
1515
|
+
if (mode === 'shell' && !(await canUsernameRunPrivilegedCommands(owner))) {
|
|
1516
|
+
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Shell sessions require the can-bypass-permissions grant');
|
|
1443
1517
|
}
|
|
1444
|
-
const { caseName = 'testcase', sessionName, mode = 'claude', openCodeConfig, codexConfig, geminiConfig, envOverrides, effort, } = parseBody(QuickStartSchema, req.body);
|
|
1445
1518
|
// Resolve the remote case FIRST — the CLI executes on the REMOTE host over ssh,
|
|
1446
1519
|
// so the LOCAL availability gates below (isCodexAvailable() etc.) don't apply and
|
|
1447
1520
|
// would wrongly reject a machine that hasn't got the CLI installed locally.
|
|
@@ -1449,11 +1522,16 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
1449
1522
|
let docker = undefined;
|
|
1450
1523
|
let dockerResumeId;
|
|
1451
1524
|
let casePath = null;
|
|
1525
|
+
// Security: fold ownership INTO the match (don't early-return) so a NON-OWNED
|
|
1526
|
+
// same-named remote/docker case is skipped and control falls through to the caller's
|
|
1527
|
+
// own LOCAL case — remote/docker names are globally unique but local names are
|
|
1528
|
+
// per-user, so a name collision must not shadow the caller's own case. canAccessOwned
|
|
1529
|
+
// is allow-all for admins/single-user, so flag-OFF stays byte-identical.
|
|
1452
1530
|
const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
|
|
1453
|
-
const remoteCase = remoteCases.find((item) => item.name === caseName);
|
|
1531
|
+
const remoteCase = remoteCases.find((item) => item.name === caseName && canAccessOwned(getAuthUser(req), item.owner));
|
|
1454
1532
|
const dockerCase = remoteCase
|
|
1455
1533
|
? undefined
|
|
1456
|
-
: (await readDockerCases(CODEMAN_CONFIG_DIR)).find((item) => item.name === caseName);
|
|
1534
|
+
: (await readDockerCases(CODEMAN_CONFIG_DIR)).find((item) => item.name === caseName && canAccessOwned(getAuthUser(req), item.owner));
|
|
1457
1535
|
if (remoteCase) {
|
|
1458
1536
|
const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === remoteCase.hostId);
|
|
1459
1537
|
if (!host)
|
|
@@ -1464,10 +1542,11 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
1464
1542
|
// remote command/env customization goes through the per-host command override.
|
|
1465
1543
|
if ((envOverrides && Object.keys(envOverrides).length > 0) ||
|
|
1466
1544
|
effort ||
|
|
1545
|
+
modelOverride !== undefined ||
|
|
1467
1546
|
codexConfig ||
|
|
1468
1547
|
geminiConfig ||
|
|
1469
1548
|
openCodeConfig) {
|
|
1470
|
-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'envOverrides, effort, and per-CLI config are not supported for remote cases (they do not cross ssh). Configure the remote command via the host command override instead.');
|
|
1549
|
+
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'envOverrides, effort, modelOverride, and per-CLI config are not supported for remote cases (they do not cross ssh). Configure the remote command via the host command override instead.');
|
|
1471
1550
|
}
|
|
1472
1551
|
// tmux is a hard prerequisite on the remote host (the agent runs inside a remote
|
|
1473
1552
|
// tmux server so it survives ssh drops). Probe before spawning so a missing tmux
|
|
@@ -1483,7 +1562,7 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
1483
1562
|
// Docker case: the CLI executes INSIDE a container via local tmux + `docker
|
|
1484
1563
|
// exec`, so the LOCAL availability gates below don't apply. Mirror the remote
|
|
1485
1564
|
// branch's rejection of per-session config that would not cross into the
|
|
1486
|
-
// container (it would silently no-op).
|
|
1565
|
+
// container (it would silently no-op). (Ownership is enforced in the .find above.)
|
|
1487
1566
|
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
|
|
1488
1567
|
if (!host)
|
|
1489
1568
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
|
|
@@ -1502,7 +1581,7 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
1502
1581
|
// Ensure the base image exists, auto-building the default image on first use so
|
|
1503
1582
|
// it is never a blocker. Dedup'd with any build kicked off at case-create, so
|
|
1504
1583
|
// this awaits the SAME in-flight build rather than starting a second one.
|
|
1505
|
-
const ensured = await ensureAgentBaseImage(sessionDocker
|
|
1584
|
+
const ensured = await ensureAgentBaseImage(sessionDocker, sessionDocker.image, {
|
|
1506
1585
|
onProgress: (line) => ctx.broadcast(SseEvent.DockerImageBuildProgress, { name: dockerCase.name, line }),
|
|
1507
1586
|
});
|
|
1508
1587
|
if (!ensured.ok) {
|
|
@@ -1520,6 +1599,15 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
1520
1599
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, tmuxCheck.error || 'base image is missing tmux');
|
|
1521
1600
|
}
|
|
1522
1601
|
}
|
|
1602
|
+
// Config drift (docs/docker-cases-plan.md §4): the desired create-config no
|
|
1603
|
+
// longer matches the existing container's codeman.confighash label. Refuse to
|
|
1604
|
+
// silently launch into the stale container — the frontend confirms a recreate
|
|
1605
|
+
// (POST /api/docker-cases/:name/recreate; workspace + transcripts ride bind
|
|
1606
|
+
// mounts and the conversation resumes), or the user reverts the host edit.
|
|
1607
|
+
const drift = await checkDockerConfigDrift(sessionDocker);
|
|
1608
|
+
if (drift.exists && drift.drifted) {
|
|
1609
|
+
return createErrorResponse(ApiErrorCode.CONFLICT, `Container config for case "${dockerCase.name}" changed since the container was created. Recreate the container to apply it (workspace and conversation survive), or revert the docker host edit.`);
|
|
1610
|
+
}
|
|
1523
1611
|
casePath = dockerCase.hostWorkspacePath; // a REAL host dir (bind-mounted into the container)
|
|
1524
1612
|
docker = sessionDocker;
|
|
1525
1613
|
// Seed resume so a relaunch resumes the case's last conversation from the
|
|
@@ -1561,7 +1649,11 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
1561
1649
|
catch {
|
|
1562
1650
|
// File missing or unparseable — treat as empty registry
|
|
1563
1651
|
}
|
|
1564
|
-
|
|
1652
|
+
// Multi-user: the linked-cases registry is ownerless/global, so only admins may
|
|
1653
|
+
// resolve a name to an arbitrary linked path. A non-admin resolves inside their
|
|
1654
|
+
// OWN case space only (single-user: isAdmin true, so linked cases still honoured).
|
|
1655
|
+
const linked = isAdmin(req) ? linkedCases[caseName] : undefined;
|
|
1656
|
+
casePath = linked || validatePathWithinBase(caseName, resolveCasesDir(getAuthUser(req)));
|
|
1565
1657
|
if (!casePath) {
|
|
1566
1658
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
|
|
1567
1659
|
}
|
|
@@ -1569,6 +1661,14 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
1569
1661
|
// By this point casePath is guaranteed non-null: for remote cases it was set from remoteCase.remotePath,
|
|
1570
1662
|
// for local cases the !casePath guard above returned early. TypeScript can't narrow across the if/else.
|
|
1571
1663
|
const resolvedCasePath = casePath;
|
|
1664
|
+
// Multi-user linchpin (section 6.2): confine the resolved workingDir to the caller's
|
|
1665
|
+
// own case space BEFORE any mkdir/scaffold below creates or mutates it. Applies to
|
|
1666
|
+
// LOCAL and DOCKER cases (docker.hostWorkspacePath is a real host dir the file routes
|
|
1667
|
+
// trust); skipped for REMOTE, whose path is an ssh path that would spuriously fail
|
|
1668
|
+
// realpath confinement. No-op for admins / single-user mode.
|
|
1669
|
+
if (!remote && !isWorkingDirAllowed(getAuthUser(req), resolvedCasePath)) {
|
|
1670
|
+
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'case path is outside your workspace');
|
|
1671
|
+
}
|
|
1572
1672
|
// Create case folder and CLAUDE.md if it doesn't exist (only for non-linked, non-remote,
|
|
1573
1673
|
// non-docker cases — docker workspaces are scaffolded in their own block below)
|
|
1574
1674
|
if (!remote && !docker && !existsSync(resolvedCasePath)) {
|
|
@@ -1618,6 +1718,12 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
1618
1718
|
/* non-fatal — the session still runs, hooks may be degraded */
|
|
1619
1719
|
}
|
|
1620
1720
|
}
|
|
1721
|
+
// Model override → <case>/.claude/settings.local.json (claude-mode; local AND
|
|
1722
|
+
// docker — the docker workspace is a real host dir, so the settings file crosses
|
|
1723
|
+
// the bind mount and the in-container claude reads it). Remote was rejected above.
|
|
1724
|
+
if (mode === 'claude' && modelOverride !== undefined) {
|
|
1725
|
+
await updateCaseModel(resolvedCasePath, modelOverride || null);
|
|
1726
|
+
}
|
|
1621
1727
|
// Strip stale disk entries for keys this request is actively setting (Claude only —
|
|
1622
1728
|
// see POST /api/sessions for full rationale).
|
|
1623
1729
|
if (mode !== 'opencode' &&
|
|
@@ -1642,6 +1748,9 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
1642
1748
|
? qsModelConfig?.defaultModel || undefined
|
|
1643
1749
|
: undefined;
|
|
1644
1750
|
const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
|
|
1751
|
+
const qsEffectiveClaudeMode = await resolveClaudeModeForUsername(qsClaudeModeConfig.claudeMode, owner);
|
|
1752
|
+
// Section 6.3: clamp Codex/Gemini bypass switches for a non-granted owner (no-op single-user/granted).
|
|
1753
|
+
const { codexConfig: qsGatedCodexConfig, geminiConfig: qsGatedGeminiConfig } = await clampExternalCliBypassForOwner(owner, codexConfig, geminiConfig);
|
|
1645
1754
|
const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig();
|
|
1646
1755
|
const session = new Session({
|
|
1647
1756
|
workingDir: resolvedCasePath,
|
|
@@ -1651,11 +1760,12 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
1651
1760
|
mode: mode,
|
|
1652
1761
|
niceConfig: niceConfig,
|
|
1653
1762
|
model: qsModel,
|
|
1654
|
-
claudeMode:
|
|
1763
|
+
claudeMode: qsEffectiveClaudeMode,
|
|
1655
1764
|
allowedTools: qsClaudeModeConfig.allowedTools,
|
|
1765
|
+
owner,
|
|
1656
1766
|
openCodeConfig: mode === 'opencode' ? openCodeConfig : undefined,
|
|
1657
|
-
codexConfig: mode === 'codex' ?
|
|
1658
|
-
geminiConfig: mode === 'gemini' ?
|
|
1767
|
+
codexConfig: mode === 'codex' ? qsGatedCodexConfig : undefined,
|
|
1768
|
+
geminiConfig: mode === 'gemini' ? qsGatedGeminiConfig : undefined,
|
|
1659
1769
|
envOverrides,
|
|
1660
1770
|
effort,
|
|
1661
1771
|
remote,
|
|
@@ -1707,6 +1817,14 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
1707
1817
|
ctx.broadcast(SseEvent.SessionInteractive, { id: session.id, mode });
|
|
1708
1818
|
}
|
|
1709
1819
|
ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) });
|
|
1820
|
+
// Docker + claude: the pane command pins the conversation id (--session-id /
|
|
1821
|
+
// --resume, claudeDockerPaneCommand), so persist it as the case's resume seed
|
|
1822
|
+
// NOW — a later container stop/reboot relaunch resumes this conversation even
|
|
1823
|
+
// if no in-container hook ever reaches the host (loopback bind, no bridge
|
|
1824
|
+
// listener). Hook/last-response adoption updates it again after /clear.
|
|
1825
|
+
if (docker && mode === 'claude') {
|
|
1826
|
+
void persistDockerCaseClaudeSessionId(CODEMAN_CONFIG_DIR, docker.containerName, session.claudeSessionId || session.id).catch(() => { });
|
|
1827
|
+
}
|
|
1710
1828
|
// Save lastUsedCase to settings for TUI/web sync
|
|
1711
1829
|
try {
|
|
1712
1830
|
const settingsFilePath = SETTINGS_PATH;
|
|
@@ -1993,6 +2111,12 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
1993
2111
|
const query = req.query;
|
|
1994
2112
|
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
|
|
1995
2113
|
const headBuf = Buffer.alloc(16384);
|
|
2114
|
+
// Multi-user: this scans the host-wide ~/.claude/projects tree, so a non-admin
|
|
2115
|
+
// must only see history whose decoded workingDir is inside their own case space.
|
|
2116
|
+
// Do NOT trust the caller-supplied projectKey — confine on the decoded path.
|
|
2117
|
+
// No-op for admins / single-user mode.
|
|
2118
|
+
const user = getAuthUser(req);
|
|
2119
|
+
const scopeHistory = isMultiUserMode() && user.role !== 'admin';
|
|
1996
2120
|
// Single-folder drill-down: when projectKey is provided, scan only that
|
|
1997
2121
|
// directory, bypass the 50-cap, and honor offset/limit pagination.
|
|
1998
2122
|
if (query.projectKey) {
|
|
@@ -2003,12 +2127,15 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
2003
2127
|
const offset = Math.max(0, parseInt(query.offset || '0', 10) || 0);
|
|
2004
2128
|
const limit = Math.min(100, Math.max(1, parseInt(query.limit || '20', 10) || 20));
|
|
2005
2129
|
const projPath = join(projectsDir, query.projectKey);
|
|
2006
|
-
|
|
2130
|
+
let all = await scanProjectDir(projPath, query.projectKey, headBuf);
|
|
2131
|
+
// Confine to the caller's workspace (a projectKey maps to a single foreign cwd).
|
|
2132
|
+
if (scopeHistory)
|
|
2133
|
+
all = all.filter((r) => isWorkingDirAllowed(user, r.workingDir));
|
|
2007
2134
|
all.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
|
|
2008
2135
|
return { sessions: all.slice(offset, offset + limit), total: all.length };
|
|
2009
2136
|
}
|
|
2010
2137
|
// Global overview: scan all projects, return up to 50 most-recent sessions.
|
|
2011
|
-
|
|
2138
|
+
let results = [];
|
|
2012
2139
|
try {
|
|
2013
2140
|
const projectDirs = await fs.readdir(projectsDir);
|
|
2014
2141
|
for (const projDir of projectDirs) {
|
|
@@ -2020,6 +2147,9 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
2020
2147
|
catch {
|
|
2021
2148
|
// Projects dir may not exist
|
|
2022
2149
|
}
|
|
2150
|
+
// Multi-user: drop rows outside the non-admin caller's own case space.
|
|
2151
|
+
if (scopeHistory)
|
|
2152
|
+
results = results.filter((r) => isWorkingDirAllowed(user, r.workingDir));
|
|
2023
2153
|
results.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
|
|
2024
2154
|
return { sessions: results.slice(0, 50) };
|
|
2025
2155
|
});
|
|
@@ -2116,7 +2246,32 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
2116
2246
|
catch {
|
|
2117
2247
|
// Mux stats are optional.
|
|
2118
2248
|
}
|
|
2119
|
-
|
|
2249
|
+
// Multi-user: a non-admin only sees their own sessions; host-wide transcript
|
|
2250
|
+
// history (not tied to an owned session) is admin-only.
|
|
2251
|
+
let sLive = live;
|
|
2252
|
+
let sPersisted = persisted;
|
|
2253
|
+
let sLifecycle = lifecycle;
|
|
2254
|
+
let sHistory = history;
|
|
2255
|
+
const uUser = getAuthUser(req);
|
|
2256
|
+
if (isMultiUserMode() && uUser.role !== 'admin') {
|
|
2257
|
+
const ownedLive = new Set([...ctx.sessions.values()].filter((s) => canAccessOwned(uUser, s.owner)).map((s) => s.id));
|
|
2258
|
+
const stored = ctx.store.getState().sessions;
|
|
2259
|
+
const ownedPersisted = new Set(Object.values(stored)
|
|
2260
|
+
.filter((p) => canAccessOwned(uUser, p.owner))
|
|
2261
|
+
.map((p) => p.id));
|
|
2262
|
+
const isOwned = (id) => ownedLive.has(id) || ownedPersisted.has(id);
|
|
2263
|
+
sLive = live.filter((l) => isOwned(l.id));
|
|
2264
|
+
sPersisted = persisted.filter((p) => isOwned(p.id));
|
|
2265
|
+
sLifecycle = lifecycle.filter((e) => isOwned(e.sessionId));
|
|
2266
|
+
sHistory = [];
|
|
2267
|
+
}
|
|
2268
|
+
const merged = mergeUnifiedSessions({
|
|
2269
|
+
live: sLive,
|
|
2270
|
+
persisted: sPersisted,
|
|
2271
|
+
lifecycle: sLifecycle,
|
|
2272
|
+
history: sHistory,
|
|
2273
|
+
mux,
|
|
2274
|
+
});
|
|
2120
2275
|
const offset = query.offset !== undefined ? parseInt(query.offset, 10) : undefined;
|
|
2121
2276
|
const limit = query.limit !== undefined ? parseInt(query.limit, 10) : undefined;
|
|
2122
2277
|
return filterAndPaginate(merged, {
|
|
@@ -2173,7 +2328,7 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
2173
2328
|
reply.header('Retry-After', '60');
|
|
2174
2329
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Rate limit exceeded (30 uploads/min per session)');
|
|
2175
2330
|
}
|
|
2176
|
-
const session = findSessionOrFail(ctx, id);
|
|
2331
|
+
const session = findSessionOrFail(ctx, id, req);
|
|
2177
2332
|
if (!req.isMultipart()) {
|
|
2178
2333
|
reply.code(400);
|
|
2179
2334
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Expected multipart/form-data');
|