Merge remote-tracking branch 'origin/master' into relprep/pr-510

Brings the 1.33.2 based adoption feature up to master. Conflicts were
resolved by keeping master's intent and re-applying the feature on top:
both sides kept for imports, the script tag and the stylesheet blocks; the
trust-dialog guard keeps master's stopped check plus the adopted refusal;
the Ralph and image watchers keep master's remote branch plus the adopted
skip; the close confirmation keeps master's worktree option plus the
adopted wording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-06 02:51:00 +01:00
commit 57b2dcc466
858 files changed
+173626 -7078

No files matched your search

+12 -2
View File
@@ -36,11 +36,14 @@ import {
} from '../../user-store.js';
import { getAuthUser, requireAdmin, revokeUserSessions } from '../route-helpers.js';
import { webviewCapabilities } from '../../webview-capabilities.js';
import { htmlViewCapabilities } from '../../html-view-capabilities.js';
import { appendAdminAudit } from '../admin-audit.js';
import { SseEvent } from '../sse-events.js';
import type { AuthPort } from '../ports/auth-port.js';
import type { SessionPort } from '../ports/session-port.js';
import type { EventPort } from '../ports/event-port.js';
import type { TabLayoutPort } from '../ports/tab-layout-port.js';
import { ownerLayoutKey } from '../../tab-layout-persistence.js';
const CreateUserSchema = z.object({
username: z.string().min(1).max(64),
@@ -72,7 +75,10 @@ function storeError(reply: FastifyReply, err: unknown): ReturnType<typeof create
return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, err instanceof Error ? err.message : 'error');
}
export function registerAdminRoutes(app: FastifyInstance, ctx: SessionPort & AuthPort & EventPort): void {
export function registerAdminRoutes(
app: FastifyInstance,
ctx: SessionPort & AuthPort & EventPort & TabLayoutPort
): void {
// Gate: admin routes exist only in multi-user mode, and only for admins.
const gate = (req: FastifyRequest, reply: FastifyReply): boolean => {
if (!isMultiUserMode()) {
@@ -183,7 +189,8 @@ export function registerAdminRoutes(app: FastifyInstance, ctx: SessionPort & Aut
// Web-tab proxy capabilities are a second credential the cookie purge does not
// touch; a forced logout that left them alive would not be a logout.
const revokedWebviews = webviewCapabilities.revokeOwner(normalizeUsername(username));
audit(req, 'user.logout', username, { revoked, revokedWebviews });
const revokedHtmlViews = htmlViewCapabilities.revokeOwner(normalizeUsername(username));
audit(req, 'user.logout', username, { revoked, revokedWebviews, revokedHtmlViews });
return { success: true, data: { revoked } };
});
@@ -206,6 +213,7 @@ export function registerAdminRoutes(app: FastifyInstance, ctx: SessionPort & Aut
}
revokeUserSessions(ctx.authSessions, username);
webviewCapabilities.revokeOwner(normalizeUsername(username));
htmlViewCapabilities.revokeOwner(normalizeUsername(username));
if (deleteSpace) await deleteUserSpace(username);
audit(req, 'user.delete', username, { deleteSpace, killedSessions: owned.length });
ctx.broadcast(SseEvent.AdminUsersChanged, {});
@@ -296,6 +304,8 @@ export function registerAdminRoutes(app: FastifyInstance, ctx: SessionPort & Aut
);
}
await fsp.rm(realTarget, { recursive: true, force: true });
// The user's default session group for that case goes with it.
await ctx.tabLayouts.forgetCaseDefaultGroup(ownerLayoutKey(username), params.caseName).catch(() => undefined);
audit(req, 'user.case-delete', username, { caseName: params.caseName });
ctx.broadcast(SseEvent.AdminUsersChanged, {});
return { success: true, data: { username, caseName: params.caseName } };
+193
View File
@@ -0,0 +1,193 @@
/**
* @fileoverview Agent-facing bounded inspection and guarded stage/submit endpoints.
* Ownership is checked before any disk or pane read. Native evidence comes only
* from strict conversation attribution; all other modes get labeled snapshots.
*
* Experimental and opt-in: all three routes answer `403 FORBIDDEN`, naming the
* setting, until the synced `agentCoordinationEnabled` is on (default OFF), the
* same shape as `cliManagementEnabled` and `mcpSyncEnabled`. The gate runs first,
* before session lookup, so a disabled install reveals nothing about its sessions.
*/
import type { FastifyInstance } from 'fastify';
import type { Session } from '../../session.js';
import type { SessionPort, InfraPort } from '../ports/index.js';
import { getCli } from '../../config/cli-registry/registry.js';
import { findExactCodexConversation, resolveEffectiveCodexHome } from '../../agent-codex-identity.js';
import { InspectionReader, InspectionConflict, boundedUtf8Tail } from '../../agent-inspection.js';
import {
GuardedStageStore,
GuardedInputConflict,
guardedComposerChrome,
MAY_BE_TYPED,
type GuardedInputTarget,
} from '../../agent-staging.js';
import { ApiErrorCode, createErrorResponse, type ApiResponse } from '../../types.js';
import { findSessionOrFail, getAuthUser, parseBody, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js';
import { AgentInspectQuerySchema, AgentStageSchema, AgentSubmitSchema } from '../schemas.js';
/** Default OFF, same shape as `readMcpSyncEnabled`: read fresh so a toggle applies at once. */
export async function readAgentCoordinationEnabled(): Promise<boolean> {
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {});
return settings.agentCoordinationEnabled === true;
}
/** The refusal every route sends while the setting is off. Exported for the tests. */
export const AGENT_COORDINATION_DISABLED_MESSAGE =
'Experimental agent coordination (inspect, stage, submit) is disabled. Turn on "Experimental agent coordination" in Settings (agentCoordinationEnabled) and save first.';
async function gate(): Promise<ApiResponse<never> | null> {
if (await readAgentCoordinationEnabled()) return null;
return createErrorResponse(ApiErrorCode.FORBIDDEN, AGENT_COORDINATION_DISABLED_MESSAGE);
}
/** A Node error code (`EACCES`, `EIO`, ...) or the error's class name; never its message. */
export function errorCode(error: unknown): string {
const code = (error as { code?: unknown } | null)?.code;
if (typeof code === 'string' && /^[A-Z0-9_]{1,40}$/.test(code)) return code;
return error instanceof Error ? error.constructor.name : 'unknown';
}
export function registerAgentSessionRoutes(app: FastifyInstance, ctx: SessionPort & InfraPort): void {
const reader = new InspectionReader();
const stages = new GuardedStageStore();
app.addHook('onClose', async () => {
reader.clear();
stages.clear();
});
const generation = (session: Session) => session.inspectionGeneration;
const target = (session: Session): GuardedInputTarget => ({
id: session.id,
chrome: () => guardedComposerChrome(getCli(session.mode)),
snapshot: () => {
if (ctx.sessions.get(session.id) !== session) return null;
const snapshot = session.guardedInputSnapshot?.();
return snapshot ?? null;
},
write: (text) => session.stageGuardedInput(text),
submit: (expected) => session.submitGuardedInput(expected),
});
app.get('/api/sessions/:id/inspect', async (req, reply) => {
const refused = await gate();
if (refused) return reply.code(403).send(refused);
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id, req);
const { maxBytes, since } = parseBody(AgentInspectQuerySchema, req.query);
const initialGeneration = generation(session);
const identity = { sessionId: session.id, mode: session.mode };
const activity = { status: session.status, lastSubmitAt: session.lastSubmitAt || null, observedAt: Date.now() };
try {
if (getCli(session.mode)?.capabilities.transcript === 'codex-rollout' && !session.remote && !session.docker) {
const conversation = await findExactCodexConversation(
{
codexHome: resolveEffectiveCodexHome(session.getCodexHomeOverride(), session.workingDir),
sessionId: session.id,
workingDir: session.workingDir,
resumeSessionId: session.codexConfig?.resumeSessionId,
},
{ maxEntries: 8192, maxBytes: 1048576 }
);
if (generation(session) !== initialGeneration || ctx.sessions.get(id) !== session)
throw new InspectionConflict('Session changed during inspection');
if (conversation) {
const result = await reader.read(
conversation.path,
{
owner: getAuthUser(req).username,
sessionId: id,
generation: initialGeneration,
conversationId: conversation.id,
},
maxBytes,
since
);
if (generation(session) !== initialGeneration || ctx.sessions.get(id) !== session)
throw new InspectionConflict('Session changed during inspection');
return {
success: true,
data: {
source: 'codex-jsonl',
identity: { ...identity, conversationId: conversation.id },
activity,
incremental: true,
maxBytes,
...result,
},
};
}
}
if (since)
throw new InspectionConflict('Attributed transcript unavailable; this cursor cannot resume a pane snapshot');
const frame = session.muxName ? ctx.mux.capturePaneText?.(session.muxName) : null;
const evidence = frame ?? session.terminalBuffer;
return {
success: true,
data: {
source: 'pane-snapshot',
identity,
activity,
incremental: false,
cursor: null,
maxBytes,
evidence: boundedUtf8Tail(evidence, maxBytes),
truncated: evidence.length > maxBytes || Buffer.byteLength(evidence) > maxBytes,
},
};
} catch (error) {
if (error instanceof InspectionConflict)
return reply.code(409).send(createErrorResponse(ApiErrorCode.CONFLICT, error.message));
// Anything else (an unreadable rollout, an IO error) is not a retryable cursor
// conflict. Log the session and the error code only: the message can carry the
// native transcript path, which must never reach the log or the response.
console.warn(`[agent-inspect] session ${session.id}: inspection failed (${errorCode(error)})`);
return reply.code(422).send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Inspection source unavailable'));
}
});
app.post('/api/sessions/:id/stage', async (req, reply) => {
const refused = await gate();
if (refused) return reply.code(403).send(refused);
const session = findSessionOrFail(ctx, (req.params as { id: string }).id, req);
const { message } = parseBody(AgentStageSchema, req.body);
try {
const receipt = await stages.stage(target(session), getAuthUser(req).username, message);
return { success: true, data: receipt };
} catch (error) {
if (!(error instanceof GuardedInputConflict))
console.warn(`[agent-stage] session ${session.id}: delivery failed (${errorCode(error)})`);
return reply
.code(409)
.send(
createErrorResponse(
ApiErrorCode.CONFLICT,
error instanceof GuardedInputConflict
? error.message
: `Stage delivery failed or ambiguous; ${MAY_BE_TYPED}`
)
);
}
});
app.post('/api/sessions/:id/submit', async (req, reply) => {
const refused = await gate();
if (refused) return reply.code(403).send(refused);
const session = findSessionOrFail(ctx, (req.params as { id: string }).id, req);
const { stageId, confirmSha } = parseBody(AgentSubmitSchema, req.body);
try {
return {
success: true,
data: await stages.submit(target(session), getAuthUser(req).username, stageId, confirmSha),
};
} catch (error) {
if (!(error instanceof GuardedInputConflict))
console.warn(`[agent-submit] session ${session.id}: delivery failed (${errorCode(error)})`);
return reply
.code(409)
.send(
createErrorResponse(
ApiErrorCode.CONFLICT,
error instanceof GuardedInputConflict
? error.message
: 'Submit delivery failed or ambiguous; receipt consumed, inspect the pane'
)
);
}
});
}
+14 -12
View File
@@ -24,6 +24,7 @@ import { ApprovalAnswerSchema } from '../schemas.js';
import { parseBody, getAuthUser, canAccessOwned, findSessionOrFail } from '../route-helpers.js';
import { approvalInbox, type ApprovalItem } from '../approval-inbox.js';
import { hooksAvailableForMode, sessionHookOptions } from '../session-wait-registry.js';
import { getCli } from '../../config/cli-registry/registry.js';
import type { SessionPort } from '../ports/index.js';
/**
@@ -99,22 +100,23 @@ export function registerApprovalRoutes(app: FastifyInstance, ctx: SessionPort):
// Throws 404 (not 403) for sessions the caller does not own, same
// no-existence-leak rule as every other session route.
const session = findSessionOrFail(ctx, item.sessionId, req);
if (!hooksAvailableForMode(session.mode, sessionHookOptions(session))) {
return createErrorResponse(ApiErrorCode.CONFLICT, 'Session mode cannot have pending approvals');
}
// A dsh approval is an ALERT, not an answerable card: the dialog belongs to
// a third-party TUI whose keystroke contract Codeman has not measured, the
// Claude-shaped option parser never reads options off its frames, and
// verifyStillAnswerable() can therefore never be conclusive for it — so the
// '1'/Esc below would be a blind keystroke into a foreign composer. The item
// still raises the red alert and clears on the harness's own working/stop
// reports; answering happens in the terminal.
if (session.mode === 'deepseek') {
// Alert-only items: the dialog belongs to a TUI whose keystroke contract Codeman
// has not measured, so '1'/Esc would be a blind keystroke into a foreign composer.
// Two sources today: a supervised-hook CLI (DeepSeek Harness), whose item clears on
// the harness's own working/stop reports, and a dialog read off the screen of a
// hook-less CLI (codex, web/screen-blocked.ts), which clears when it leaves the
// screen and was read from text the agent writes, so it must never steer a key.
// Checked BEFORE the hooks gate below: a codex session has no hooks, and "this mode
// cannot have pending approvals" would be false about the item the caller holds.
if (item.answerInTerminal) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'DeepSeek Harness approvals must be answered in the terminal: the dialog belongs to a third-party TUI whose keystrokes Codeman cannot verify.'
`${getCli(session.mode)?.label ?? session.mode} approvals must be answered in the terminal: the dialog belongs to a TUI whose keystrokes Codeman cannot verify.`
);
}
if (!hooksAvailableForMode(session.mode, sessionHookOptions(session))) {
return createErrorResponse(ApiErrorCode.CONFLICT, 'Session mode cannot have pending approvals');
}
// Re-capture the pane before aiming keystrokes at it: if the dialog was
// answered in the terminal moments ago, the digit would land in whatever
+592 -45
View File
@@ -10,7 +10,7 @@ import { FastifyInstance } from 'fastify';
import { existsSync, lstatSync, mkdirSync, writeFileSync, readdirSync, readFileSync, createReadStream } from 'node:fs';
import { exec } from 'node:child_process';
import fs from 'node:fs/promises';
import { join, resolve, basename } from 'node:path';
import { join, resolve, basename, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { homedir } from 'node:os';
import type {
@@ -29,6 +29,8 @@ import {
ClonePreflightSchema,
LinkCaseSchema,
CaseOrderSchema,
CaseDefaultGroupSchema,
RenameCaseSchema,
RemoteCaseLinkSchema,
RemoteHostSchema,
DockerCaseLinkSchema,
@@ -49,7 +51,20 @@ import {
probeGitRemote,
} from '../../git-clone.js';
import type { GitRemoteProbe, GitUrlParse } from '../../git-clone.js';
import { isPathWithin } from '../../git-worktrees.js';
import { defaultDockerWorkspaces } from './git-status-routes.js';
import { generateClaudeMd } from '../../templates/claude-md.js';
import { prepareNewCasePath } from '../case-path.js';
import {
CaseRenameError,
isCaseSessionStarting,
moveCaseFolder,
moveClaudeHistory,
renameInOrder,
siblingPath,
withCaseLock,
} from '../../case-rename.js';
import { boundedPathExists, describeUnknownPath, probePath, probePathKind } from '../../utils/index.js';
import { readAgentCaseMarker, type AgentCaseMarker } from '../../agent-case-marker.js';
import { settingsWriteBlocker, writeHooksConfig } from '../../hooks-config.js';
import {
@@ -58,16 +73,25 @@ import {
isAdmin,
isWorkingDirAllowed,
ownerFor,
requireAdmin,
resolveCasesDir,
SETTINGS_PATH,
validatePathWithinBase,
parseBody,
readJsonConfig,
} from '../route-helpers.js';
import {
assertSettingsWritable,
mutateSettingsFile,
SettingsWriteRefusedError,
settingsWriteErrorResponse,
} from '../settings-writer.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import type { AuthUser } from '../../types.js';
import { SseEvent } from '../sse-events.js';
import type { EventPort, ConfigPort, SessionPort } from '../ports/index.js';
import type { EventPort, ConfigPort, SessionPort, TabLayoutPort } from '../ports/index.js';
import { ownerLayoutKey } from '../../tab-layout-persistence.js';
import { TabLayoutValidationError } from '../../tab-layout.js';
import type { FastifyRequest } from 'fastify';
import { dataPath, getDataDir } from '../../config/instance.js';
import {
@@ -128,6 +152,10 @@ const APP_VERSION = (() => {
const LOCAL_CLONE_ADMIN_ONLY =
'Cloning from a local path is admin-only in multi-user mode. Use a repository URL instead.';
/** Refusal text for a clone at or inside a Docker case workspace (git would run its hooks on the host). */
const DOCKER_CLONE_REFUSAL =
'Cloning is not available into a folder inside a Docker case workspace: a container can write there, and git would run the new repository’s hooks and filters on this machine. Clone outside the workspace and link it, or clone from inside the container.';
/**
* Whether a clone or preflight must run with git's credential helpers cleared:
* a non-admin in multi-user mode. Every user's git runs as the one server
@@ -163,6 +191,9 @@ function gitDiagnosticLine(stderr: string): string {
* the clone response says so out loud instead of silently merging into them.
*/
function repoShipsClaudeSettings(casePath: string): boolean {
// Deliberately NOT the bounded path probe: the tree was just cloned into the
// local case space (and lstat'ed synchronously moments ago), so a bound protects
// nothing here, while a probe answering "unknown" could silently drop this warning.
return ['settings.json', 'settings.local.json'].some((file) => existsSync(join(casePath, '.claude', file)));
}
@@ -240,7 +271,33 @@ async function ensureCaseImage(
return { ok: true, imageBuilding: true };
}
export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & ConfigPort & SessionPort): void {
/**
* Is `name` a case the caller can see (the same rules as GET /api/cases and
* resolveCasePath): an owned remote or Docker case, a linked case (admin or
* single-user only), or a directory in the caller's own case space. Bounded:
* a case folder on a mount that stopped answering still counts as there, like
* the case list keeps listing it.
*/
async function caseVisibleToCaller(name: string, req: FastifyRequest): Promise<boolean> {
if (!SAFE_CASE_NAME.test(name)) return false;
const user = getAuthUser(req);
if ((await readRemoteCases(CODEMAN_CONFIG_DIR)).some((c) => c.name === name && canAccessOwned(user, c.owner))) {
return true;
}
if ((await readDockerCases(CODEMAN_CONFIG_DIR)).some((c) => c.name === name && canAccessOwned(user, c.owner))) {
return true;
}
const linkedCases = await readLinkedCases();
if (linkedCases[name] && (!isMultiUserMode() || isAdmin(req)))
return (await probePath(linkedCases[name])) !== 'absent';
const kind = await probePathKind(join(resolveCasesDir(user), name));
return kind === 'directory' || kind === 'unknown';
}
export function registerCaseRoutes(
app: FastifyInstance,
ctx: EventPort & ConfigPort & SessionPort & TabLayoutPort
): void {
// ═══════════════════════════════════════════════════════════════
// Case CRUD (list, create, link, detail, fix-plan)
// ═══════════════════════════════════════════════════════════════
@@ -266,7 +323,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
cases.push({
name: e.name,
path: casePath,
hasClaudeMd: existsSync(join(casePath, 'CLAUDE.md')),
hasClaudeMd: await boundedPathExists(join(casePath, 'CLAUDE.md')),
location: 'local',
...(marker ? { agentCreated: agentCreatedInfo(marker) } : {}),
});
@@ -281,15 +338,19 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
const existingNames = new Set(cases.map((c) => c.name));
if (admin) {
for (const [name, path] of Object.entries(linkedCases)) {
if (!existingNames.has(name) && SAFE_CASE_NAME.test(name) && existsSync(path)) {
cases.push({
name,
path,
hasClaudeMd: existsSync(join(path, 'CLAUDE.md')),
linked: true,
location: 'linked-local',
});
}
if (existingNames.has(name) || !SAFE_CASE_NAME.test(name)) continue;
const state = await probePath(path);
if (state === 'absent') continue;
// An unreachable linked case (a dead network mount) stays listed and says
// so: dropping it would read as "deleted" and invite a same-name local case.
cases.push({
name,
path,
hasClaudeMd: state === 'present' && (await boundedPathExists(join(path, 'CLAUDE.md'))),
linked: true,
location: 'linked-local',
...(state === 'unknown' ? { unreachable: true } : {}),
});
}
}
@@ -333,7 +394,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
const dockerCaseInfo: CaseInfo = {
name: dockerCase.name,
path: dockerDisplayPath({ container, path: dockerCase.hostWorkspacePath }),
hasClaudeMd: existsSync(join(dockerCase.hostWorkspacePath, 'CLAUDE.md')),
hasClaudeMd: await boundedPathExists(join(dockerCase.hostWorkspacePath, 'CLAUDE.md')),
location: 'docker',
docker: {
hostId: host.id,
@@ -424,8 +485,109 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
return { success: true, data: { cases: summaries } };
});
app.post('/api/cases', async (req): Promise<ApiResponse<{ case: { name: string; path: string } }>> => {
const { name, description } = parseBody(CreateCaseSchema, req.body);
/**
* `POST /api/cases` with a `path`: create the folder (or fill an EMPTY existing one), scaffold it
* exactly like a normal case, and register it in the linked-cases registry so it lists, resolves
* and deletes (unlinks, never removes files) like any linked case. Everything is judged before
* anything is written; a failure after the first write undoes what this call created.
*/
async function createCaseInCustomFolder(
name: string,
description: string | undefined,
customPath: string,
req: FastifyRequest,
reply: { code: (n: number) => unknown }
): Promise<ApiResponse<{ case: { name: string; path: string } }>> {
const ownCasesDir = resolveCasesDir(getAuthUser(req));
if (existsSync(join(ownCasesDir, name))) {
reply.code(409);
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'A case with this name already exists in codeman-cases.');
}
const linkedCases = await readLinkedCases();
if (linkedCases[name]) {
reply.code(409);
return createErrorResponse(
ApiErrorCode.ALREADY_EXISTS,
`Case "${name}" is already linked to ${linkedCases[name]}`
);
}
// The caller's own cases dir and the shared one (the same folder outside multi-user mode).
const casesDirs = [...new Set([ownCasesDir, resolveCasesDir()])];
const prepared = await prepareNewCasePath(customPath, { home: homedir(), dataDir: getDataDir(), casesDirs });
if (!prepared.ok) {
// A parent that did not answer is not a bad request: OPERATION_FAILED (422), like
// POST /api/sessions for a workingDir on a dead mount.
if (prepared.code === 'UNREACHABLE') {
reply.code(422);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, prepared.reason);
}
const status = prepared.code === 'NOT_FOUND' ? 404 : prepared.code === 'EXISTS' ? 409 : 400;
reply.code(status);
const code =
prepared.code === 'NOT_FOUND'
? ApiErrorCode.NOT_FOUND
: prepared.code === 'EXISTS'
? ApiErrorCode.ALREADY_EXISTS
: ApiErrorCode.INVALID_INPUT;
return createErrorResponse(code, prepared.reason);
}
const casePath = prepared.path;
const alreadyAs = Object.entries(linkedCases).find(([, p]) => p === casePath)?.[0];
if (alreadyAs) {
reply.code(409);
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, `That folder is already the case "${alreadyAs}"`);
}
const made: string[] = [];
try {
if (!prepared.existedEmpty) {
mkdirSync(casePath);
made.push(casePath);
}
mkdirSync(join(casePath, 'src'));
made.push(join(casePath, 'src'));
const templatePath = await ctx.getDefaultClaudeMdPath();
writeFileSync(join(casePath, 'CLAUDE.md'), generateClaudeMd(name, description || '', templatePath));
made.push(join(casePath, 'CLAUDE.md'));
made.push(join(casePath, '.claude')); // before the write, so a half-written one is undone too
await writeHooksConfig(casePath);
const codemanDir = getDataDir();
if (!existsSync(codemanDir)) mkdirSync(codemanDir, { recursive: true });
// Re-read right before writing, so a case linked since the check above is not dropped. This only
// narrows the window: like POST /api/cases/link, the registry write is not serialized, and two
// requests that both read before either writes can still lose one entry.
const fresh = await readLinkedCases();
if (fresh[name]) throw Object.assign(new Error(`Case "${name}" was just linked`), { conflict: true });
fresh[name] = casePath;
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(fresh, null, 2));
ctx.broadcast(SseEvent.CaseCreated, { name, path: casePath });
return { success: true, data: { case: { name, path: casePath } } };
} catch (err) {
// Undo only what this call made. The whole folder if we created it, otherwise the scaffold
// entries inside the empty folder the user picked; never anything else.
for (const p of made.reverse()) await fs.rm(p, { recursive: true, force: true }).catch(() => undefined);
if ((err as { conflict?: boolean }).conflict) {
reply.code(409);
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, getErrorMessage(err));
}
reply.code(500);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
}
app.post('/api/cases', async (req, reply): Promise<ApiResponse<{ case: { name: string; path: string } }>> => {
const { name, description, path: customPath } = parseBody(CreateCaseSchema, req.body);
// A custom folder writes outside the cases directory and registers the path in the shared,
// ownerless linked-cases registry, so it carries the same bar as POST /api/cases/link.
if (customPath !== undefined) {
const denied = adminOnly(req, reply);
if (denied) return denied;
return createCaseInCustomFolder(name, description, customPath, req, reply);
}
const casePath = validatePathWithinBase(name, resolveCasesDir(getAuthUser(req)));
if (!casePath) {
@@ -505,7 +667,8 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
* request died mid-clone still sees the case appear over SSE when git finishes.
*
* Deliberately NOT admin-gated in multi-user mode: unlike `/api/cases/link`,
* this writes only inside the caller's own `resolveCasesDir`. Two things would
* this writes only inside the caller's own `resolveCasesDir`, EXCEPT with a `path` (a custom folder,
* registered as a linked case in the shared registry), which is admin-only like Link Existing. Two things would
* otherwise read through that boundary: a `local`-transport source (refused for
* non-admins) and the server account's git credential helpers, which every user
* shares (cleared for non-admins, see `cloneWithoutCredentialHelpers`).
@@ -530,7 +693,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
warnings: string[];
}>
> => {
const { name, repository, ref, shallow, description } = parseBody(CloneCaseSchema, req.body);
const { name, repository, ref, shallow, description, path: customPath } = parseBody(CloneCaseSchema, req.body);
const user = getAuthUser(req);
const parsed = parseGitRepositoryUrl(repository);
@@ -549,8 +712,59 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
);
}
// A custom folder writes outside the cases directory and registers the path in the shared,
// ownerless linked-cases registry, so it carries the same bar as POST /api/cases/link.
if (customPath !== undefined) {
const denied = adminOnly(req, reply);
if (denied) return denied;
}
const casesDir = resolveCasesDir(user);
const casePath = validatePathWithinBase(name, casesDir);
let casePath: string | null;
let intoEmptyDir = false;
if (customPath !== undefined) {
const prepared = await prepareNewCasePath(customPath, {
home: homedir(),
dataDir: getDataDir(),
casesDirs: [...new Set([casesDir, resolveCasesDir()])],
casesDirAdvice: 'clone there without a custom folder',
});
if (!prepared.ok) {
reply.code(
prepared.code === 'UNREACHABLE'
? 422
: prepared.code === 'NOT_FOUND'
? 404
: prepared.code === 'EXISTS'
? 409
: 400
);
return createErrorResponse(
prepared.code === 'UNREACHABLE'
? ApiErrorCode.OPERATION_FAILED
: prepared.code === 'NOT_FOUND'
? ApiErrorCode.NOT_FOUND
: prepared.code === 'EXISTS'
? ApiErrorCode.ALREADY_EXISTS
: ApiErrorCode.INVALID_INPUT,
prepared.code === 'EXISTS'
? `${customPath} already has files in it. Pick an empty folder, or a folder that does not exist yet.`
: prepared.reason
);
}
intoEmptyDir = prepared.existedEmpty;
casePath = prepared.path;
// The same folder listed as a local case in the cases dir would make deleting it remove files.
if (existsSync(join(casesDir, name))) {
reply.code(409);
return createErrorResponse(
ApiErrorCode.ALREADY_EXISTS,
'A case with this name already exists in codeman-cases.'
);
}
} else {
casePath = validatePathWithinBase(name, casesDir);
}
if (!casePath) return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
// Reject a duplicate name across EVERY case kind before invoking git, so a
@@ -559,8 +773,14 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
const linkedCases = await readLinkedCases();
const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR);
const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
const linkedAs = Object.entries(linkedCases).find(([, p]) => p === casePath)?.[0];
if (linkedAs && customPath !== undefined) {
reply.code(409);
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, `That folder is already the case "${linkedAs}"`);
}
if (
existsSync(casePath) ||
// A custom folder was judged by prepareNewCasePath (an existing empty one is fine).
(customPath === undefined && existsSync(casePath)) ||
linkedCases[name] ||
dockerCases.some((item) => item.name === name) ||
remoteCases.some((item) => item.name === name)
@@ -568,16 +788,47 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists');
}
// git creates the leaf, not necessarily the case space above it.
// git creates the leaf, not necessarily the case space above it (a custom folder's parent
// already exists: prepareNewCasePath refused a missing one).
if (customPath === undefined) {
try {
mkdirSync(casesDir, { recursive: true });
} catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
}
// git runs the new repository's hooks and filters on THIS machine once the clone finishes, and a container
// with the workspace bind-mounted can write both while it fetches. Like git status, fetch and worktrees,
// never clone at or inside a Docker case workspace. Fails closed when the Docker case list cannot be read.
try {
mkdirSync(casesDir, { recursive: true });
} catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
// Probed first (#516): a case's workspace can sit on a mount that stopped answering, and an unbounded
// realpath there would hold this request and a threadpool worker. Such a root is judged as spelled.
const roots = await Promise.all(
(await defaultDockerWorkspaces()).map(async (root) =>
(await probePathKind(root, { pastCap: true })) === 'unknown'
? resolve(root)
: fs.realpath(root).catch(() => resolve(root))
)
);
// A custom folder is already symlink-resolved; the default one is judged through its real cases dir.
const dest =
customPath !== undefined ? casePath : join(await fs.realpath(casesDir).catch(() => casesDir), name);
if (roots.some((root) => isPathWithin(dest, root))) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, DOCKER_CLONE_REFUSAL);
}
} catch {
reply.code(503);
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
'Could not read the Docker case list, so Codeman cannot tell whether that folder is a Docker case workspace. Try again.'
);
}
const clone = await cloneRepository({
repository: parsed.repository,
destination: casePath,
...(intoEmptyDir ? { intoEmptyDir: true } : {}),
withoutCredentialHelpers: cloneWithoutCredentialHelpers(req),
...(ref ? { ref } : {}),
...(shallow ? { shallow: true } : {}),
@@ -638,6 +889,26 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
warnings.push(`Case scaffolding was incomplete: ${getErrorMessage(err)}`);
}
if (customPath !== undefined) {
// Re-read right before writing so a case linked while git ran is not dropped. Like
// POST /api/cases/link the write is not serialized, so this only narrows the window.
try {
const fresh = await readLinkedCases();
if (fresh[name])
throw Object.assign(new Error(`Case "${name}" was linked while the clone ran`), { conflict: true });
fresh[name] = casePath;
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(fresh, null, 2));
} catch (err) {
// The clone itself succeeded: keep the folder (the user waited for it) and say where it is.
const conflict = (err as { conflict?: boolean }).conflict;
reply.code(conflict ? 409 : 500);
return createErrorResponse(
conflict ? ApiErrorCode.ALREADY_EXISTS : ApiErrorCode.OPERATION_FAILED,
`Cloned into ${casePath} but could not register the case (${getErrorMessage(err)}). Use Link Existing to add it.`
);
}
}
ctx.broadcast(SseEvent.CaseCreated, { name, path: casePath });
return {
success: true,
@@ -1426,7 +1697,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
// admin-only in multi-user mode (mirrors host CRUD + the admin-only GET listing).
const denied = adminOnly(req, reply);
if (denied) return denied;
const { name, path: folderPath } = parseBody(LinkCaseSchema, req.body, 'Invalid request body');
const { name, path: folderPath } = parseBody(LinkCaseSchema, req.body);
// Expand ~ to home directory
const expandedPath = folderPath.startsWith('~') ? join(homedir(), folderPath.slice(1)) : folderPath;
@@ -1473,6 +1744,19 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
app.delete('/api/cases/:name', async (req): Promise<ApiResponse<{ name: string }>> => {
const { name } = req.params as { name: string };
const user = getAuthUser(req);
// The caller's default session group for this case goes with it (best-effort:
// a stale value is ignored at creation anyway).
// An admin deleting another user's remote/Docker case clears that owner's too.
const forgetCaseDefault = async (caseOwner?: string): Promise<void> => {
const owners = new Set([ownerLayoutKey(ownerFor(req)), ...(caseOwner ? [ownerLayoutKey(caseOwner)] : [])]);
for (const owner of owners) {
try {
await ctx.tabLayouts.forgetCaseDefaultGroup(owner, name);
} catch {
/* never blocks the delete */
}
}
};
if (!validatePathWithinBase(name, resolveCasesDir(user))) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
@@ -1482,12 +1766,14 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
// docker case is skipped so control falls through to the caller's own local delete.
// canAccessOwned is all-true for admins/single-user, so flag-OFF stays byte-identical.
const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
if (remoteCases.some((item) => item.name === name && canAccessOwned(user, item.owner))) {
const remoteCase = remoteCases.find((item) => item.name === name && canAccessOwned(user, item.owner));
if (remoteCase) {
await writeRemoteCases(
CODEMAN_CONFIG_DIR,
remoteCases.filter((item) => !(item.name === name && canAccessOwned(user, item.owner)))
);
ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'remote-unlinked' });
await forgetCaseDefault(remoteCase.owner);
return { success: true, data: { name } };
}
@@ -1520,6 +1806,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
.catch(() => {});
}
ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'docker-unlinked' });
await forgetCaseDefault(dockerCase.owner);
return { success: true, data: { name } };
}
@@ -1532,6 +1819,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
try {
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(linkedCases, null, 2));
ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'unlinked' });
await forgetCaseDefault();
return { success: true, data: { name } };
} catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
@@ -1547,34 +1835,273 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
try {
await fs.rm(casePath, { recursive: true, force: true });
ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'deleted' });
await forgetCaseDefault();
return { success: true, data: { name } };
} catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
});
// ========== Default session group ==========
/**
* The tab group new sessions of this case land in when the create request names
* no group and no parent places them. Scoped to the caller's own tab layout: the
* options are their groups, and a stored id that is no longer one of them reads
* as Ungrouped (null); the read writes nothing (the next creation for the case
* forgets it). Not admin-gated in multi-user mode: the
* value is per owner (like the layout it points into), never server-wide.
*/
app.get('/api/cases/:name/default-group', async (req, reply) => {
const { name } = req.params as { name: string };
if (!(await caseVisibleToCaller(name, req))) {
return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, `Case "${name}" not found`));
}
const view = await ctx.tabLayouts.caseDefaultGroup(ownerLayoutKey(ownerFor(req)), name);
return { success: true, data: { caseName: name, ...view } };
});
app.put('/api/cases/:name/default-group', async (req, reply) => {
const { name } = req.params as { name: string };
const { groupId } = parseBody(CaseDefaultGroupSchema, req.body, 'Invalid default group');
if (!(await caseVisibleToCaller(name, req))) {
return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, `Case "${name}" not found`));
}
try {
const view = await ctx.tabLayouts.setCaseDefaultGroup(ownerLayoutKey(ownerFor(req)), name, groupId);
return { success: true, data: { caseName: name, ...view } };
} catch (err) {
if (err instanceof TabLayoutValidationError) {
return reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'That tab group does not exist'));
}
throw err;
}
});
// ========== Rename Case ==========
/**
* Rename a case: its name and, with it, its folder.
*
* - A case under the cases directory IS its folder name, so it always moves.
* - A linked case has an alias that is only a key in linked-cases.json; the real folder moves too only
* with `renameFolder: true` (it can be shared with other tools).
* - Remote and Docker cases are not renamable here: their folder lives on another host or in a container
* whose name derives from the case name.
*
* ⚠️ Moving a folder that sessions have open is the dangerous part (see src/case-rename.ts): live
* sessions in it are listed and refused (409 `requiresStop`) unless `stopSessions` is set, in which
* case they are ended first. The folder is held by an in-process lock for the whole operation, so a
* session cannot start in it half-moved.
*/
app.post('/api/cases/:name/rename', async (req, reply) => {
const { name } = req.params as { name: string };
const user = getAuthUser(req);
const body = parseBody(RenameCaseSchema, req.body);
const newName = body.newName;
const fail = (status: number, code: ApiErrorCode, message: string, extra: Record<string, unknown> = {}) => {
reply.code(status);
return { ...createErrorResponse(code, message), ...extra };
};
if (!SAFE_CASE_NAME.test(name) || !validatePathWithinBase(name, resolveCasesDir(user))) {
return fail(400, ApiErrorCode.INVALID_INPUT, 'Invalid case name');
}
if (newName === name) return fail(400, ApiErrorCode.INVALID_INPUT, 'The new name is the same as the old one');
// Remote and Docker cases first, so a same-named local case cannot be confused for them.
const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR);
if (
remoteCases.some((c) => c.name === name && canAccessOwned(user, c.owner)) ||
dockerCases.some((c) => c.name === name && canAccessOwned(user, c.owner))
) {
return fail(400, ApiErrorCode.INVALID_INPUT, 'Remote and Docker cases cannot be renamed here');
}
const casesBase = resolveCasesDir(user);
const linkedCases = await readLinkedCases();
const isLinked = Boolean(linkedCases[name]) && (!isMultiUserMode() || isAdmin(req));
const oldPath = isLinked ? linkedCases[name] : join(casesBase, name);
// The new name must be free in every namespace the list merges.
const localTaken = existsSync(join(casesBase, newName));
const taken =
localTaken ||
Boolean(linkedCases[newName]) ||
remoteCases.some((c) => c.name === newName) ||
dockerCases.some((c) => c.name === newName);
if (taken) return fail(409, ApiErrorCode.ALREADY_EXISTS, `A case named "${newName}" already exists`);
const state = await probePath(oldPath);
if (state === 'absent') return fail(404, ApiErrorCode.NOT_FOUND, `Case "${name}" not found`);
// An alias-only rename never touches the folder, so an unreachable one does not block it.
if (state === 'unknown' && (!isLinked || body.renameFolder === true)) {
return fail(503, ApiErrorCode.OPERATION_FAILED, describeUnknownPath('Case folder', oldPath, { pastCap: true }));
}
const moveFolder = !isLinked || body.renameFolder === true;
const newPath = isLinked ? (moveFolder ? siblingPath(oldPath, newName) : oldPath) : join(casesBase, newName);
if (moveFolder && newPath !== oldPath) {
// probePath follows symlinks, so a dangling one reads as absent; moveCaseFolder would still refuse it
// (it checks with lstat), but only after the sessions below were stopped. The probe answered for this
// very path, so the lstat after it is not a first touch of an unreachable mount.
const target = await probePath(newPath, { pastCap: true });
const danglingLink =
target === 'absent' &&
(await fs.lstat(newPath).then(
() => true,
() => false
));
if (target !== 'absent' || danglingLink) {
return fail(409, ApiErrorCode.ALREADY_EXISTS, `A folder named ${newPath} already exists`);
}
}
// Sessions with the folder open. Only a move disturbs them; an alias-only rename leaves them running.
// Remote sessions carry the remote path as their workingDir, so they are not this folder's.
const heldRoot = resolve(oldPath);
const open = moveFolder
? Array.from(ctx.sessions.values()).filter((s) => {
if (s.remote) return false;
const dir = resolve(s.workingDir);
return dir === heldRoot || dir.startsWith(heldRoot + sep);
})
: [];
if (open.some((s) => !canAccessOwned(user, s.owner))) {
return fail(403, ApiErrorCode.FORBIDDEN, "Another user's session has this case open");
}
// The admin's rename writes settings.json only after the sessions are ended and the folder moved, and
// the writer refuses a file that is not a JSON object: check it by the writer's own rule now, so no
// session is ended for a rename that cannot complete. Not between the starting check and the lock below.
if (isAdmin(req)) {
try {
await assertSettingsWritable();
} catch (err) {
if (err instanceof SettingsWriteRefusedError) return fail(409, ApiErrorCode.CONFLICT, err.message);
throw err;
}
}
// A session being created in the folder is not in ctx.sessions yet, so it could be neither listed nor
// stopped: it would start in the old path after the move. Checked in the same tick that takes the lock
// below (nothing is awaited in between), and the Session constructor refuses the folder from then on.
if (moveFolder && isCaseSessionStarting(oldPath)) {
return fail(409, ApiErrorCode.CONFLICT, 'A session is starting in this case. Try again in a moment.', {
renameCode: 'BUSY',
});
}
if (open.length > 0 && !body.stopSessions) {
return fail(
409,
ApiErrorCode.CONFLICT,
`${open.length} live session${open.length === 1 ? ' has' : 's have'} this folder open. Stop them to rename it.`,
{ requiresStop: true, sessions: open.map((s) => ({ id: s.id, name: s.name })) }
);
}
// Sessions really ended so far: an error after `stopSessions` still says how many are gone.
let stopped = 0;
try {
await withCaseLock(oldPath, newPath, async () => {
for (const s of open) {
await ctx.cleanupSession(s.id, true, 'case_rename');
stopped++;
}
if (moveFolder) await moveCaseFolder(oldPath, newPath);
const originalLinks = isLinked ? await readLinkedCases() : undefined;
// The settings.json values this rename replaced, set only once it wrote them. A failure puts back
// these keys alone, so a settings save that landed in between is kept.
let settingsUndo: Record<string, unknown> | undefined;
try {
// settings.json is server-wide and caseOrder/lastUsedCase name the admin's cases, so only the
// admin's rename touches it (a non-admin's own rename must not rewrite someone else's list).
// Written before linked-cases.json so a failure here leaves the registry untouched.
if (isAdmin(req)) {
const { before, after } = await mutateSettingsFile((existing) => {
const order = renameInOrder(existing.caseOrder, name, newName);
const last = existing.lastUsedCase === name ? newName : undefined;
if (!order && !last) return undefined;
return { ...existing, ...(order ? { caseOrder: order } : {}), ...(last ? { lastUsedCase: last } : {}) };
});
if (after) {
const keys = ['caseOrder', 'lastUsedCase'].filter((k) => after[k] !== before[k]);
settingsUndo = Object.fromEntries(keys.map((k) => [k, before[k]]));
}
}
if (originalLinks) {
const links = { ...originalLinks };
delete links[name];
links[newName] = newPath;
try {
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(links, null, 2));
} catch (err) {
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(originalLinks, null, 2)).catch(() => {});
throw err;
}
}
} catch (err) {
// The folder moved but the registries did not follow: put settings.json and the folder back
// rather than leave a case whose name and folder disagree.
const undo = settingsUndo;
if (undo) await mutateSettingsFile((current) => ({ ...current, ...undo })).catch(() => {});
if (moveFolder) {
const movedBack = await fs.rename(newPath, oldPath).then(
() => true,
() => false
);
if (!movedBack) {
throw new Error(`${getErrorMessage(err)}. The folder could not be moved back and is now at ${newPath}.`);
}
}
throw err;
}
if (moveFolder) await moveClaudeHistory(oldPath, newPath);
// The owner's default session group is keyed by case name: carry it over, best effort.
const layoutOwner = ownerLayoutKey(ownerFor(req));
try {
const { groupId } = await ctx.tabLayouts.caseDefaultGroup(layoutOwner, name);
if (groupId) await ctx.tabLayouts.setCaseDefaultGroup(layoutOwner, newName, groupId);
} catch {
/* the group is a convenience; the rename already happened */
}
await ctx.tabLayouts.forgetCaseDefaultGroup(layoutOwner, name).catch(() => undefined);
});
ctx.broadcast(SseEvent.CaseRenamed, { oldName: name, newName, oldPath, newPath, folderMoved: moveFolder });
return {
success: true,
data: { oldName: name, newName, oldPath, newPath, folderMoved: moveFolder, stoppedSessions: open.length },
};
} catch (err) {
if (err instanceof CaseRenameError) {
const status = err.code === 'BUSY' ? 409 : err.code === 'EXISTS' ? 409 : err.code === 'LOCKED' ? 423 : 400;
const code =
err.code === 'EXISTS'
? ApiErrorCode.ALREADY_EXISTS
: err.code === 'BUSY'
? ApiErrorCode.CONFLICT
: ApiErrorCode.OPERATION_FAILED;
return fail(status, code, err.message, { renameCode: err.code, stoppedSessions: stopped });
}
return fail(500, ApiErrorCode.OPERATION_FAILED, getErrorMessage(err), { stoppedSessions: stopped });
}
});
// ========== Reorder Cases ==========
app.put('/api/cases/order', async (req): Promise<ApiResponse<{ order: string[] }>> => {
app.put('/api/cases/order', async (req, reply): Promise<ApiResponse<{ order: string[] }> | undefined> => {
// `caseOrder` is ONE list in the server-wide settings.json, read by every user's
// GET /api/cases, so a non-admin's reorder would replace everyone's order (and
// publish their case names to every user through GET /api/settings). Admin-only in
// multi-user mode, like PUT /api/settings; a per-user order would need per-user
// storage. No-op in single-user mode.
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
const { order } = parseBody(CaseOrderSchema, req.body, 'Invalid order data');
try {
const dir = getDataDir();
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
let existing: Record<string, unknown> = {};
try {
existing = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf-8'));
} catch {
/* ignore */
}
const merged = { ...existing, caseOrder: order };
await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2));
await mutateSettingsFile((current) => ({ ...current, caseOrder: order }));
ctx.broadcast(SseEvent.CaseOrderChanged, { order });
return { success: true, data: { order } };
} catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
return settingsWriteErrorResponse(err);
}
});
@@ -1618,7 +2145,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
return {
name,
path: dockerDisplayPath({ container, path: dockerCase.hostWorkspacePath }),
hasClaudeMd: existsSync(join(dockerCase.hostWorkspacePath, 'CLAUDE.md')),
hasClaudeMd: await boundedPathExists(join(dockerCase.hostWorkspacePath, 'CLAUDE.md')),
location: 'docker',
docker: {
hostId: host.id,
@@ -1633,16 +2160,32 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
}
const casePath = await resolveCasePath(name, getAuthUser(req));
const linked = casePath !== join(resolveCasesDir(getAuthUser(req)), name);
if (!existsSync(casePath)) {
// NOT_FOUND means DEFINITELY absent: the Run button creates a case on it, so
// a path that merely did not answer (a dead network mount) must never get it.
// One path, asked for explicitly: probe it even while unrelated mounts are dead.
const state = await probePath(casePath, { pastCap: true });
if (state === 'absent') {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Case not found');
}
if (state === 'unknown') {
// The linked registry knows where the case lives, so say where, and that
// it is not answering. A local case has no such record to fall back on.
if (!linked) {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
describeUnknownPath('Case folder', casePath, { pastCap: true })
);
}
return { name, path: casePath, hasClaudeMd: false, linked: true, unreachable: true };
}
const linked = casePath !== join(resolveCasesDir(getAuthUser(req)), name);
return {
name,
path: casePath,
hasClaudeMd: existsSync(join(casePath, 'CLAUDE.md')),
// Probed like the folder above, or a healthy case reads as having no CLAUDE.md under the cap.
hasClaudeMd: (await probePath(join(casePath, 'CLAUDE.md'), { pastCap: true })) === 'present',
...(linked && { linked: true }),
};
});
@@ -1660,7 +2203,11 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
const fixPlanPath = join(casePath, '@fix_plan.md');
if (!existsSync(fixPlanPath)) {
const fixPlanState = await probePath(fixPlanPath, { pastCap: true });
if (fixPlanState === 'unknown') {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Case folder is not responding or not readable');
}
if (fixPlanState === 'absent') {
return { exists: false, content: null, todos: [] };
}
+95 -8
View File
@@ -29,7 +29,11 @@
* write path (`registry-writer.ts` mirrors `custom-model-hosts.ts`).
*/
import { spawn } from 'node:child_process';
import { execFile, spawn } from 'node:child_process';
import { constants as fsConstants } from 'node:fs';
import { access } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { promisify } from 'node:util';
import type { FastifyInstance, FastifyRequest } from 'fastify';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
import { getAuthUser, isAdmin, parseBody, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js';
@@ -207,14 +211,65 @@ const CLI_INSTALL_TIMEOUT_MS = 300_000;
*/
const installsInFlight = new Set<string>();
const execFileAsync = promisify(execFile);
/**
* True when `npm install -g` can write to this process's npm global prefix, or when that cannot
* be determined (then nothing is redirected: a wrong guess would move installs somewhere the
* user did not choose). `npm config get prefix` is asked rather than guessed from `process.execPath`
* because a user `.npmrc` / `NPM_CONFIG_PREFIX` can point it anywhere.
*
* Async so the server keeps serving while npm boots (130 to 240 ms), and killed with SIGKILL on
* timeout because `SIGTERM` alone leaves the wait running. A prefix that does not exist yet is
* judged by the nearest ancestor that does: npm creates the missing directories, so a user
* `.npmrc` pointing at `~/.npm-global` before it was made is not moved.
*/
export async function npmGlobalPrefixWritable(source: NodeJS.ProcessEnv): Promise<boolean> {
try {
const { stdout } = await execFileAsync('npm', ['config', 'get', 'prefix'], {
env: source,
encoding: 'utf8',
timeout: 5_000,
killSignal: 'SIGKILL',
});
const prefix = stdout.trim();
if (!prefix) return true;
// npm creates lib/node_modules under the prefix; walk up to the first directory that exists.
let dir = join(prefix, 'lib', 'node_modules');
for (;;) {
try {
await access(dir, fsConstants.W_OK);
return true;
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') return false;
const parent = dirname(dir);
if (parent === dir) return false;
dir = parent;
}
}
} catch {
return true;
}
}
/**
* The server's environment minus every `CODEMAN_*` variable. An install script is third-party
* code, and those variables carry Codeman's own secrets and wiring (`CODEMAN_PASSWORD`, the
* data dir, the tmux socket), none of which an installer needs. Inside the Docker Compose
* container (`CODEMAN_IN_CONTAINER=1`) it also points `NPM_CONFIG_PREFIX` at `$HOME/.local`,
* so an `npm install -g` lands on the persistent home mount instead of the image.
* data dir, the tmux socket), none of which an installer needs.
*
* It also points `NPM_CONFIG_PREFIX` at `$HOME/.local` so an `npm install -g` lands somewhere the
* server user can write and Codeman's resolvers already search (`~/.local/bin`):
* - inside the Docker Compose container (`CODEMAN_IN_CONTAINER=1`), so installs survive an image
* update (the image's own prefix is image content);
* - on a native install whose npm global prefix is not writable by the server user (a system node
* under `/usr`, installed by root). Without this `npm install -g` died with EACCES (exit 243),
* e.g. DeepSeek's `npm install -g @deepseek-ai/dsh`. An explicit `NPM_CONFIG_PREFIX` the
* operator set is respected, and so is a prefix that is writable (nvm, `~/.npm-global`, ...).
*/
export function installEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
export function installEnv(
source: NodeJS.ProcessEnv = process.env,
prefixWritable: (env: NodeJS.ProcessEnv) => boolean = () => true
): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {};
for (const [key, value] of Object.entries(source)) {
if (!key.startsWith('CODEMAN_')) env[key] = value;
@@ -224,11 +279,40 @@ export function installEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.Proc
// vanishes. HOME is the persistent bind mount and `~/.local/bin` is already on every resolver's search
// list, so npm-based installs are redirected there. curl|bash installers already target HOME.
if (source.CODEMAN_IN_CONTAINER === '1' && source.HOME) {
env.NPM_CONFIG_PREFIX = `${source.HOME}/.local`;
redirectNpmPrefix(env, source.HOME);
} else if (process.platform !== 'win32' && source.HOME && !source.NPM_CONFIG_PREFIX && !prefixWritable(env)) {
redirectNpmPrefix(env, source.HOME);
}
return env;
}
/**
* Point npm at `$HOME/.local`, dropping every spelling of the prefix key first. `npm run` exports a
* lowercase `npm_config_prefix`, npm reads `npm_config_*` case-insensitively, and when both spellings
* are present a `/bin/sh` that sorts its environment (bash) lets the older value win. The explicit
* operator guard in `installEnv` stays on the uppercase key only: npm always injects the lowercase one.
*/
function redirectNpmPrefix(env: NodeJS.ProcessEnv, home: string): void {
for (const key of Object.keys(env)) if (/^npm_config_prefix$/i.test(key)) delete env[key];
env.NPM_CONFIG_PREFIX = `${home}/.local`;
}
/**
* `installEnv` for this process, with the (async) npm prefix probe done first and only when the
* command runs npm at all: a `curl | bash` installer never pays for it.
*/
async function installEnvFor(command: string, source: NodeJS.ProcessEnv = process.env): Promise<NodeJS.ProcessEnv> {
const usesNpm = /\bnpm\b/.test(command);
const probeNeeded =
usesNpm &&
source.CODEMAN_IN_CONTAINER !== '1' &&
process.platform !== 'win32' &&
!!source.HOME &&
!source.NPM_CONFIG_PREFIX;
const writable = probeNeeded ? await npmGlobalPrefixWritable(installEnv(source)) : true;
return installEnv(source, () => writable);
}
interface InstallResult {
code: number | null;
output: string;
@@ -244,6 +328,7 @@ interface InstallResult {
* CUSTOM entry can never reach this function at all — see the route's own guard below.
*/
async function runInstallCommand(command: string): Promise<InstallResult> {
const env = await installEnvFor(command);
return new Promise((resolve) => {
let child: ReturnType<typeof spawn>;
try {
@@ -255,7 +340,7 @@ async function runInstallCommand(command: string): Promise<InstallResult> {
// out into package-manager children, and spawn's own `timeout` option signals
// only the direct child, leaving survivors holding the pipes open forever.
detached: true,
env: installEnv(),
env,
});
} catch (err) {
resolve({ code: null, output: `spawn failed: ${getErrorMessage(err)}`, timedOut: false });
@@ -312,7 +397,9 @@ export function registerCliRegistryRoutes(app: FastifyInstance): void {
// admin/settings surface; every SPAWN-time caller elsewhere uses
// enabledClis() instead). Deliberately excludes launch/env/capabilities/
// overlays/discovery — the same rule every other catalogue-export surface in
// this codebase follows.
// this codebase follows, with one deliberate exception: the page catalog
// (`window.__codemanCliCatalog`, server.ts) carries `capabilities.external`,
// which Session Options reads to keep Claude's Respawn and Ralph tabs.
//
// NOT gated on cliManagementEnabled: reading the list is cheap and is not
// the risky part. The Settings UI section simply never fetches this while
+305
View File
@@ -0,0 +1,305 @@
/**
* @fileoverview Config backups (src/config-backup.ts).
*
* GET /api/config-backups — settings in effect, the folder, and the snapshots in it.
* POST /api/config-backups — snapshot now (a manual request always makes one).
* POST /api/config-backups/:id/restore — put a snapshot's files back (after a `pre-restore` snapshot).
* GET /api/config-backups/diff?from=&to= — what differs between two snapshots, or a snapshot and the
* running config (`current`, the default for `to`).
*
* The files include credentials (`.env`, `users.json`, endpoint keys), so in multi-user mode all four
* are admin only. Responses carry file NAMES and sizes, never file content, except the diff, which shows
* changed values but never a credential's (`.env`, `users.json`, `push-keys.json`, `webhook.json`, `intents.json` and any key that looks
* like a token/secret/password are reported as "changed" only). Restore replaces files the running server
* may have cached: `restartRequired` says which ones only apply after a restart. A restore that could not
* write every file answers 500 with the error envelope plus `data` (`restored`, `failed`, `untouched`,
* `safetySnapshot`, `restartRequired`): the files it wrote stay restored and are reloaded like a full one.
*/
import { readFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
import { getAuthUser, isAdmin, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js';
import { appendAdminAudit } from '../admin-audit.js';
import { reloadCliRegistry } from '../../config/cli-registry/registry.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import { getDataDir } from '../../config/instance.js';
import { intentStore } from '../../intent-store.js';
import { describeUnknownPath, probePathKind } from '../../utils/bounded-path-probe.js';
import { dirname } from 'node:path';
import {
CONFIG_BACKUP_FILES,
CONFIG_DIFF_CURRENT,
ConfigBackupCorruptError,
ConfigBackupNotFoundError,
ConfigBackupPartialRestoreError,
defaultConfigBackupDir,
diffConfigBackups,
listConfigBackups,
readConfigBackupSettings,
resolveConfigBackupDir,
restoreConfigBackup,
runConfigBackupPass,
withRecordedRetention,
readConfigBackupOrigin,
type ConfigBackupResult,
type ConfigBackupSettings,
type ConfigBackupSummary,
type ConfigDiff,
} from '../../config-backup.js';
// Only recorded in each manifest; a missing package.json just leaves it blank.
const APP_VERSION: string = (() => {
try {
return (createRequire(import.meta.url)('../../../package.json') as { version?: string }).version ?? '';
} catch {
return '';
}
})();
/** Files whose running-process copy only reloads on restart (the rest are read per request; `users.json`
* is re-read within a second, `clis.json` is reloaded below, `intents.json` by `reload()`). */
const RESTART_REQUIRED = new Set<string>(['.env', 'push-keys.json', 'settings.json']);
export interface ConfigBackupStatus {
settings: ConfigBackupSettings;
dir: string;
defaultDir: string;
tracked: readonly string[];
backups: ConfigBackupSummary[];
}
/**
* The settings in effect and the folder they name. A custom folder is probed (bounded, #516) BEFORE
* the recorded-retention fallback reads it, so this throws `BackupDirUnavailableError` for a folder
* that does not answer and never lists it; `present` is false for a custom folder that does not exist yet.
*/
export async function readEffectiveBackupSettings(): Promise<{
settings: ConfigBackupSettings;
dir: string;
present: boolean;
}> {
const raw = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {});
const base = readConfigBackupSettings(raw);
const dir = resolveConfigBackupDir(base, getDataDir());
const present = await probeBackupDir(base, dir);
// The same fallback the timer pass uses, so the form never shows 20 / 30 while a pass keeps 100 / 0.
const settings = present ? withRecordedRetention(base, raw, dir, readConfigBackupOrigin(getDataDir())) : base;
return { settings, dir, present };
}
/** A user-chosen backup folder that is not safe to touch (bounded probe, as for case paths, #516). */
export class BackupDirUnavailableError extends Error {}
/**
* Probe a custom backup folder without blocking the event loop on a dead mount. The default folder
* needs no probe. Returns whether the folder exists; throws when it did not answer, or is not a
* folder. A missing custom folder is only creatable when its parent is present.
*/
export async function probeBackupDir(settings: ConfigBackupSettings, dir: string): Promise<boolean> {
if (!settings.dir || dir === defaultConfigBackupDir(getDataDir())) return true;
const kind = await probePathKind(dir, { pastCap: true });
if (kind === 'unknown')
throw new BackupDirUnavailableError(describeUnknownPath('Backup folder', dir, { pastCap: true }));
if (kind === 'file') throw new BackupDirUnavailableError(`Backup folder is a file, not a folder: ${dir}`);
if (kind === 'directory') return true;
const parent = await probePathKind(dirname(dir), { pastCap: true });
if (parent === 'unknown') {
throw new BackupDirUnavailableError(describeUnknownPath('Backup folder', dirname(dir), { pastCap: true }));
}
if (parent !== 'directory') throw new BackupDirUnavailableError(`Backup folder's parent is missing: ${dirname(dir)}`);
return false;
}
async function readSettingsForBackup(): Promise<Record<string, unknown> | 'unreadable'> {
try {
const parsed: unknown = JSON.parse(await readFile(SETTINGS_PATH, 'utf8'));
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: 'unreadable';
} catch (err) {
return (err as NodeJS.ErrnoException).code === 'ENOENT' ? {} : 'unreadable';
}
}
/** Snapshot (when changed) and apply retention; shared by the timer and the manual route. */
export async function runConfigBackup(
reason: string,
opts: { force?: boolean } = {}
): Promise<{ result: ConfigBackupResult; removed: string[] } | null> {
// Read settings.json directly: readJsonConfig answers {} for ANY failure, so a syntax error (or, before
// settings writes became atomic, a read that landed inside one) used to prune with the defaults (20 / 30
// days) and drop the longer history the user asked to keep.
const raw = await readSettingsForBackup();
const unreadable = raw === 'unreadable';
const fromFile = readConfigBackupSettings(unreadable ? {} : raw);
if (!fromFile.enabled && !opts.force) return null;
const dir = resolveConfigBackupDir(fromFile, getDataDir());
await probeBackupDir(fromFile, dir);
return runConfigBackupPass({
dataDir: getDataDir(),
backupDir: dir,
reason,
appVersion: APP_VERSION,
force: opts.force,
settings: fromFile,
raw: unreadable ? {} : raw,
// An unreadable settings.json still gets its snapshot, but no pass deletes anything on its say-so.
prune: !unreadable,
});
}
export interface ConfigBackupRouteDeps {
/** Re-read discord.json into the running bridge (it caches its config and would keep the pre-restore token). */
reloadDiscord?: () => Promise<void>;
}
export function registerConfigBackupRoutes(app: FastifyInstance, deps: ConfigBackupRouteDeps = {}): void {
const deny = (req: FastifyRequest, reply: FastifyReply): ApiResponse<never> | null => {
if (isMultiUserMode() && !isAdmin(req)) {
reply.code(403);
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
}
return null;
};
app.get('/api/config-backups', async (req, reply): Promise<ApiResponse<ConfigBackupStatus>> => {
const denied = deny(req, reply);
if (denied) return denied;
let eff: Awaited<ReturnType<typeof readEffectiveBackupSettings>>;
try {
eff = await readEffectiveBackupSettings();
} catch (err) {
reply.code(503);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
const { settings, dir, present } = eff;
// Read-only: a listing must not create config-backup-id, and without one nothing can be ours.
const origin = readConfigBackupOrigin(getDataDir());
return {
success: true,
data: {
settings,
dir,
defaultDir: defaultConfigBackupDir(getDataDir()),
tracked: CONFIG_BACKUP_FILES,
backups: present && origin ? listConfigBackups(dir, origin) : [],
},
};
});
app.post('/api/config-backups', async (req, reply) => {
const denied = deny(req, reply);
if (denied) return denied;
try {
const out = await runConfigBackup('manual', { force: true });
return { success: true, data: { ...out!.result, removed: out!.removed } };
} catch (err) {
reply.code(err instanceof BackupDirUnavailableError ? 503 : 500);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Backup failed: ${getErrorMessage(err)}`);
}
});
app.get('/api/config-backups/diff', async (req, reply): Promise<ApiResponse<ConfigDiff>> => {
const denied = deny(req, reply);
if (denied) return denied;
const q = req.query as { from?: string; to?: string };
if (typeof q.from !== 'string' || !q.from) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, '`from` is required (a backup id or "current")');
}
try {
const { dir } = await readEffectiveBackupSettings();
const data = diffConfigBackups({
dataDir: getDataDir(),
backupDir: dir,
from: q.from,
to: typeof q.to === 'string' && q.to ? q.to : CONFIG_DIFF_CURRENT,
});
return { success: true, data };
} catch (err) {
if (err instanceof ConfigBackupNotFoundError) {
reply.code(404);
return createErrorResponse(ApiErrorCode.NOT_FOUND, err.message);
}
if (err instanceof ConfigBackupCorruptError) {
reply.code(409);
return createErrorResponse(ApiErrorCode.CONFLICT, err.message);
}
reply.code(err instanceof BackupDirUnavailableError ? 503 : 500);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Diff failed: ${getErrorMessage(err)}`);
}
});
/**
* What a restore that wrote files must do, whether or not every file made it: reload what the running
* server caches, audit it, and name the files that only apply after a restart.
*/
const afterRestore = (req: FastifyRequest, id: string, restored: string[], failed: string[] = []): string[] => {
// IntentStore caches the file and rewrites it whole on the next prompt, which would undo the restore.
if (restored.includes('intents.json')) intentStore.reload();
// restoreConfigBackup writes clis.json directly (the one writer besides mutateRegistryFile).
if (restored.includes('clis.json')) reloadCliRegistry();
// The Discord bridge caches discord.json and rewrites nothing from it, so a restore must reach the running bridge.
if (restored.includes('discord.json')) void deps.reloadDiscord?.().catch(() => undefined);
// A restore of users.json replaces accounts, password hashes, roles and bypass grants.
if (isMultiUserMode()) {
void appendAdminAudit({
admin: getAuthUser(req).username,
action: 'config_backup_restore',
target: id,
ip: req.ip,
detail: { restored, ...(failed.length > 0 && { failed }) },
});
}
return restored.filter((n) => RESTART_REQUIRED.has(n));
};
app.post('/api/config-backups/:id/restore', async (req, reply) => {
const denied = deny(req, reply);
if (denied) return denied;
const { id } = req.params as { id: string };
try {
const { dir } = await readEffectiveBackupSettings();
const out = restoreConfigBackup({ dataDir: getDataDir(), backupDir: dir, id, appVersion: APP_VERSION });
return {
success: true,
data: {
restored: out.restored,
untouched: out.untouched,
safetySnapshot: out.safetySnapshot,
restartRequired: afterRestore(req, id, out.restored),
},
};
} catch (err) {
if (err instanceof ConfigBackupNotFoundError) {
reply.code(404);
return createErrorResponse(ApiErrorCode.NOT_FOUND, err.message);
}
if (err instanceof ConfigBackupCorruptError) {
reply.code(409);
return createErrorResponse(ApiErrorCode.CONFLICT, `${err.message}. Nothing was changed.`);
}
if (err instanceof ConfigBackupPartialRestoreError) {
// Some files are the backup's now: the client must learn which (Settings re-reads a restored
// settings.json, or its next Save writes the old values back) and where the undo copy is.
const failedNames = err.failed.map((f) => f.name);
const restartRequired = afterRestore(req, id, err.restored, failedNames);
reply.code(500);
return {
...createErrorResponse(ApiErrorCode.OPERATION_FAILED, err.message),
data: {
restored: err.restored,
failed: err.failed,
untouched: err.untouched,
safetySnapshot: err.safetySnapshot,
restartRequired,
},
};
}
reply.code(err instanceof BackupDirUnavailableError ? 503 : 500);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Restore failed: ${getErrorMessage(err)}`);
}
});
}
+194 -23
View File
@@ -21,7 +21,7 @@ import { isMultiUserMode } from '../../config/multiuser.js';
import { getDataDir } from '../../config/instance.js';
import { isBlockedWebviewUrl } from '../webview-egress-policy.js';
import { egressBlockedReason, webviewFetch } from '../webview-egress.js';
import { CustomModelHostSchema } from '../schemas.js';
import { CustomModelHostSchema, MAX_DISCOVERED_CONTEXT_TOKENS } from '../schemas.js';
import { readCustomModelHosts, writeCustomModelHosts, type CustomModelHost } from '../../custom-model-hosts.js';
import type { CliEntry } from '../../config/cli-registry/types.js';
@@ -56,11 +56,31 @@ export const CLAUDE_MIN_SAFE_CONTEXT_TOKENS = 40000;
*/
export function exceedsSafeContextFloor(
entry: Pick<CliEntry, 'capabilities'>,
contextLength: number | undefined
contextLength: number | undefined,
slotCount?: number
): boolean {
const cap = entry.capabilities.customModelInjection;
if (cap.kind !== 'env' || !cap.contextLengthVar) return false;
return typeof contextLength === 'number' && contextLength < CLAUDE_MIN_SAFE_CONTEXT_TOKENS;
if (typeof contextLength !== 'number') return false;
if (contextLength < CLAUDE_MIN_SAFE_CONTEXT_TOKENS) return true;
return perSlotContext(contextLength, slotCount) < CLAUDE_MIN_SAFE_CONTEXT_TOKENS;
}
/**
* The context one request can rely on when the server runs `slotCount` parallel slots.
* llama-server divides its context across `--parallel` slots (by default they share ONE
* unified KV pool), and Claude Code sends concurrent requests: the main session plus every
* parallel subagent, each carrying the full `CLAUDE_MIN_SAFE_CONTEXT_TOKENS` of its own
* system prompt and tools. With N busy slots each request therefore gets roughly
* `contextLength / N`, and once the pool is spent llama.cpp answers HTTP 500 "Context size
* has been exceeded" (measured: 4 slots, ~131k pool, four simultaneous 40k-token requests
* all failed; the same requests against one slot all succeeded). An unknown or single slot
* count leaves the context whole.
*/
export function perSlotContext(contextLength: number, slotCount: number | undefined): number {
const slots =
typeof slotCount === 'number' && Number.isFinite(slotCount) && slotCount > 1 ? Math.floor(slotCount) : 1;
return Math.floor(contextLength / slots);
}
function adminOnly(req: FastifyRequest, reply: { code: (n: number) => unknown }): ApiResponse<never> | null {
@@ -109,7 +129,7 @@ function applyStoredApiKey(incoming: CustomModelHost, existing: CustomModelHost)
}
/**
* `modelContextLengths`/`modelSizesGB` are server-populated by discovery, never
* `modelContextLengths`/`modelSlotCounts`/`modelSizesGB` are server-populated by discovery, never
* user-entered, and PUT replaces the whole record — so merge them back in from the
* stored host rather than trust whatever the editor's body carried (or omitted).
* The editor only ever sends `models`/`lastDiscoveredAt` verbatim from its cached
@@ -117,7 +137,12 @@ function applyStoredApiKey(incoming: CustomModelHost, existing: CustomModelHost)
* future caller forgets, same class of bug `applyStoredApiKey` exists to prevent.
*/
function applyDiscoveredFields(incoming: CustomModelHost, existing: CustomModelHost): CustomModelHost {
return { ...incoming, modelContextLengths: existing.modelContextLengths, modelSizesGB: existing.modelSizesGB };
return {
...incoming,
modelContextLengths: existing.modelContextLengths,
modelSlotCounts: existing.modelSlotCounts,
modelSizesGB: existing.modelSizesGB,
};
}
function authHeaders(host: Pick<CustomModelHost, 'apiKey' | 'authStyle'>): Record<string, string> {
@@ -133,8 +158,15 @@ function authHeaders(host: Pick<CustomModelHost, 'apiKey' | 'authStyle'>): Recor
export interface DiscoveryResult {
models: string[];
/** See `CustomModelHost.modelContextLengths` — only ever populated for models already loaded. */
/**
* See `CustomModelHost.modelContextLengths`: populated from a model's own `/v1/models`
* entry (`context_length`, else `context_window`) whenever the server states one, and
* from the launch command or `/props` for loaded models, which overwrite (and so win
* over) any listed figure for the same model.
*/
contextLengths: Record<string, number>;
/** See `CustomModelHost.modelSlotCounts`; populated only for models the server reports loaded (launch command or `/props`). No listing states a slot count, unlike `contextLengths`. */
slotCounts: Record<string, number>;
/** See `CustomModelHost.modelSizesGB` — populated for every model whose own listing states one. */
sizesGB: Record<string, number>;
}
@@ -155,9 +187,23 @@ function parseSizeGB(description: unknown): number | undefined {
return Number.isFinite(size) && size > 0 ? size : undefined;
}
/**
* Best-effort: reads a context length (tokens) stated by a model's OWN entry in
* `GET /v1/models`. OpenRouter-style servers call the field `context_length`; current
* llama-swap sends both `context_length` and `context_window` per model, and current
* Bifrost (a gateway in front of llama-swap) passes `context_window` through. Older
* servers send neither, and a server that states nothing gets no entry, never a guess.
* Non-numeric, zero, negative, fractional or over-the-ceiling values are ignored, the
* same rule the stored field's schema enforces: a wrong figure here is worse than none.
*/
function parseListedContextLength(value: unknown): number | undefined {
if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) return undefined;
return value <= MAX_DISCOVERED_CONTEXT_TOKENS ? value : undefined;
}
/**
* Best-effort: fetches `GET /props?model=<id>` (llama.cpp-native, llama-swap-proxied) for
* ONE already-loaded model and pulls its real `n_ctx` out. Never called for a model that
* ONE already-loaded model and pulls its `n_ctx` and `total_slots` out. Never called for a model that
* isn't already loaded — see the caller and `CustomModelHost.modelContextLengths` for why
* that's a hard safety requirement, not just a nicety: llama-swap treats this endpoint's
* `?model=` as a routing hint, and asking it about an unloaded model risks triggering an
@@ -173,20 +219,39 @@ function parseSizeGB(description: unknown): number | undefined {
* (below), which reads the actual launch flag `/running` reports, is the primary source;
* this is only used when that parse comes up empty (no recognized flag in `cmd`, or `cmd`
* itself unavailable).
*
* ⚠️ `/props` only ever RAISES the slot warning, never skips it: its `n_ctx` is the context
* fallback when `cmd` states no context flag at all, and `total_slots` is the slot count when
* `cmd` has no `--parallel`. It never widens the pool the slot check divides. And it is not
* free: llama-swap routes it through the model's process handler, which refreshes the model's
* last-use time, so probing a model with a `ttl` on every 5-minute sweep would keep it loaded
* forever. `discoverModels` therefore probes once per launch command (`propsByCmd`).
*
* Returns `undefined` when the request itself failed (network error, timeout, unreadable body),
* which is retried next sweep; a non-2xx status, or a 2xx without the fields, gives an object with
* them unset, which `propsForLaunch` remembers.
*/
async function fetchContextLength(
async function fetchServerProps(
host: Pick<CustomModelHost, 'baseUrl'>,
modelId: string,
headers: Record<string, string>
): Promise<number | undefined> {
): Promise<{ nCtx?: number; totalSlots?: number } | undefined> {
try {
const url = new URL(`${host.baseUrl.replace(/\/+$/, '')}/props`);
url.searchParams.set('model', modelId);
const res = await webviewFetch(url, { headers, signal: AbortSignal.timeout(PROPS_TIMEOUT_MS) });
if (!res.ok) return undefined;
const body = (await res.json()) as { n_ctx?: unknown; default_generation_settings?: { n_ctx?: unknown } };
// A non-2xx answer (an upstream that does not serve /props) is a real answer: empty, so it is
// remembered per launch command instead of being re-probed every sweep.
if (!res.ok) return {};
const body = (await res.json()) as {
n_ctx?: unknown;
total_slots?: unknown;
default_generation_settings?: { n_ctx?: unknown };
};
const nCtx = body.n_ctx ?? body.default_generation_settings?.n_ctx;
return typeof nCtx === 'number' && Number.isFinite(nCtx) && nCtx > 0 ? nCtx : undefined;
const positive = (v: unknown): number | undefined =>
typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined;
return { nCtx: positive(nCtx), totalSlots: positive(body.total_slots) };
} catch {
return undefined;
}
@@ -196,7 +261,7 @@ async function fetchContextLength(
* Parses the REAL configured context size out of llama-swap's own launch command for a
* model (`/running`'s `cmd` field, e.g. `"llama-server -m ... --fit-ctx 16384 ..."`) —
* the primary source for `modelContextLengths`, preferred over `/props`'s `n_ctx` (see
* `fetchContextLength`'s own doc comment for why that field is unreliable here). Checks
* `fetchServerProps`'s own doc comment for why that field is unreliable here). Checks
* `--fit-ctx` first (llama-swap's own auto-fit flag), then the plain llama.cpp
* `-c`/`--ctx-size`/`--ctx_size` flags a hand-written launch command might use instead.
* Returns `undefined` when `cmd` has none of these — not every launch command needs to
@@ -211,6 +276,51 @@ function parseCtxFromCmd(cmd: unknown): number | undefined {
return Number.isFinite(value) && value > 0 ? value : undefined;
}
/**
* Parses an explicit `--parallel N` / `-np N` (or `--parallel=N`) out of a launch command.
* Returns `undefined` when the command states none, including `-np -1` (llama.cpp's "pick
* automatically"), because a llama-server started without it chooses the slot count itself
* (4 on the build this was measured on) and only `/props`'s `total_slots` can say what it chose.
*/
function parseSlotsFromCmd(cmd: unknown): number | undefined {
if (typeof cmd !== 'string') return undefined;
const match = /(?:^|\s)(?:-np|--parallel)(?:\s+|=)(\d+)(?=\s|$)/.exec(cmd);
if (!match) return undefined;
const value = Number(match[1]);
return Number.isFinite(value) && value > 0 ? value : undefined;
}
/** The `/props` answer for the launch command a model was last probed under, by host and model id. */
const propsByCmd = new Map<string, { cmd: string; props: { nCtx?: number; totalSlots?: number } }>();
/** Forget what was probed (tests only: a changed launch command already probes again). */
export function clearPropsProbeCache(): void {
propsByCmd.clear();
}
/**
* `/props` for one loaded model, but only once per launch command: the probe refreshes the model's
* last-use time in llama-swap, so repeating it on every rediscovery sweep would stop a model with a
* `ttl` from ever unloading. A changed `cmd` (a reloaded or reconfigured model) probes again; a
* `cmd` that is not a string cannot be told apart, so it is probed each time; a probe that did not
* answer (network error, timeout) is not remembered; a non-2xx status is. Held in memory only, so a restart probes once more.
*/
async function propsForLaunch(
host: Pick<CustomModelHost, 'baseUrl'>,
modelId: string,
cmd: unknown,
headers: Record<string, string>
): Promise<{ nCtx?: number; totalSlots?: number } | undefined> {
const key = `${host.baseUrl}\0${modelId}`;
if (typeof cmd === 'string') {
const cached = propsByCmd.get(key);
if (cached?.cmd === cmd) return cached.props;
}
const props = await fetchServerProps(host, modelId, headers);
if (props && typeof cmd === 'string') propsByCmd.set(key, { cmd, props });
return props;
}
async function discoverModels(
host: Pick<CustomModelHost, 'baseUrl' | 'apiKey' | 'authStyle'>
): Promise<DiscoveryResult> {
@@ -221,24 +331,42 @@ async function discoverModels(
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = (await res.json()) as {
data?: Array<{ id?: unknown; status?: { value?: unknown }; description?: unknown }>;
data?: Array<{
id?: unknown;
status?: { value?: unknown };
description?: unknown;
context_length?: unknown;
context_window?: unknown;
}>;
};
const entries = body.data ?? [];
const models = entries.map((m) => m.id).filter((id): id is string => typeof id === 'string' && id.length > 0);
const sizesGB: Record<string, number> = {};
// The lowest-precedence context source, and the only one a gateway without /running or
// /props of its own (e.g. Bifrost) can offer: the figure a model's own listing states
// (`context_length`, else `context_window`). The loaded-model pass below OVERWRITES
// these for whatever it reads from the launch command or /props, so precedence per
// model stays: `/running` cmd, then `/props`, then this listed figure.
const contextLengths: Record<string, number> = {};
for (const entry of entries) {
if (typeof entry.id !== 'string' || !entry.id) continue;
const size = parseSizeGB(entry.description);
if (size !== undefined) sizesGB[entry.id] = size;
const listed = parseListedContextLength(entry.context_length) ?? parseListedContextLength(entry.context_window);
if (listed !== undefined) contextLengths[entry.id] = listed;
}
// llama-swap-specific, feature-detected: a server that never mentions `status` on ANY
// entry gets no context-length enrichment at all, rather than treating "no status field"
// as "assume unloaded" — either reading is a guess, and skipping is the safe one, since
// fetchContextLength must only ever run against a model this server itself calls loaded.
// entry gets no launch-command/props context enrichment at all, rather than treating
// "no status field" as "assume unloaded"; either reading is a guess, and skipping is
// the safe one, since fetchContextLength must only ever run against a model this server
// itself calls loaded. Listed figures above are unaffected: reading them triggers nothing.
const hasStatusField = entries.some((m) => m && typeof m === 'object' && 'status' in m);
const contextLengths: Record<string, number> = {};
const slotCounts: Record<string, number> = {};
// Ids whose figure THIS round's loaded-model probe answered: the listed figure remains
// the source only for the ids NOT in this set (see the per-slot correction below).
const ctxFromProbe = new Set<string>();
if (hasStatusField) {
const loadedIds = entries
.filter((m) => m.status && typeof m.status === 'object' && (m.status as { value?: unknown }).value === 'loaded')
@@ -252,13 +380,51 @@ async function discoverModels(
const swapStatus = await getLlamaSwapStatus(host);
const cmdById = new Map(swapStatus.running.map((r) => [r.model, r.cmd]));
for (const id of loadedIds) {
const fromCmd = parseCtxFromCmd(cmdById.get(id));
const ctx = fromCmd ?? (await fetchContextLength(host, id, headers));
if (ctx !== undefined) contextLengths[id] = ctx;
const cmd = cmdById.get(id);
let ctx = parseCtxFromCmd(cmd);
const cmdSlots = parseSlotsFromCmd(cmd);
let slots = cmdSlots;
// /props is only needed for whatever the launch command did not answer. The context
// still comes from the command whenever it states one (see fetchServerProps's doc
// comment for why /props's n_ctx is only a fallback); the slot count usually cannot
// come from the command at all, since an unset --parallel is chosen by the server.
if (ctx === undefined || slots === undefined) {
const props = await propsForLaunch(host, id, cmd, headers);
const ctxFromProps = ctx === undefined && props?.nCtx !== undefined;
ctx ??= props?.nCtx;
slots ??= props?.totalSlots;
// With an explicit --parallel N, llama.cpp reports /props's n_ctx PER SLOT (the unified
// KV pool is only the default when the count is auto), so it is already the figure one
// request can use, so the count is 1: recording N would divide it a second time, and
// recording nothing would leave an earlier round's count in place (applyDiscoveredModels
// merges onto what was learned before).
if (cmdSlots !== undefined && ctxFromProps) slots = 1;
}
// Assigning only when a probe answered keeps the listed figure (if any) when
// neither the launch command nor /props stated one, and replaces it with the
// stronger source whenever they did: cmd > /props > listed, never the reverse.
if (ctx !== undefined) {
contextLengths[id] = ctx;
ctxFromProbe.add(id);
}
if (slots !== undefined) slotCounts[id] = slots;
}
}
}
return { models, contextLengths, sizesGB };
// llama-swap documents the LISTED `context_length` as the PER-SLOT window, so whenever
// the listed figure is the one taken this round (the model was not loaded, or the
// probe answered nothing), its slot count is 1 by definition: the floor check must NOT
// divide a per-slot figure a second time. Without recording 1 here, the count a loaded
// round stored survives the merge (applyDiscoveredModels keeps what a later round does
// not overwrite): 262144 @ 4 slots stored, model unloaded, listed 65536 → 65536 with 4
// slots → a false 16384-per-slot warning. Even a count this round's launch command
// parsed must yield: pairing a per-slot listed figure with the cmd's --parallel is the
// same double division. Same rule the /props branch above applies. Ids whose figure the
// probe DID answer keep their probe-derived count untouched.
for (const id of Object.keys(contextLengths)) {
if (!ctxFromProbe.has(id)) slotCounts[id] = 1;
}
return { models, contextLengths, slotCounts, sizesGB };
}
/**
@@ -573,7 +739,7 @@ export function triggerLlamaSwapLoad(
}
function applyDiscoveredModels(host: CustomModelHost, result: DiscoveryResult): CustomModelHost {
const { models, contextLengths, sizesGB } = result;
const { models, contextLengths, slotCounts, sizesGB } = result;
const defaultModelId = host.defaultModelId && models.includes(host.defaultModelId) ? host.defaultModelId : undefined;
// Merge onto what's already known rather than replacing: a model not probed this round
// (not currently loaded) keeps whatever context length an earlier round already learned
@@ -581,6 +747,10 @@ function applyDiscoveredModels(host: CustomModelHost, result: DiscoveryResult):
const merged = { ...host.modelContextLengths, ...contextLengths };
const kept = Object.fromEntries(Object.entries(merged).filter(([id]) => models.includes(id)));
const modelContextLengths = Object.keys(kept).length > 0 ? kept : undefined;
// Slot counts follow the same loaded-only, keep-what-an-earlier-round-learned rule.
const mergedSlots = { ...host.modelSlotCounts, ...slotCounts };
const keptSlots = Object.fromEntries(Object.entries(mergedSlots).filter(([id]) => models.includes(id)));
const modelSlotCounts = Object.keys(keptSlots).length > 0 ? keptSlots : undefined;
// sizesGB, unlike contextLengths, is populated for every model in the SAME pass (no
// loaded-only restriction — see parseSizeGB), so this is closer to a plain replace, but
// still merges onto the previous round rather than dropping a size for a model whose
@@ -593,6 +763,7 @@ function applyDiscoveredModels(host: CustomModelHost, result: DiscoveryResult):
models,
defaultModelId,
modelContextLengths,
modelSlotCounts,
modelSizesGB,
lastDiscoveredAt: new Date().toISOString(),
};
+228
View File
@@ -0,0 +1,228 @@
/**
* @fileoverview Discord bridge settings and the deliberate-post route (src/discord-bridge.ts).
*
* GET /api/discord the config WITHOUT its token, plus gateway state and last result
* PUT /api/discord change any field; an empty `token` clears it
* POST /api/discord/test post one test message to the parent channel
* POST /api/sessions/:id/discord-notify a CLI asks for the user (physical step, blocker, timed-out watch)
*
* The bot token is a bearer secret, so it is stored in its own 0600 file and never returned. In
* multi-user mode the three settings routes are admin only: the server receives every session's
* events. `discord-notify` follows the session's own ownership rules instead.
*/
import { readFile, realpath, stat } from 'node:fs/promises';
import { basename, isAbsolute, relative, sep } from 'node:path';
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
import { canAccessOwned, findSessionOrFail, getAuthUser, isAdmin, parseBody } from '../route-helpers.js';
import type { SessionPort } from '../ports/session-port.js';
import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from '../../config/attachment-guard.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import { describeUnknownPath, probePathKind } from '../../utils/bounded-path-probe.js';
import { DiscordNotifySchema, DiscordUpdateSchema } from '../schemas.js';
import {
discordConfigProblem,
MAX_FILE_BYTES,
maskToken,
readDiscordConfig,
writeDiscordConfig,
type DiscordBridge,
} from '../../discord-bridge.js';
import type { DiscordConfig, DiscordResult, DiscordStatus } from '../../types/discord.js';
export interface DiscordRouteDeps {
bridge: DiscordBridge;
configDir: string;
ctx: SessionPort;
hostTitle: () => string;
}
/**
* A Docker case exports CODEMAN_SESSION_ID cut to 8 characters, so accept a unique prefix of at
* least that length. Anything ambiguous or shorter is left as given and 404s like any unknown id.
*/
function resolveSessionId(ctx: SessionPort, req: FastifyRequest, id: string): string {
if (ctx.sessions.has(id) || id.length < 8) return id;
// Only sessions the caller may see count, so another user's session is never revealed by its prefix.
const user = getAuthUser(req);
const hits = [...ctx.sessions.values()].filter((s) => s.id.startsWith(id) && canAccessOwned(user, s.owner));
return hits.length === 1 ? hits[0].id : id;
}
// A loop or a prompt-injected agent must not ping the phones until Discord answers 429, nor hold 8 MB
// uploads in memory without bound. Per session, refilled continuously (the same shape as paste-image's).
const NOTIFY_PER_MINUTE = 6;
const notifyBuckets = new Map<string, { tokens: number; at: number }>();
function consumeNotifyToken(sessionId: string, now: number = Date.now()): boolean {
if (notifyBuckets.size > 1000) {
for (const [k, b] of notifyBuckets) if (now - b.at > 3_600_000) notifyBuckets.delete(k);
}
const b = notifyBuckets.get(sessionId) ?? { tokens: NOTIFY_PER_MINUTE, at: now };
b.tokens = Math.min(NOTIFY_PER_MINUTE, b.tokens + ((now - b.at) * NOTIFY_PER_MINUTE) / 60_000);
b.at = now;
notifyBuckets.set(sessionId, b);
if (b.tokens < 1) return false;
b.tokens -= 1;
return true;
}
/** Give back a token spent on a request that was refused before anything was posted. */
function refundNotifyToken(sessionId: string): void {
const b = notifyBuckets.get(sessionId);
if (b) b.tokens = Math.min(NOTIFY_PER_MINUTE, b.tokens + 1);
}
/** Test hook: forget every bucket between runs. */
export function _resetNotifyBuckets(): void {
notifyBuckets.clear();
}
/**
* A file a session may send: a regular file, at most MAX_FILE_BYTES, whose REAL path (symlinks
* followed) lies inside the session's working folder and is not on the attachment blocklist. The
* blocklist matters as much as the folder: with a home directory as the session folder, confinement
* alone would let a CLI be talked into posting ~/.codeman/discord.json, an ssh key or a `.env`.
* Returns the bytes, or the reason.
*/
async function fileProblem(
workingDir: string,
remote: boolean,
file: string
): Promise<{ name: string; data: Uint8Array } | string> {
if (remote) return 'Files can only be sent from a local session';
if (!isAbsolute(file)) return 'The file path must be absolute';
// Bounded first: realpath on a dead mount would hang the request.
if ((await probePathKind(workingDir, { pastCap: true })) !== 'directory') {
return describeUnknownPath('The session folder', workingDir, { pastCap: true });
}
// The agent chose this path: probe it bounded too, before realpath can hold a worker on a dead mount.
const kind = await probePathKind(file, { pastCap: true });
if (kind === 'unknown') return describeUnknownPath('The file', file, { pastCap: true });
if (kind !== 'file') return 'Not a regular file';
try {
const [root, real] = await Promise.all([realpath(workingDir), realpath(file)]);
const rel = relative(root, real);
if (rel === '' || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel))
return 'The file must be inside the session folder';
if (isBlockedAttachmentPath(real, (await loadAttachmentGuardConfig()).blockedTrees)) {
return 'That file cannot be sent: it can hold credentials';
}
const info = await stat(real);
if (!info.isFile()) return 'Not a regular file';
if (info.size > MAX_FILE_BYTES) return `The file is over ${MAX_FILE_BYTES / 1024 / 1024} MB`;
return { name: basename(real), data: new Uint8Array(await readFile(real)) };
} catch {
return 'The file could not be read';
}
}
export function registerDiscordRoutes(app: FastifyInstance, deps: DiscordRouteDeps): void {
const denied = (req: FastifyRequest, reply: FastifyReply): ApiResponse<never> | null => {
if (isMultiUserMode() && !isAdmin(req)) {
reply.code(403);
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
}
return null;
};
const status = async (): Promise<DiscordStatus> => {
const { token, ...rest } = await readDiscordConfig(deps.configDir);
return {
...rest,
hasToken: token !== '',
tokenMasked: maskToken(token),
gateway: deps.bridge.gatewayState,
gatewayError: deps.bridge.gatewayError,
lastResult: deps.bridge.lastResult,
};
};
app.get('/api/discord', async (req, reply): Promise<ApiResponse<DiscordStatus>> => {
const no = denied(req, reply);
if (no) return no;
return { success: true, data: await status() };
});
app.put('/api/discord', async (req, reply): Promise<ApiResponse<DiscordStatus>> => {
const no = denied(req, reply);
if (no) return no;
const patch = parseBody(DiscordUpdateSchema, req.body, 'Invalid Discord settings');
const current = await readDiscordConfig(deps.configDir);
const next: DiscordConfig = {
...current,
...patch,
token: patch.token !== undefined ? patch.token.trim() : current.token,
};
const problem = discordConfigProblem(next);
if (problem) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, problem);
}
try {
await writeDiscordConfig(deps.configDir, next);
await deps.bridge.reload();
} catch (err) {
reply.code(500);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
return { success: true, data: await status() };
});
app.post('/api/discord/test', async (req, reply): Promise<ApiResponse<DiscordResult>> => {
const no = denied(req, reply);
if (no) return no;
const cfg = await readDiscordConfig(deps.configDir);
if (!cfg.token || !cfg.parentChannelId) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Save the bot token and parent channel ID first');
}
// 200 even when delivery failed: the request to Codeman worked, `data.ok` says whether Discord did.
return { success: true, data: await deps.bridge.sendTest(cfg, deps.hostTitle()) };
});
app.post('/api/sessions/:id/discord-notify', async (req, reply): Promise<ApiResponse<DiscordResult | null>> => {
const { id: rawId } = req.params as { id: string };
const id = resolveSessionId(deps.ctx, req, rawId);
const { message, urgent, file } = parseBody(DiscordNotifySchema, req.body, 'Invalid Discord message');
const session = findSessionOrFail(deps.ctx, id, req);
if (!consumeNotifyToken(session.id)) {
reply.code(429);
reply.header('Retry-After', '60');
return createErrorResponse(
ApiErrorCode.RATE_LIMITED,
`Too many messages (${NOTIFY_PER_MINUTE} per minute per session)`
);
}
let upload: { name: string; data: Uint8Array } | undefined;
if (file !== undefined) {
const problem = await fileProblem(session.workingDir, !!session.remote, file);
if (typeof problem === 'string') {
refundNotifyToken(session.id);
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, problem);
}
upload = problem;
}
const result = await deps.bridge.notify(
{
event: 'session:discord-notify',
title: urgent ? 'Needs you now' : 'Message from the CLI',
body: message,
urgency: urgent ? 'critical' : 'warning',
sessionId: session.id,
sessionName: session.name,
host: deps.hostTitle(),
file: upload,
},
// deliberate: an agent's own message is never deduped, so a second, different post is not lost.
{ urgent: urgent === true, deliberate: true }
);
if (result === null) {
reply.code(409);
return createErrorResponse(ApiErrorCode.CONFLICT, 'The Discord bridge is not enabled');
}
return { success: true, data: result };
});
}
+100
View File
@@ -0,0 +1,100 @@
/**
* @fileoverview `GET /api/doctor` — the `codeman doctor` dependency report (Node, the agent CLIs,
* tmux, LibreOffice, MS Office) for Settings → System → Diagnostics.
*
* The probe engine is synchronous (`which` + `<bin> --version` per tool, each up to its own
* timeout), so it must never run on the server's event loop: a handful of slow probes would
* freeze every request and every SSE client, with the process still alive. The default runner
* therefore runs `codeman doctor --json` in a CHILD PROCESS of this same entry script and
* parses its output; the runner is injected so tests never spawn anything.
*
* Read-only, but the report names install paths and versions on the host, so in multi-user
* mode it is admin only (the same bar as the other host-introspection routes).
*/
import { execFile } from 'node:child_process';
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
import { isAdmin } from '../route-helpers.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import { TOOL_CATEGORIES } from '../../config/dependency-registry.js';
import type { DependencyReportJson } from '../../utils/dependency-report.js';
export type DoctorRunner = (category?: string) => Promise<DependencyReportJson>;
const DOCTOR_TIMEOUT_MS = 30_000;
function isReport(v: unknown): v is DependencyReportJson {
const r = v as Partial<DependencyReportJson> | null;
return !!r && Array.isArray(r.tools) && typeof r.summary === 'object' && r.summary !== null;
}
/**
* Run `doctor --json` out of process. The CLI exits non-zero when a required tool is missing,
* and still prints the report, so a non-zero exit with parseable stdout is a normal result.
*/
export const defaultDoctorRunner: DoctorRunner = (category) =>
new Promise((resolve, reject) => {
const args = [
...process.execArgv,
process.argv[1],
'doctor',
'--json',
...(category ? ['--category', category] : []),
];
execFile(
process.execPath,
args,
{ timeout: DOCTOR_TIMEOUT_MS, maxBuffer: 1024 * 1024, env: process.env },
(err, stdout) => {
if (err && (err as { killed?: boolean }).killed) {
return reject(new Error(`timed out after ${DOCTOR_TIMEOUT_MS / 1000} s`));
}
try {
const parsed: unknown = JSON.parse(stdout);
if (isReport(parsed)) return resolve(parsed);
} catch {
/* fall through to the error below */
}
reject(err ?? new Error('doctor produced no report'));
}
);
});
export function registerDoctorRoutes(app: FastifyInstance, runner: DoctorRunner = defaultDoctorRunner): void {
// Each run forks a full Node process, so two tabs or a script must not stack them: callers
// asking for the same category while one is in flight share its promise.
const inFlight = new Map<string, Promise<DependencyReportJson>>();
const runShared = (category?: string): Promise<DependencyReportJson> => {
const key = category ?? '';
let running = inFlight.get(key);
if (!running) {
running = runner(category).finally(() => inFlight.delete(key));
inFlight.set(key, running);
}
return running;
};
app.get(
'/api/doctor',
async (req: FastifyRequest, reply: FastifyReply): Promise<ApiResponse<DependencyReportJson>> => {
if (isMultiUserMode() && !isAdmin(req)) {
reply.code(403);
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
}
const { category } = req.query as { category?: string };
if (category !== undefined && !(TOOL_CATEGORIES as readonly string[]).includes(category)) {
reply.code(400);
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
`Unknown category "${category}". Valid categories: ${TOOL_CATEGORIES.join(', ')}`
);
}
try {
return { success: true, data: await runShared(category) };
} catch (err) {
reply.code(500);
return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, `doctor failed: ${getErrorMessage(err)}`);
}
}
);
}
+140
View File
@@ -0,0 +1,140 @@
/**
* @fileoverview The edit history panel's API: which files a claude session edited (main agent
* and subagents), each edit's diff, and Bash-generated images (src/web/edit-history-sync.ts).
*
* GET /api/sessions/:id/edits[?sync=1] list (no diffs); `sync=1` reads what is new first
* GET /api/sessions/:id/edits/:entryId one edit's diff as unified text
* POST /api/sessions/:id/edits/older read the next older block, then list
*
* Read-only, and reads only on these requests (the panel drives it; nothing syncs in the
* background). Remote, Docker and non-claude sessions answer `state: 'unsupported'`
* without being read. Ownership goes through `findSessionOrFail` with `req`, like every
* session-scoped route. No route here serves a file: an image entry is shown through the
* existing file routes (workspace `file-raw`, or the attachment routes outside it).
*/
import type { FastifyInstance } from 'fastify';
import { ApiErrorCode, createErrorResponse, type ApiResponse } from '../../types.js';
import { findSessionOrFail } from '../route-helpers.js';
import type { SessionPort } from '../ports/index.js';
import type { EditHistoryEntry, EditHistoryStore } from '../../edit-history-store.js';
import type { EditHunk } from '../../edit-history-parse.js';
import {
unsupportedReason,
type EditHistorySession,
type EditHistoryState,
type EditHistoryStatus,
type EditHistorySync,
} from '../edit-history-sync.js';
export interface EditHistoryListEntry extends Omit<EditHistoryEntry, 'hunks'> {
relPath: string;
}
export interface EditHistoryList {
state: EditHistoryState;
reason?: string;
backfillTruncated: boolean;
agents: Record<string, { description: string; agentType: string; worktreeBranch?: string }>;
entries: EditHistoryListEntry[];
}
export interface EditHistoryDiff {
diff: string;
truncated: boolean;
}
/** `path` relative to `base` when it lies below it, else unchanged. */
export function relativeTo(path: string, base: string | undefined): string {
if (!base) return path;
const root = base.endsWith('/') ? base : `${base}/`;
return path.startsWith(root) ? path.slice(root.length) : path;
}
/** Hunks as unified diff text, the form the Git panel's renderer reads. */
export function hunksToUnified(hunks: readonly EditHunk[]): string {
const out: string[] = [];
for (const h of hunks) {
out.push(`@@ -${h.oldStart},${h.oldLines} +${h.newStart},${h.newLines} @@`);
out.push(...h.lines);
}
return out.join('\n');
}
function listOf(
store: EditHistoryStore,
workingDir: string,
status: { state: EditHistoryState; reason?: string }
): EditHistoryList {
const agents = store.getAgents();
const entries = store.list().map(({ hunks: _hunks, ...rest }) => ({
...rest,
relPath: relativeTo(rest.path, (rest.agentId && agents[rest.agentId]?.worktreePath) || workingDir),
}));
const publicAgents: EditHistoryList['agents'] = {};
for (const [id, a] of Object.entries(agents)) {
publicAgents[id] = { description: a.description, agentType: a.agentType };
if (a.worktreeBranch) publicAgents[id].worktreeBranch = a.worktreeBranch;
}
const list: EditHistoryList = {
state: status.state,
backfillTruncated: store.backfillTruncated,
agents: publicAgents,
entries,
};
if (status.reason) list.reason = status.reason;
return list;
}
/** The answer for a session that is never read (remote, Docker, non-claude): no store involved. */
function unsupportedList(reason: string): EditHistoryList {
return { state: 'unsupported', reason, backfillTruncated: false, agents: {}, entries: [] };
}
export function registerEditHistoryRoutes(app: FastifyInstance, ctx: SessionPort, sync: EditHistorySync): void {
const sessionOf = (id: string, req: Parameters<typeof findSessionOrFail>[2]) => {
const session = findSessionOrFail(ctx, id, req);
const view: EditHistorySession = {
id: session.id,
mode: session.mode,
remote: session.remote,
docker: session.docker,
claudeSessionId: session.claudeSessionId,
claudeConfigDir: session.claudeConfigDir(),
};
return { session, view };
};
app.get('/api/sessions/:id/edits', async (req): Promise<ApiResponse<EditHistoryList>> => {
const { id } = req.params as { id: string };
const { sync: doSync } = req.query as { sync?: string };
const { session, view } = sessionOf(id, req);
const why = unsupportedReason(view);
// Never read: answer an empty list without loading (or creating) a store for it.
if (why) return { success: true, data: unsupportedList(why) };
// The sync starts in the same tick as the session check: a deletion right after it marks
// the running sync gone (EditHistorySync.remove), so nothing writes a deleted session.
const status: EditHistoryStatus = doSync === '1' ? await sync.sync(view) : sync.getStatus(session.id);
return { success: true, data: listOf(await sync.getStore(session.id), session.workingDir, status) };
});
app.post('/api/sessions/:id/edits/older', async (req): Promise<ApiResponse<EditHistoryList>> => {
const { id } = req.params as { id: string };
const { session, view } = sessionOf(id, req);
const why = unsupportedReason(view);
if (why) return { success: true, data: unsupportedList(why) };
const status = await sync.sync(view, { older: true });
return { success: true, data: listOf(await sync.getStore(session.id), session.workingDir, status) };
});
app.get('/api/sessions/:id/edits/:entryId', async (req, reply): Promise<ApiResponse<EditHistoryDiff>> => {
const { id, entryId } = req.params as { id: string; entryId: string };
const { session, view } = sessionOf(id, req);
const entry = unsupportedReason(view) ? undefined : (await sync.getStore(session.id)).get(entryId);
if (!entry) {
reply.code(404);
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'No such edit in this session');
}
return { success: true, data: { diff: hunksToUnified(entry.hunks), truncated: entry.truncated } };
});
}
+383 -37
View File
@@ -23,6 +23,7 @@ import type {
} from '../../types.js';
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
import { compileFileQuery } from '../../utils/file-query.js';
import { describeUnknownPath, probePathKind } from '../../utils/bounded-path-probe.js';
import { fileStreamManager } from '../../file-stream-manager.js';
import {
AUDIO_ATTACHMENT_EXTENSIONS,
@@ -30,12 +31,21 @@ import {
attachmentRecordToEvent,
attachmentRegistry,
buildFileThumbnailRoute,
getAttachmentType,
isSupportedAttachmentExtension,
registerExternalAttachment,
TEXT_ATTACHMENT_EXTENSIONS,
VIDEO_ATTACHMENT_EXTENSIONS,
type AttachmentRecord,
} from '../../attachment-registry.js';
import {
FolderListingError,
folderFilePath,
folderGrants,
folderAllowedForUser,
listFolder,
validFolderFileName,
} from '../../folder-listing.js';
import { generateFirstPageThumbnail } from '../../document-thumbnailer.js';
import { getOfficePreviewPdfPath, getPreviewPdfDownloadName } from '../../document-preview-cache.js';
import { sanitizeAttachmentHistoryItem } from '../../session-attachment-history.js';
@@ -47,6 +57,7 @@ import {
findSessionOrFail,
getAuthUser,
parseBody,
resolveCasesDir,
validateSessionFilePath,
validateSessionFilePathLexical,
} from '../route-helpers.js';
@@ -64,7 +75,12 @@ import { parseByteRange } from '../http-range.js';
import { isSensitivePath } from '../sensitive-path.js';
import { SseEvent } from '../sse-events.js';
import type { ConfigPort, EventPort, SessionPort } from '../ports/index.js';
import { FilesystemBrowseQuerySchema, FilesystemPreviewQuerySchema, FileWriteSchema } from '../schemas.js';
import {
FilesystemBrowseQuerySchema,
FilesystemMkdirSchema,
FilesystemPreviewQuerySchema,
FileWriteSchema,
} from '../schemas.js';
import {
MAX_EDITABLE_BYTES,
applyEol,
@@ -73,6 +89,35 @@ import {
isEditableFileName,
} from '../../config/file-editing.js';
/**
* Upper bound on an XLSX the browser preview will fetch (`?preview=true`).
* Parsing happens client-side in spreadsheet-preview-worker.js, so this caps
* what a single preview can hand the worker; the renderer refuses the same size
* before fetching. An explicit download is unaffected (global download cap).
*/
export const MAX_XLSX_BROWSER_PREVIEW_BYTES = 10 * 1024 * 1024;
/** Whether a raw request is an XLSX browser preview over the preview cap. */
function exceedsXlsxPreviewLimit(extension: string, query: { preview?: string; download?: string }, size: number) {
return (
extension === 'xlsx' &&
query.preview === 'true' &&
query.download !== 'true' &&
size > MAX_XLSX_BROWSER_PREVIEW_BYTES
);
}
function sendXlsxPreviewTooLarge(reply: FastifyReply, size: number): void {
reply
.code(413)
.send(
createErrorResponse(
ApiErrorCode.INVALID_INPUT,
`File too large to preview (${Math.ceil(size / 1024 / 1024)}MB > ${MAX_XLSX_BROWSER_PREVIEW_BYTES / 1024 / 1024}MB limit)`
)
);
}
const MIME_TYPES: Record<string, string> = {
png: 'image/png',
jpg: 'image/jpeg',
@@ -100,6 +145,7 @@ const MIME_TYPES: Record<string, string> = {
pdf: 'application/pdf',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
json: 'application/json',
md: 'text/markdown',
txt: 'text/plain',
@@ -170,8 +216,8 @@ interface FileBodySource {
cleanup?: () => void;
}
/** Byte source for a LOCAL file (the historical, only path). */
function localFileSource(resolvedPath: string) {
/** Byte source for a LOCAL file (the historical, only path). Shared with html-view-routes.ts. */
export function localFileSource(resolvedPath: string) {
return (range?: { start: number; end: number }): FileBodySource => ({
content: createReadStream(resolvedPath, range ? { start: range.start, end: range.end } : undefined),
});
@@ -191,8 +237,11 @@ function localFileSource(resolvedPath: string) {
* `open` supplies the bytes for the (optional) range, which is what lets a remote
* case reuse this instead of re-implementing the 206/416 contract: same headers,
* same status codes, whether the file sits on this host or behind an ssh pipe.
*
* Exported for the rendered-HTML route (html-view-routes.ts), so a page's own
* `<video>`/`<audio>` can seek as well.
*/
function sendFileBody(
export function sendFileBody(
reply: FastifyReply,
size: number,
rangeHeader: string | string[] | undefined,
@@ -241,7 +290,9 @@ async function serveRawFile(
// would be stored XSS. SVG was always here; HTML/HTM join it now that the text
// family is servable, so widening what can be READ never widened what can RUN.
// The preview overlay reads these through `fetch()`, which ignores the
// disposition, so a clicked .html still shows its source.
// disposition, so the source view of a clicked .html still works. Rendering it
// as a page is html-view-routes.ts's job (sandboxed, opaque origin), never this
// route's.
const markupOnly = extension === 'svg' || extension === 'html' || extension === 'htm';
if (download || markupOnly) {
reply.header(
@@ -396,6 +447,40 @@ async function resolveServableRemoteAttachment(
return { path: probe.realPath, probe };
}
/**
* Stream a guard-checked file ({@link resolveServableAttachmentPath}) — the body of
* the attachment raw route, shared with the folder-grid file route.
*
* A remote file streams over ssh exactly like file-raw, with the same 200/206/416
* contract, and its size comes from the guard's own re-probe — so serving a remote
* file needs no stat the local branch would not also need.
*/
async function serveServableRaw(
reply: FastifyReply,
servable: ServableAttachment,
remote: SessionRemote | undefined,
fileName: string,
extension: string,
query: { download?: string; preview?: string; range?: string }
): Promise<void> {
try {
const target: FileTarget =
servable.probe && remote
? { kind: 'remote', resolvedPath: servable.path, relativePath: '', remote, probe: servable.probe }
: { kind: 'local', resolvedPath: servable.path, relativePath: '' };
const size = servable.probe ? servable.probe.size : (await fs.stat(servable.path)).size;
if (exceedsXlsxPreviewLimit(extension, query, size)) {
sendXlsxPreviewTooLarge(reply, size);
return;
}
await serveRawFile(reply, target, size, fileName, extension, query.download === 'true', query.range);
} catch (err) {
reply
.code(err instanceof RemoteFileAccessError ? 502 : 500)
.send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`));
}
}
/**
* Convert a DOCX/PPTX to a single-PDF preview (LibreOffice when available) and
* stream it inline. PDF/PNG and text formats don't need conversion — callers
@@ -490,8 +575,20 @@ function getKnownSessionWorkingDir(
interface SessionFileScope {
workingDir: string;
remote?: SessionRemote;
/** The session runs inside a Docker case: a local session for these routes, but `~` is the container's. */
docker?: boolean;
}
/**
* A Docker case is a LOCAL session for the file routes (the workspace is a
* bind mount, so its paths are host paths), but `~` inside the container is the
* container's own seeded home, not this host's. Expanding `~/x.png` against the
* host's home would open a different file than the one the agent printed, so
* the form is refused instead of guessed.
*/
export const DOCKER_HOME_PATH_ERROR =
'A ~/ path inside a Docker case is the container home, which this server cannot read; use the absolute path';
/**
* The file-access scope of a session, live or persisted — the `workingDir`-only
* variant of {@link getKnownSessionWorkingDir}, plus the remote metadata the routes
@@ -509,12 +606,12 @@ function getKnownSessionFileScope(
const user = getAuthUser(req);
const liveSession = ctx.sessions.get(sessionId);
if (liveSession && canAccessOwned(user, liveSession.owner)) {
return { workingDir: liveSession.workingDir, remote: liveSession.remote };
return { workingDir: liveSession.workingDir, remote: liveSession.remote, docker: !!liveSession.docker };
}
const stored = ctx.store.getSession(sessionId);
if (stored && canAccessOwned(user, (stored as { owner?: string }).owner)) {
return { workingDir: stored.workingDir, remote: stored.remote };
return { workingDir: stored.workingDir, remote: stored.remote, docker: !!stored.docker };
}
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`));
@@ -676,7 +773,11 @@ const FILESYSTEM_IMAGE_PREVIEW_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif'
const FILESYSTEM_TEXT_PREVIEW_EXTENSIONS = new Set(['md', 'txt', 'json']);
const FILESYSTEM_DOCUMENT_PREVIEW_EXTENSIONS = new Set(['pdf', 'docx', 'pptx']);
function isPathWithinRoot(root: string, candidate: string): boolean {
/**
* Whether `candidate` is `root` itself or lies beneath it (both already resolved). The one lexical
* containment check for workspace confinement; ralph-routes.ts uses it for a remote `@fix_plan.md`.
*/
export function isPathWithinRoot(root: string, candidate: string): boolean {
const rel = relative(root, candidate);
return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`));
}
@@ -1353,6 +1454,83 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
};
});
// "New folder" in the path picker: creates ONE folder directly inside a folder the picker may browse.
app.post('/api/filesystem/mkdir', async (req, reply): Promise<ApiResponse<{ path: string }>> => {
const { path: parentPath, name, sessionId, showHidden } = parseBody(FilesystemMkdirSchema, req.body);
// Probed first (#516): the picker's resolver runs realpathSync on this path, which would freeze the
// whole server on a mount that stopped answering.
if ((await probePathKind(parentPath, { pastCap: true })) === 'unknown') {
reply.code(422);
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
describeUnknownPath('The folder', parentPath, { pastCap: true })
);
}
const { candidatePath, resolvedPath, blockedTrees } = await resolveFilesystemPickerPath(
ctx,
req,
parentPath,
sessionId,
wantsHiddenPickerEntries(showHidden)
);
const target = join(resolvedPath, name);
if (isBlockedPickerPath(resolvedPath, blockedTrees, true) || isBlockedPickerPath(target, blockedTrees, true)) {
reply.code(403);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Folders cannot be created here');
}
// The cases directories (shared, and the caller's own in multi-user mode) are what GET /api/cases lists as
// local cases: a folder made there is a case with no scaffold, which Link then refuses by name and which
// deleting would take the linked folder with. A case there is made through Add Case > New Case.
const casesDirs = await Promise.all(
[...new Set([CASES_DIR, resolveCasesDir(getAuthUser(req))])].map((dir) =>
fs.realpath(dir).catch(() => resolve(dir))
)
);
if (casesDirs.includes(resolvedPath)) {
reply.code(400);
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'This is a cases folder: every folder in it is a case. Use Add Case > New Case to make one there.'
);
}
// Like browse, a file is not a place to create in.
try {
if (!(await fs.stat(resolvedPath)).isDirectory()) throw new Error('not a directory');
} catch {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'The folder to create in must be a directory');
}
try {
// Not recursive: the parent was just resolved inside an allowed root, and an existing folder must be a 409.
await fs.mkdir(target);
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === 'EEXIST') {
reply.code(409);
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, `"${name}" already exists in this folder`);
}
if (code === 'EACCES' || code === 'EPERM' || code === 'EROFS') {
reply.code(403);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'This folder is not writable');
}
if (code === 'ENOENT') {
reply.code(404);
return createErrorResponse(ApiErrorCode.NOT_FOUND, `Folder not found: ${candidatePath}`);
}
if (code === 'ENAMETOOLONG') {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Folder name is too long');
}
// Never the errno text: it names the resolved, symlink-followed path, not the one the picker shows.
reply.code(500);
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
`Could not create the folder${code ? ` (${code})` : ''}`
);
}
return { success: true, data: { path: join(candidatePath, name) } };
});
// Inline preview for files selected through the root-confined filesystem picker.
app.get('/api/filesystem/preview', { compress: false }, async (req, reply): Promise<void> => {
const { path: requestedPath, sessionId, showHidden } = parseBody(FilesystemPreviewQuerySchema, req.query);
@@ -1724,7 +1902,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
// breadth of formats the attachments viewer renders (image/audio/video/pdf)
// so the file viewer can open the same files.
const ext = filePath.split('.').pop()?.toLowerCase() || '';
const imageExts = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico']);
const imageExts = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'svg', 'bmp', 'ico']);
// Shared with the attachment registry so a video plays the same whether it
// sits in the workspace or is reached by id from outside it.
const videoExts = VIDEO_ATTACHMENT_EXTENSIONS;
@@ -1778,13 +1956,15 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
const fileRawUrl = `/api/sessions/${id}/file-raw?path=${encodeURIComponent(filePath)}`;
if (raw === 'true' || mediaType || otherBinaryExts.has(ext)) {
// Return metadata for media/binary files (no text body)
// Return metadata for media/binary files (no text body). XLSX is still a
// binary here (no text body); `spreadsheet` tells the overlay it can parse
// it client-side from `url`. xls/ods stay plain binary (download only).
return {
success: true,
data: {
path: filePath,
size: stat.size,
type: mediaType ?? 'binary',
type: mediaType ?? (ext === 'xlsx' ? 'spreadsheet' : 'binary'),
extension: ext,
url: fileRawUrl,
},
@@ -2009,7 +2189,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
// Serve raw file content (for images/binary files)
app.get('/api/sessions/:id/file-raw', async (req, reply) => {
const { id } = req.params as { id: string };
const { path: filePath, download } = req.query as { path?: string; download?: string };
const { path: filePath, download, preview } = req.query as { path?: string; download?: string; preview?: string };
const session = findSessionOrFail(ctx, id, req);
if (!filePath) {
@@ -2039,12 +2219,17 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
}
const ext = filePath.split('.').pop()?.toLowerCase() || '';
if (exceedsXlsxPreviewLimit(ext, { preview, download }, size)) {
sendXlsxPreviewTooLarge(reply, size);
return;
}
const mimeTypes: Record<string, string> = {
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
avif: 'image/avif',
ico: 'image/x-icon',
bmp: 'image/bmp',
mp4: 'video/mp4',
@@ -2106,6 +2291,10 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing attachment path'));
return;
}
if (session.docker && body.path.startsWith('~/')) {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, DOCKER_HOME_PATH_ERROR));
return;
}
try {
// A remote case registers a path that lives on the REMOTE host: the guard and
@@ -2216,7 +2405,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
// attachment-guard policy on every request (defense-in-depth) before streaming.
app.get('/api/sessions/:id/attachments/:attachmentId/raw', async (req, reply) => {
const { id, attachmentId } = req.params as { id: string; attachmentId: string };
const { download } = req.query as { download?: string };
const { download, preview } = req.query as { download?: string; preview?: string };
const session = findSessionOrFail(ctx, id, req);
const record = getAttachmentOr404(reply, id, attachmentId);
if (!record) return;
@@ -2226,30 +2415,11 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
});
if (!servable) return;
try {
// A remote record streams over ssh exactly like file-raw, with the same
// 200/206/416 contract, and its size comes from the guard's own re-probe — so
// serving a remote attachment needs no stat the local branch would not also need.
const remote = session.remote;
const target: FileTarget =
servable.probe && remote
? { kind: 'remote', resolvedPath: servable.path, relativePath: '', remote, probe: servable.probe }
: { kind: 'local', resolvedPath: servable.path, relativePath: '' };
const size = servable.probe ? servable.probe.size : (await fs.stat(servable.path)).size;
await serveRawFile(
reply,
target,
size,
record.fileName,
record.extension,
download === 'true',
req.headers.range
);
} catch (err) {
reply
.code(err instanceof RemoteFileAccessError ? 502 : 500)
.send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`));
}
await serveServableRaw(reply, servable, session.remote, record.fileName, record.extension, {
download,
preview,
range: req.headers.range,
});
});
// Serve a converted PDF preview of a registered attachment by id. Office docs
@@ -2313,6 +2483,182 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
await serveThumbnail(reply, servable.path, record.extension);
});
// ===== Folder grid =====
// Grant + list a folder (absolute or `~/` on the session's host) for the preview
// grid. Same guard as a single external attachment; the grid then fetches files by
// `folderId + name`, so no browser request carries a path. See folder-listing.ts.
app.post('/api/sessions/:id/folders', async (req, reply) => {
const { id } = req.params as { id: string };
const scope = getKnownSessionFileScope(ctx, id, reply, req);
if (!scope) return;
const body = (req.body || {}) as { path?: unknown };
if (!body.path || typeof body.path !== 'string') {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing folder path'));
return;
}
// A bare `~` expands to this host's home too, not the container's.
if (scope.docker && (body.path === '~' || body.path.startsWith('~/'))) {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, DOCKER_HOME_PATH_ERROR));
return;
}
try {
const listing = await listFolder(id, body.path, {
sessionWorkingDir: scope.workingDir,
remote: scope.remote,
user: getAuthUser(req),
});
return { success: true, data: listing };
} catch (err) {
if (err instanceof FolderListingError) {
const code =
err.statusCode === 404
? ApiErrorCode.NOT_FOUND
: err.statusCode >= 500
? ApiErrorCode.OPERATION_FAILED
: ApiErrorCode.INVALID_INPUT;
reply.code(err.statusCode).send(createErrorResponse(code, err.message));
return;
}
return reply
.code(500)
.send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to list folder: ${getErrorMessage(err)}`));
}
});
/**
* One file of a granted folder, guard-checked like an attachment: the name is a
* bare file name of a supported type, and the joined path goes through the same
* {@link resolveServableAttachmentPath} (blocklist, workspace confinement, symlink
* re-resolution, remote re-probe) an attachment by id does. The multi-user rule
* ({@link folderAllowedForUser}) runs again on the resolved file: a symlink inside
* the granted folder must not lead into another user's space.
*/
async function resolveFolderFile(
req: FastifyRequest,
reply: FastifyReply
): Promise<{ scope: SessionFileScope; servable: ServableAttachment; name: string; extension: string } | null> {
const { id, folderId, name } = req.params as { id: string; folderId: string; name: string };
const scope = getKnownSessionFileScope(ctx, id, reply, req);
if (!scope) return null;
const grant = folderGrants.get(id, folderId);
if (!grant) {
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Folder not found'));
return null;
}
const fileName = validFolderFileName(name);
if (!fileName) {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid file name'));
return null;
}
const filePath = folderFilePath(grant.dir, fileName);
if (!scope.remote) {
// Bounded, like the folder itself: the grant may point at a mount that has stopped answering since.
const kind = await probePathKind(filePath, { pastCap: true });
if (kind === 'unknown') {
reply
.code(503)
.send(
createErrorResponse(ApiErrorCode.OPERATION_FAILED, describeUnknownPath('File', filePath, { pastCap: true }))
);
return null;
}
if (kind !== 'file') {
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
return null;
}
}
const extension = extname(fileName).toLowerCase().replace(/^\./, '');
const record: AttachmentRecord = {
attachmentId: '',
sessionId: id,
filePath,
fileName,
extension,
attachmentType: getAttachmentType(extension),
size: 0,
mtimeMs: 0,
timestamp: 0,
source: 'external',
};
const servable = await resolveServableAttachmentPath(reply, record, scope);
if (!servable) return null;
// The requested NAME only opens the door; registration judges the file a
// symlink resolves to (supported extension, regular file) and so does this.
// Without it `shot.png -> ../secret.bin` is served, and a FIFO or a device
// behind a supported name parks a worker on an open that never returns.
if (!isSupportedAttachmentExtension(extname(servable.path))) {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Unsupported attachment type'));
return null;
}
const isRegularFile = scope.remote
? servable.probe?.kind === 'file'
: await fs.stat(servable.path).then(
(stat) => stat.isFile(),
() => false
);
if (!isRegularFile) {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Attachment path is not a file'));
return null;
}
if (!folderAllowedForUser(servable.path, getAuthUser(req), scope)) {
reply.code(403).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'File is outside your space'));
return null;
}
return { scope, servable, name: fileName, extension };
}
app.get('/api/sessions/:id/folders/:folderId/files/:name/raw', async (req, reply) => {
const file = await resolveFolderFile(req, reply);
if (!file) return;
const { download, preview } = req.query as { download?: string; preview?: string };
await serveServableRaw(reply, file.servable, file.scope.remote, file.name, file.extension, {
download,
preview,
range: req.headers.range,
});
});
app.get('/api/sessions/:id/folders/:folderId/files/:name/preview', async (req, reply) => {
const file = await resolveFolderFile(req, reply);
if (!file) return;
if (file.extension !== 'docx' && file.extension !== 'pptx') {
const { id, folderId } = req.params as { id: string; folderId: string };
reply.redirect(
`/api/sessions/${encodeURIComponent(id)}/folders/${encodeURIComponent(folderId)}/files/${encodeURIComponent(file.name)}/raw`
);
return;
}
if (file.servable.probe) {
reply
.code(400)
.send(
createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'Office document preview is not available for files in a remote (SSH) case'
)
);
return;
}
await serveConvertedPreview(reply, file.servable.path, file.name, file.extension);
});
app.get('/api/sessions/:id/folders/:folderId/files/:name/thumbnail', async (req, reply) => {
const file = await resolveFolderFile(req, reply);
if (!file) return;
if (file.servable.probe) {
reply
.code(400)
.send(
createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'Thumbnails are not available for files in a remote (SSH) case'
)
);
return;
}
await serveThumbnail(reply, file.servable.path, file.extension);
});
// Serve converted document previews for a workspace-relative path. DOCX/PPTX
// are converted to PDF via LibreOffice; PDF/PNG/text preview through file-raw.
app.get('/api/sessions/:id/file-preview', async (req, reply) => {
+191
View File
@@ -0,0 +1,191 @@
/**
* @fileoverview The Git panel's routes: `GET /api/sessions/:id/git-status` (what the session's workspace
* has not committed or pushed, src/git-workspace-status.ts) and `POST /api/sessions/:id/git-fetch` (the
* panel's Fetch button, src/git-fetch.ts), for the bottom-bar Git indicator and its window. The answer is
* an overview: the enclosing repository, or each repository found below a folder that holds several
* projects (see `getGitWorkspaceOverview` for exactly which).
*
* Read-only and offline: the status route never fetches and never runs a git write command. Fetching is a
* separate, user-triggered path (`POST /api/sessions/:id/git-fetch`, src/git-fetch.ts) that the panel calls
* only when the user clicks Fetch. A remote (SSH) or Docker session is not inspected and answers
* `state: 'unsupported'` on both routes, and neither is any repository at or inside a Docker case
* workspace (a container can write there, and git here would run on the host). Ownership goes through
* `findSessionOrFail`, like every session-scoped route.
*/
import type { FastifyInstance } from 'fastify';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
import { redactGitCredentials } from '../../git-clone.js';
import { emptyReport, fetchWorkspaceOnce, gitFetchStatusFrom, type GitFetchReport } from '../../git-fetch.js';
import { readDockerCasesOrThrow } from '../../docker-hosts.js';
import { getDataDir } from '../../config/instance.js';
import { findSessionOrFail, persistAndBroadcastSession } from '../route-helpers.js';
import {
emptyOverview,
findWorkspaceRepo,
getGitFileDiff,
resolveOverviewLimits,
getGitWorkspaceOverview,
getGitWorkspaceStatus,
type GitFileDiff,
type GitFileKind,
type GitRunner,
type GitWorkspaceOverview,
} from '../../git-workspace-status.js';
import type { EventPort, SessionPort } from '../ports/index.js';
/**
* Host paths of every Docker case workspace: repositories at or inside these are never inspected.
* Exported so the interval scheduler (src/git-fetch-scheduler.ts) uses the SAME list: the Docker rule has
* to be one rule, checked by every path that could run git.
*
* REJECTS when `docker-cases.json` exists but cannot be read or parsed (its writer truncates and rewrites it
* in place, so a read can land mid-write). The fetch paths fail closed on that: a fetch runs a repository's
* own hooks, and without the list nothing tells which folders a container can write to. The read-only
* status and diff routes read it as no Docker workspaces, as they always have.
*/
export const defaultDockerWorkspaces = async (): Promise<string[]> =>
(await readDockerCasesOrThrow(getDataDir())).map((c) => c.hostWorkspacePath).filter(Boolean);
/** The read-only routes' reading of the list: unchanged, an unreadable list is no Docker workspaces. */
const dockerWorkspacesOrNone = (dockerWorkspaces: () => Promise<string[]>): Promise<string[]> =>
dockerWorkspaces().catch(() => []);
/**
* The repository limit and git timeout a request asks for (Settings → Bottom bar, per device):
* `maxRepos` and `timeout` (seconds) query parameters, each clamped to a safe range, so an odd value
* can never cost more than the module's own ceiling.
*/
function limitsFrom(query: { maxRepos?: unknown; timeout?: unknown }) {
// A repeated key arrives as an array: it is not a number, so it means "default" like '' and absent.
const timeout = typeof query.timeout === 'string' && query.timeout.trim() ? query.timeout : undefined;
return resolveOverviewLimits({
maxRepos: query.maxRepos,
timeoutMs: timeout !== undefined ? Number(timeout) * 1000 : undefined,
});
}
export function registerGitStatusRoutes(
app: FastifyInstance,
// EventPort as well as SessionPort: the fetch route publishes the run it did, and a route that broadcasts
// has to say so in its own signature.
ctx: SessionPort & EventPort,
git?: GitRunner,
dockerWorkspaces: () => Promise<string[]> = defaultDockerWorkspaces
): void {
app.get('/api/sessions/:id/git-status', async (req): Promise<ApiResponse<GitWorkspaceOverview>> => {
const { id } = req.params as { id: string };
const query = req.query as { fresh?: string; maxRepos?: unknown; timeout?: unknown };
const { fresh } = query;
const limits = limitsFrom(query);
const session = findSessionOrFail(ctx, id, req);
if (session.remote) return { success: true, data: emptyOverview('unsupported', { reason: 'remote' }) };
if (session.docker) return { success: true, data: emptyOverview('unsupported', { reason: 'docker' }) };
return {
success: true,
data: await getGitWorkspaceOverview(session.workingDir, {
git,
fresh: fresh === '1',
...limits,
dockerWorkspaces: await dockerWorkspacesOrNone(dockerWorkspaces),
}),
};
});
// The diff of one file the panel lists. `repo` and `path` are matched against the CURRENT status
// (a repository this session's folder holds, a path git reported in it) rather than trusted, so
// the route cannot be pointed at an arbitrary directory or file. The repository is checked against
// the overview's own (cached) list with the Docker roots as they are now, and only that one
// repository's status is refreshed: a click must not re-read every repository in the folder.
app.get('/api/sessions/:id/git-diff', async (req, reply): Promise<ApiResponse<GitFileDiff>> => {
const { id } = req.params as { id: string };
const query = req.query as { repo?: string; path?: string; kind?: string; maxRepos?: unknown; timeout?: unknown };
const { repo, path, kind } = query;
const limits = limitsFrom(query);
const session = findSessionOrFail(ctx, id, req);
if (session.remote || session.docker) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Git is not available for remote or Docker sessions');
}
const repoRoot = repo
? await findWorkspaceRepo(session.workingDir, repo, {
git,
...limits,
dockerWorkspaces: await dockerWorkspacesOrNone(dockerWorkspaces),
})
: null;
const status = repoRoot
? await getGitWorkspaceStatus(repoRoot, { git, fresh: true, timeoutMs: limits.timeoutMs })
: null;
const entry =
status?.state === 'ok'
? status.files.find((f) => f.path === path && f.kind === (kind as GitFileKind))
: undefined;
if (!status?.repoRoot || !entry) {
reply.code(404);
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'That file has no outstanding change any more');
}
try {
return {
success: true,
data: await getGitFileDiff(status.repoRoot, entry, { git, timeoutMs: limits.timeoutMs }),
};
} catch (err) {
reply.code(500);
return createErrorResponse(
ApiErrorCode.INTERNAL_ERROR,
`git diff failed: ${redactGitCredentials(getErrorMessage(err))}`
);
}
});
// Fetch, and only because the user clicked Fetch in the panel (src/git-fetch.ts): one
// `git fetch --quiet` per repository the panel would show, through the same resolver so the two cannot
// disagree about which repositories belong to the workspace. This is the only git command the Git panel runs
// that writes, and it writes remote-tracking refs only: nothing commits, checks out or merges. Cloning and
// worktree management write too, but those happen when the user asked for them; this route is the one that
// can also run later, on a timer. It is not as harmless as a read though: a fetch holds git's ref lock, so
// an agent's own `git pull` in the same clone can collide with it, which is why the run goes through
// `fetchWorkspaceOnce`: a click while a tick is already fetching this folder joins that run instead of
// starting a second one. It can also run a
// repository's own hooks (`reference-transaction`, `pre-auto-gc` through the maintenance git runs after a
// fetch) and its config commands such as `credential.helper`, so the `FETCH_CONFIG_ARGS` overrides turn
// maintenance and gc off, and a repository a container can write to is refused before any git runs. Remote
// and Docker sessions answer `unsupported` like the status route.
app.post('/api/sessions/:id/git-fetch', async (req): Promise<ApiResponse<GitFetchReport>> => {
const { id } = req.params as { id: string };
const query = req.query as { maxRepos?: unknown; timeout?: unknown };
const limits = limitsFrom(query);
const session = findSessionOrFail(ctx, id, req);
if (session.remote) return { success: true, data: emptyReport('unsupported', { reason: 'remote' }) };
if (session.docker) return { success: true, data: emptyReport('unsupported', { reason: 'docker' }) };
// Fail CLOSED on a Docker case list that cannot be read: a fetch runs the repository's hooks, so it never
// runs without knowing which folders a container can write to. Answered as a failed run (`state: 'error'`),
// published like one, so the Session Options Git row shows it.
let dockerRoots: string[];
try {
dockerRoots = await dockerWorkspaces();
} catch (err) {
console.warn(`[git fetch] Docker case list unreadable, nothing fetched: ${getErrorMessage(err)}`);
const report = emptyReport('error', {
error:
'Nothing was fetched: Codeman could not read its Docker case list (docker-cases.json), so it cannot tell which folders a container can write to.',
});
if (session.setGitFetchStatus(gitFetchStatusFrom(report, 'click'))) persistAndBroadcastSession(ctx, session);
return { success: true, data: report };
}
const run = await fetchWorkspaceOnce(
session.workingDir,
{ git, ...limits, dockerWorkspaces: dockerRoots },
'click'
);
// The response answers the click, and the run is also the session's current truth: what the panel's
// "behind" number is based on. Publishing it is what the api docs say a click does, and what makes a
// second tab open on the same folder see the same run. The trigger recorded is the RUN's, not this
// request's: a click that joined a tick already going fetched nothing itself, so the row has to name the
// interval as what fetched.
if (session.setGitFetchStatus(gitFetchStatusFrom(run.report, run.trigger)))
persistAndBroadcastSession(ctx, session);
return { success: true, data: run.report };
});
}
+34 -6
View File
@@ -14,6 +14,7 @@ import { sanitizeHookData, parseBody } from '../route-helpers.js';
import { persistDockerCaseClaudeSessionId } from '../../docker-hosts.js';
import { getDataDir } from '../../config/instance.js';
import { sessionWaits, hooksAvailableForMode, sessionHookOptions } from '../session-wait-registry.js';
import { getCli } from '../../config/cli-registry/registry.js';
import { approvalInbox, type ApprovalKind } from '../approval-inbox.js';
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
@@ -142,7 +143,8 @@ export function registerHookEventRoutes(
// bumped only by input that flowed through Codeman's own write path, so
// it read 0 forever for those panes and every consumer of "when did this
// pane last submit" silently degraded.
session?.markPromptSubmitted();
// The prompt text tells a person's prompt from the keep-alive's (prompt-cache-keepwarm.ts).
session?.markPromptSubmitted(typeof data.prompt === 'string' ? data.prompt : undefined);
}
// Persist when the conversation actually moved: `/clear` emits no
// completion event, so without this the successor id is lost on restart
@@ -182,9 +184,21 @@ export function registerHookEventRoutes(
// seeds from /api/approvals), and it suppresses the push: an alert nobody can answer
// is worth even less on a phone than in a tab.
let acknowledgedReason: string | undefined;
// A turn Codeman started itself (the prompt-cache keep-alive, prompt-cache-keepwarm.ts)
// ends with the same stop hook and idle prompt as a real one. Both open pre-acknowledged,
// so no tab, toast or push asks a person to look at a session that wants nothing from them.
const keepAliveReason =
session?.keepAliveTurnActive && (event === 'stop' || event === 'idle_prompt') ? 'cache keep-alive' : undefined;
const approvalKind = APPROVAL_KIND_BY_EVENT[event];
// Only a human spends an idle alert, so a keep-alive turn leaves a pending idle item exactly
// as it found it: its stop does not resolve it, and its idle prompt does not replace it (a
// replacement would forget the item was unseen, or that a person had seen it). With no idle
// item pending, the keep-alive's idle prompt opens a pre-acknowledged one.
const keepsPendingIdle = keepAliveReason !== undefined && approvalInbox.getForSession(sessionId)?.kind === 'idle';
if (session && hooksAvailableForMode(session.mode, sessionHookOptions(session))) {
if (approvalKind) {
if (approvalKind === 'idle' && keepsPendingIdle) {
// Left in place: no new item, and the broadcast below carries the keep-alive reason.
} else if (approvalKind) {
const toolInput =
safeData.tool_input && typeof safeData.tool_input === 'object'
? (safeData.tool_input as Record<string, unknown>)
@@ -200,10 +214,15 @@ export function registerHookEventRoutes(
toolSummary: typeof toolSummary === 'string' ? toolSummary : undefined,
message: typeof safeData.message === 'string' ? safeData.message : undefined,
cwd: typeof safeData.cwd === 'string' ? safeData.cwd : undefined,
// Supervised-hook CLIs (DeepSeek Harness): dialog keystrokes are unverifiable,
// so the item is an alert — answer in the terminal. Capability-gated, not by
// mode id, so a future supervised CLI inherits the same shape.
answerInTerminal: getCli(session.mode)?.capabilities.hooks === 'supervised',
// What the pane says is still running in the background. An idle prompt from a
// session that is watching its own work opens acknowledged, so it never arms an
// alert nobody can answer; notePrompt() carries the whole reasoning.
watching: session.watching,
preAcknowledged: keepAliveReason,
// Visible tmux frame first (it IS the dialog); raw byte-buffer tail as
// the fallback for direct-PTY sessions and the no-op test mux.
capture: () => {
@@ -214,11 +233,16 @@ export function registerHookEventRoutes(
});
approvalId = item.id;
acknowledgedReason = item.acknowledgedReason;
} else if (APPROVAL_RESOLVING_EVENTS.has(event)) {
} else if (APPROVAL_RESOLVING_EVENTS.has(event) && !keepsPendingIdle) {
approvalInbox.resolveForSession(sessionId, 'resolved_in_terminal');
}
}
acknowledgedReason ??= keepAliveReason;
// The keep-alive turn's own idle prompt is the last thing it sends, so the window ends here
// rather than on a fixed clock that a slow turn could outlast.
if (keepAliveReason && event === 'idle_prompt') session?.endKeepAliveTurn();
ctx.broadcast(`hook:${event}`, {
sessionId,
timestamp: Date.now(),
@@ -244,7 +268,9 @@ export function registerHookEventRoutes(
sessionId,
sessionName,
...safeData,
...(approvalId && session?.mode !== 'deepseek' && { approvalId }),
// Supervised-hook CLIs answer in the terminal: omit approvalId so push
// cannot offer Approve/Deny that the answer route would refuse.
...(approvalId && session && getCli(session.mode)?.capabilities.hooks !== 'supervised' && { approvalId }),
});
}
@@ -252,9 +278,11 @@ export function registerHookEventRoutes(
// Claude pane; only the ones where the conversation actually moved (a /clear
// successor) carry information, and recording the rest would push a row into
// the Summary timeline and /api/search per turn and evict useful rows from
// the 1000-event FIFO (#367 merge-time fix).
// the 1000-event FIFO (#367 merge-time fix). A keep-alive turn's stop and idle prompt are
// left out too: the summary files a `stop` as a warning, which the Away digest lists under
// Needs Attention, and that turn is Codeman's own, not the agent's.
const summaryTracker = ctx.runSummaryTrackers.get(sessionId);
if (summaryTracker && (event !== 'prompt_submitted' || conversationChanged)) {
if (summaryTracker && !keepAliveReason && (event !== 'prompt_submitted' || conversationChanged)) {
summaryTracker.recordHookEvent(event, safeData);
}
+297
View File
@@ -0,0 +1,297 @@
/**
* @fileoverview Render a local HTML file (an agent's report, a generated page) as a
* page instead of as source text.
*
* Routes:
* - `POST /api/sessions/:id/html-view` (authenticated) — `{ path }` of an `.html` file;
* returns `{ url }`, an `/html-view/<cap>/<file>` URL for that file.
* - `GET /html-view/:cap/*` (capability) — the HTML file and its sibling assets.
*
* ⚠️ Every response carries `Content-Security-Policy: sandbox ...` WITHOUT
* `allow-same-origin`, so the page runs in an opaque origin whether it is framed or
* opened as a top-level tab: it can run its own scripts but cannot read the Codeman
* document, its cookies or its API. That is what makes rendering safe at all; see
* html-view-capabilities.ts for why the route authenticates on a capability.
* Network access is deliberately OPEN (owner decision, see {@link HTML_VIEW_CSP}).
*
* Path policy: the HTML file itself is admitted like the file preview admits a file
* (inside the session workspace, or outside it under the attachment guard), and the
* capability then serves ONLY the files in that file's own directory tree, filtered
* by the same blocklist plus an asset-extension allowlist and a no-dotfile rule, so
* a page cannot use its own directory to pull a `.env` out through the browser.
* Because that tree is served recursively, minting is refused for an HTML file in a
* hidden directory or directly in a broad root (`/`, home, `/tmp`, `/var/tmp` and
* `$TMPDIR`, the cases and user-space roots, a drive's mount point) or in a
* directory above one, see {@link isBroadHtmlViewRoot}.
*/
import type { FastifyInstance } from 'fastify';
import { realpathSync } from 'node:fs';
import fs from 'node:fs/promises';
import { homedir, tmpdir } from 'node:os';
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
import { ApiErrorCode, createErrorResponse } from '../../types.js';
import { isBlockedAttachmentPath, isUnderTree, loadAttachmentGuardConfig } from '../../config/attachment-guard.js';
import { HTML_VIEW_PREFIX, htmlViewCapabilities } from '../../html-view-capabilities.js';
import { CASES_DIR, findSessionOrFail, ownerFor, parseBody, validateSessionFilePath } from '../route-helpers.js';
import { HtmlViewOpenSchema } from '../schemas.js';
import { DOCKER_HOME_PATH_ERROR, localFileSource, sendFileBody } from './file-routes.js';
import { getUserSpacesDir } from '../../config/multiuser.js';
import { exceedsDownloadLimit } from '../../config/buffer-limits.js';
import type { SessionPort } from '../ports/index.js';
/** Extensions that open as a rendered page. */
export const HTML_VIEW_EXTENSIONS: ReadonlySet<string> = new Set(['html', 'htm']);
/** What a rendered page may load from its own directory, with the type it is served as. */
const ASSET_TYPES: Record<string, string> = {
html: 'text/html; charset=utf-8',
htm: 'text/html; charset=utf-8',
css: 'text/css; charset=utf-8',
js: 'text/javascript; charset=utf-8',
mjs: 'text/javascript; charset=utf-8',
json: 'application/json; charset=utf-8',
map: 'application/json; charset=utf-8',
txt: 'text/plain; charset=utf-8',
csv: 'text/csv; charset=utf-8',
md: 'text/plain; charset=utf-8',
svg: 'image/svg+xml',
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
avif: 'image/avif',
ico: 'image/x-icon',
bmp: 'image/bmp',
woff: 'font/woff',
woff2: 'font/woff2',
ttf: 'font/ttf',
otf: 'font/otf',
wasm: 'application/wasm',
mp4: 'video/mp4',
webm: 'video/webm',
mp3: 'audio/mpeg',
wav: 'audio/wav',
pdf: 'application/pdf',
};
/**
* The whole Content-Security-Policy every response is served under: the `sandbox`
* directive and nothing else. It REPLACES Codeman's own policy (`default-src 'self'`
* and the rest, set earlier by registerSecurityHeaders). No `allow-same-origin`:
* that one token is the difference between "a page" and "a page that can drive
* Codeman".
*
* ⚠️ Network access is OPEN, by the owner's decision (2026-10-13, #547): a rendered
* page behaves like a file opened from disk in a browser, so there is deliberately
* no fetch directive (`default-src`, `script-src`, `connect-src`, ...). It loads
* scripts, styles, fonts, images and media from anywhere (an agent's dashboard
* pulling Chart.js from a CDN) and may fetch, XHR, open WebSockets, send beacons and
* post forms anywhere. What confines it is the opaque origin (no Codeman cookies, no
* Codeman API) and the capability serving only its own folder. Do not add a fetch
* directive, or `allow-same-origin`, without the owner. test/routes/html-view-routes.test.ts
* pins the exact value.
*/
export const HTML_VIEW_CSP = 'sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads';
function extensionOf(path: string): string {
return extname(path).toLowerCase().replace(/^\./, '');
}
/** realpath when the path exists, else the plain resolved path. */
function canonical(path: string): string {
try {
return realpathSync(path);
} catch {
return resolve(path);
}
}
/**
* The shared temp dirs, named literally: `os.tmpdir()` is `$TMPDIR` whenever that
* is set, and macOS sets it in every terminal session (`/var/folders/.../T`), so
* a server started from one would otherwise never see `/tmp` as broad.
*/
const SHARED_TEMP_DIRS = ['/tmp', '/var/tmp'];
/**
* Where drives are mounted. A page at a drive's root (WSL's `/mnt/d/report.html`)
* would be handed the whole drive, so the mount parent and each mount point in it
* count as broad.
*/
const MOUNT_PARENTS = ['/mnt', '/media', '/Volumes'];
/**
* Whether a directory is too broad to hand to a page as its asset root. The
* capability serves the HTML file's directory RECURSIVELY to the page's own
* scripts, so `/tmp/report.html` would expose every other session's scratch
* output under `/tmp`, and `~/report.html` the home tree. Refuse those roots
* (and the cases / user-space roots, which hold every workspace) outright, and
* any directory ABOVE one of them (`/home`, `/Users`, macOS's `/private`), since
* serving it recursively serves the root below it too.
*/
export function isBroadHtmlViewRoot(dir: string): boolean {
const target = canonical(dir);
const userSpaces = canonical(getUserSpacesDir());
// canonical() also maps the literal temp dirs to macOS's `/private/tmp` and `/private/var/tmp`.
const broad = [sep, homedir(), tmpdir(), ...SHARED_TEMP_DIRS, CASES_DIR, userSpaces].map(canonical);
if (broad.some((root) => isUnderTree(root, target))) return true;
for (const parent of MOUNT_PARENTS.map(canonical)) {
if (target === parent || dirname(target) === parent) return true;
}
// A user's own space (`<spaces>/<user>`) and its cases dir hold every workspace of that user.
if (dirname(target) === userSpaces) return true;
return basename(target) === 'cases' && dirname(dirname(target)) === userSpaces;
}
export function registerHtmlViewRoutes(app: FastifyInstance, ctx: SessionPort): void {
app.post('/api/sessions/:id/html-view', async (req, reply) => {
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id, req);
const body = parseBody(HtmlViewOpenSchema, req.body ?? {});
if (session.remote) {
reply
.code(400)
.send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'HTML pages in a remote (SSH) case cannot be rendered'));
return;
}
if (!HTML_VIEW_EXTENSIONS.has(extensionOf(body.path))) {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Not an HTML file'));
return;
}
// A `~/` link (the terminal passes a home-relative path through, since only
// the server knows the home) is this host's home, as the attachment route
// expands it. A Docker case's `~` is the container's, so it is refused there.
let requestedPath = body.path;
if (requestedPath.startsWith('~/')) {
if (session.docker) {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, DOCKER_HOME_PATH_ERROR));
return;
}
requestedPath = join(homedir(), requestedPath.slice(2));
}
// Inside the workspace: the same check every file route uses. Outside it: the
// attachment guard, exactly as an external file preview is admitted.
let resolvedPath: string;
const inWorkspace = validateSessionFilePath(session.workingDir, requestedPath);
if (inWorkspace) {
resolvedPath = inWorkspace.resolvedPath;
} else {
if (!isAbsolute(requestedPath)) {
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
return;
}
try {
resolvedPath = await fs.realpath(requestedPath);
} catch {
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
return;
}
const guard = await loadAttachmentGuardConfig();
if (guard.confineToWorkspace || isBlockedAttachmentPath(resolvedPath, guard.blockedTrees)) {
reply.code(403).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Access to this file is blocked'));
return;
}
}
const stat = await fs.stat(resolvedPath).catch(() => null);
if (!stat?.isFile()) {
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
return;
}
// The page URL names the RESOLVED file, so it has to be HTML too: a
// `report.html -> notes.txt` link would otherwise mint a page for a text file.
if (!HTML_VIEW_EXTENSIONS.has(extensionOf(resolvedPath))) {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Not an HTML file'));
return;
}
// The serving route never descends into a dot segment below the capability
// root; the HTML file's own path gets the same rule, or `.cache/r.html` would
// mint a capability over a hidden directory and serve its `token.json`. Inside
// the workspace the rule covers the part below the workspace root, which is
// all the page's tree can reach, so a workspace that itself lives under a
// hidden folder (`~/.local/src/app`) can still render its pages.
const checkedPath = inWorkspace ? inWorkspace.relativePath : resolvedPath;
if (checkedPath.split(sep).some((segment) => segment.startsWith('.'))) {
reply
.code(403)
.send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'HTML files in hidden directories cannot be rendered'));
return;
}
if (isBroadHtmlViewRoot(dirname(resolvedPath))) {
reply
.code(403)
.send(
createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'This folder is too broad to render a page from; move the HTML file into its own folder'
)
);
return;
}
const cap = htmlViewCapabilities.mint(dirname(resolvedPath), ownerFor(req), !inWorkspace);
return {
success: true,
data: { url: `${HTML_VIEW_PREFIX}/${cap}/${encodeURIComponent(basename(resolvedPath))}` },
};
});
app.get(`${HTML_VIEW_PREFIX}/:cap/*`, async (req, reply) => {
const { cap, '*': rest } = req.params as { cap: string; '*': string };
const record = htmlViewCapabilities.resolve(cap);
const notFound = () => reply.code(404).header('Content-Type', 'text/plain; charset=utf-8').send('Not found');
if (!record) return notFound();
// Fastify has already percent-decoded the wildcard. Hidden files and
// directories are never served, whatever their extension. This is the cheap
// reject on the requested name; the same two rules run again on the resolved
// path below.
const segments = (rest || '').split('/').filter(Boolean);
if (segments.length === 0 || segments.some((s) => s.startsWith('.'))) return notFound();
if (!ASSET_TYPES[extensionOf(segments[segments.length - 1])]) return notFound();
let resolvedPath: string;
try {
resolvedPath = await fs.realpath(resolve(record.rootDir, segments.join(sep)));
} catch {
return notFound();
}
// Symlinks are resolved above, so this is the real containment check, and the
// dot and extension rules have to hold for where a link LANDS as well: a
// `cfg.json -> .git/config` inside the page's folder passes both on its own name.
if (!isUnderTree(resolvedPath, record.rootDir)) return notFound();
const landed = relative(record.rootDir, resolvedPath).split(sep);
if (landed.some((s) => s.startsWith('.'))) return notFound();
// Typed by what the bytes are, the resolved name.
const contentType = ASSET_TYPES[extensionOf(resolvedPath)];
if (!contentType) return notFound();
const guard = await loadAttachmentGuardConfig();
if (isBlockedAttachmentPath(resolvedPath, guard.blockedTrees)) return notFound();
// Re-checked per request, like the attachment serve path (file-routes.ts): a
// page minted from outside the workspace before an admin switched confinement
// on stops serving once it is on, instead of living out its capability.
if (record.outsideWorkspace && guard.confineToWorkspace) return notFound();
const stat = await fs.stat(resolvedPath).catch(() => null);
if (!stat?.isFile() || exceedsDownloadLimit(stat.size)) return notFound();
reply.header('Content-Security-Policy', HTML_VIEW_CSP);
reply.header('Content-Type', contentType);
reply.header('Cache-Control', 'no-store');
reply.header('Referrer-Policy', 'no-referrer');
// The sandboxed page is opaque-origin, so even its fetch() of a sibling
// `data.json` is CORS-checked with `Origin: null`. `*` without credentials is
// enough, and widens nothing: these bytes are already readable by anyone
// holding the capability URL.
reply.header('Access-Control-Allow-Origin', '*');
// The file routes' body sender: Content-Length, and `Range` answered with 206
// (or 416), so a page's own <video>/<audio> can seek. It hijacks the reply, and
// carries every header set above across to it.
sendFileBody(reply, stat.size, req.headers.range, localFileSource(resolvedPath));
return reply;
});
}
+184
View File
@@ -0,0 +1,184 @@
/**
* @fileoverview Agent inbox routes: post, read (optionally long-polling), ack, and an
* owner-scoped summary of counts. Store semantics live in `agent-inbox.ts`.
*
* Who is calling decides what a read or an ack does, so it is resolved here and never
* taken from the body. `resolveParentSessionId()` reads the `X-Codeman-Parent-Session`
* header `codeman agent` sends on every request and accepts it only for a live session
* the caller can see that has the same owner as the receiver (exact id, or a unique
* prefix of at least 8). Anything else is "no session": a post is from the user, and a
* read is a peek. What that buys: in multi-user mode a caller cannot name another
* owner's session (an admin, who can see every session, can act as any session of
* the receiver's owner, as admins can everywhere else). In single-user mode every
* local process holds the same credentials, so the header is a hint the client sets
* about itself.
*
* - `POST /api/sessions/:id/inbox` stores a message. `from` is the resolved caller or
* null. A full inbox is 409 CONFLICT; the oldest message is never dropped.
* - `GET /api/sessions/:id/inbox[?wait=ms]` returns every pending message. Only the
* receiver's own read marks them seen. With `wait`, it parks while nothing is
* UNSEEN (a seen-but-unacked message does not end the wait, or a receiver holding
* one would spin).
* - `POST /api/sessions/:id/inbox/ack` removes messages by id, idempotently, and only
* for the receiver itself (403 otherwise).
* - `GET /api/agent-inbox/summary` reports `pending`, `unseen`, `oldestUnseenAt` and
* `parked` per session the caller can see. Counts only, never text, and kept out of
* `toState()` so a post does not fan out a `session:updated` to every browser.
*
* Nothing here writes to a pane.
*
* @module web/routes/inbox-routes
*/
import { FastifyInstance } from 'fastify';
import { ApiErrorCode, createErrorResponse } from '../../types.js';
import { InboxAckSchema, InboxPostSchema, InboxReadQuerySchema } from '../schemas.js';
import {
abortOnClientHangUp,
canAccessOwned,
findSessionOrFail,
getAuthUser,
ownerFor,
parseBody,
resolveParentSessionId,
waitCapacityResponse,
} from '../route-helpers.js';
import { agentInbox, MAX_INBOX_BYTES, MAX_MESSAGE_BYTES, MAX_MESSAGES_PER_INBOX } from '../agent-inbox.js';
import { sessionWaits, WaitCapacityError, type InboxWaitResult } from '../session-wait-registry.js';
import { clampWaitMs } from '../../config/agent-wait.js';
import type { SessionPort } from '../ports/index.js';
/** Per-session counts the summary route reports. */
export interface InboxSessionSummary {
pending: number;
unseen: number;
oldestUnseenAt: number | null;
/** The receiver itself is parked on `GET …/inbox?wait=` right now. */
parked: boolean;
}
export function registerInboxRoutes(app: FastifyInstance, ctx: SessionPort): void {
/** The resolved caller is the receiving session itself. */
const callerIsReceiver = (req: Parameters<typeof resolveParentSessionId>[1], id: string, owner?: string) =>
resolveParentSessionId(ctx, req, undefined, owner) === id;
app.post('/api/sessions/:id/inbox', async (req) => {
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id, req);
const { text } = parseBody(InboxPostSchema, req.body, 'Invalid inbox message');
const from = resolveParentSessionId(ctx, req, undefined, session.owner) ?? null;
const result = agentInbox.post(id, from, text);
if (!result.ok) {
switch (result.reason) {
case 'empty':
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'text must not be empty');
case 'message-too-large':
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
`Message is larger than ${MAX_MESSAGE_BYTES} bytes (UTF-8). Write it to a file in the workspace and post the path.`
);
case 'inbox-full-count':
return createErrorResponse(
ApiErrorCode.CONFLICT,
`Inbox of ${id} is full (${MAX_MESSAGES_PER_INBOX} messages). Nothing was stored; the receiver has to ack before more can be posted.`
);
case 'inbox-full-bytes':
return createErrorResponse(
ApiErrorCode.CONFLICT,
`Inbox of ${id} is full (${MAX_INBOX_BYTES} bytes of pending text). Nothing was stored; the receiver has to ack before more can be posted.`
);
}
}
// After the store, so a parked reader that wakes finds the message.
sessionWaits.notifyInbox(id);
return {
success: true,
data: { sessionId: id, message: result.message, pending: result.pending, unseen: result.unseen },
};
});
app.get('/api/sessions/:id/inbox', async (req, reply) => {
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id, req);
const query = parseBody(InboxReadQuerySchema, req.query);
const receiver = callerIsReceiver(req, id, session.owner);
// A receiver loops on this URL with identical parameters; a cached 200 would turn
// the loop into a busy spin (same reason as GET …/wait).
reply.header('Cache-Control', 'no-store');
let wait: InboxWaitResult | undefined;
if (query.wait !== undefined) {
const timeoutMs = clampWaitMs(query.wait);
if (agentInbox.unseenCount(id) > 0) {
wait = {
posted: false,
timedOut: false,
immediate: true,
ended: false,
aborted: false,
waitedMs: 0,
timeoutMs,
};
} else {
const abort = abortOnClientHangUp(reply);
try {
wait = await sessionWaits.waitForInbox(id, {
timeoutMs,
owner: ownerFor(req),
abortSignal: abort.signal,
receiver,
});
} catch (err) {
if (err instanceof WaitCapacityError) return waitCapacityResponse(err);
throw err;
}
}
}
// Read AFTER the wait: this is what the caller gets, so it must be current. A wait
// that ended because the session was deleted reads an empty inbox. A client that
// hung up never sees this answer, so its read must not mark anything seen.
const read = agentInbox.read(id, { receiver: receiver && !wait?.aborted });
return { success: true, data: { sessionId: id, receiver, ...read, ...(wait ? { wait } : {}) } };
});
app.post('/api/sessions/:id/inbox/ack', async (req) => {
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id, req);
const { ids } = parseBody(InboxAckSchema, req.body);
if (!callerIsReceiver(req, id, session.owner)) {
// A no-op here would let a coordinator believe it cleared a worker's mail, and
// would hide a client that forgot the header. Refusing says both out loud.
return createErrorResponse(
ApiErrorCode.FORBIDDEN,
`Only ${id} itself can acknowledge its inbox (X-Codeman-Parent-Session must resolve to it). Reading as anyone else is a peek.`
);
}
const removed = agentInbox.ack(id, ids);
return {
success: true,
data: { sessionId: id, removed, pending: agentInbox.pendingCount(id), unseen: agentInbox.unseenCount(id) },
};
});
app.get('/api/agent-inbox/summary', async (req, reply) => {
reply.header('Cache-Control', 'no-store');
const user = getAuthUser(req);
const counts = agentInbox.summary();
const sessions: Record<string, InboxSessionSummary> = {};
for (const session of ctx.sessions.values()) {
if (!canAccessOwned(user, session.owner)) continue;
const c = counts.get(session.id);
const parked = sessionWaits.inboxWaiterCount(session.id, true) > 0;
if (!c && !parked) continue;
sessions[session.id] = {
pending: c?.pending ?? 0,
unseen: c?.unseen ?? 0,
oldestUnseenAt: c?.oldestUnseenAt ?? null,
parked,
};
}
return { success: true, data: { sessions } };
});
}
+12
View File
@@ -6,15 +6,20 @@ export { registerPushRoutes } from './push-routes.js';
export { registerTeamRoutes } from './team-routes.js';
export { registerMuxRoutes } from './mux-routes.js';
export { registerFileRoutes } from './file-routes.js';
export { registerHtmlViewRoutes } from './html-view-routes.js';
export { registerScheduledRoutes } from './scheduled-routes.js';
export { registerCronRoutes } from './cron-routes.js';
export { registerSystemRoutes } from './system-routes.js';
export { registerHookEventRoutes } from './hook-event-routes.js';
export { registerApprovalRoutes } from './approval-routes.js';
export { registerInboxRoutes } from './inbox-routes.js';
export { registerRebootRestoreRoutes } from './reboot-restore-routes.js';
export { registerReadMyMindRoutes } from './readmymind-routes.js';
export { registerGitStatusRoutes } from './git-status-routes.js';
export { registerEditHistoryRoutes } from './edit-history-routes.js';
export { registerStatusTelemetryRoutes } from './status-telemetry-routes.js';
export { registerCaseRoutes } from './case-routes.js';
export { registerWorktreeRoutes } from './worktree-routes.js';
export { registerSessionRoutes } from './session-routes.js';
export { registerRespawnRoutes } from './respawn-routes.js';
export { registerRalphRoutes } from './ralph-routes.js';
@@ -28,6 +33,11 @@ export { registerWsRoutes } from './ws-routes.js';
export { registerVoiceRoutes } from './voice-routes.js';
export { registerWebviewRoutes, tryWebviewRefererFallback } from './webview-routes.js';
export { registerTabLayoutRoutes } from './tab-layout-routes.js';
export { registerMcpSyncRoutes } from './mcp-sync-routes.js';
export { registerConfigBackupRoutes, runConfigBackup } from './config-backup-routes.js';
export { registerWebhookRoutes } from './webhook-routes.js';
export { registerDiscordRoutes } from './discord-routes.js';
export { registerDoctorRoutes } from './doctor-routes.js';
export {
registerCustomModelRoutes,
refreshAllCustomModelHosts,
@@ -39,3 +49,5 @@ export {
type CustomModelSwapDisplacement,
} from './custom-model-routes.js';
export { registerCliRegistryRoutes, readCliManagementEnabled, type CliListItem } from './cli-registry-routes.js';
export { registerAgentSessionRoutes } from './agent-session-routes.js';
+104
View File
@@ -0,0 +1,104 @@
/**
* @fileoverview MCP server sync (src/mcp-sync.ts).
*
* GET /api/mcp-sync — dry run: per participating CLI, which servers it has and which it would gain.
* POST /api/mcp-sync — apply: add the missing servers to each CLI's own config file.
*
* Opt-in: both verbs answer 403 until `mcpSyncEnabled` is on (default OFF), because this writes
* OTHER tools' own user config. Writes files in the SERVER user's home, so in multi-user mode it
* is admin only. A second apply while one is running answers 409. Responses carry server names
* only, never env values, headers or file content (a parse failure is reported by position).
*
* A CLI takes part when it is ENABLED in the registry, declares an `mcpConfig`, and is installed
* or already has its config file (Copilot CLI declares its own `mcpConfig`; with its run mode disabled it still
* takes part as a sync-only tool when installed or when its config file exists); one that is enabled but absent from the machine is reported
* `absent` and never created. Its file is located with this process's env (the env the CLIs
* Codeman spawns inherit), so a relocation var such as `CODEX_HOME` is followed.
*/
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import {
ApiErrorCode,
createErrorResponse,
getErrorMessage,
type ApiResponse,
type McpSyncResult,
} from '../../types.js';
import { isAdmin, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import { enabledClis } from '../../config/cli-registry/registry.js';
import { isCliEntryInstalled, probeStockCliAvailability } from '../../utils/cli-installed-probes.js';
import { McpSyncBusyError, syncMcpServers, type McpSyncTarget } from '../../mcp-sync.js';
import { mcpSyncOnlyTargets } from '../../mcp-sync-targets.js';
/** Default OFF, same shape as `readCliManagementEnabled`: read fresh so a toggle applies at once. */
export async function readMcpSyncEnabled(): Promise<boolean> {
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {});
return settings.mcpSyncEnabled === true;
}
/**
* Enabled CLIs that declare an MCP config file, in registry order (first definition wins), then the
* sync-only tools (src/mcp-sync-targets.ts: Copilot CLI while its run mode is disabled), which come
* last so a registry CLI's definition wins a same-name difference.
*/
export function mcpSyncTargets(availability: Record<string, boolean>): McpSyncTarget[] {
const registry = enabledClis()
.filter((e) => e.capabilities.mcpConfig)
.sort((a, b) => a.order - b.order)
.map((e) => ({
id: e.id,
label: e.label,
...e.capabilities.mcpConfig!,
installed: isCliEntryInstalled(e, availability),
}));
return [...registry, ...mcpSyncOnlyTargets(new Set(registry.map((t) => t.id)))];
}
/**
* Installed, enabled agent CLIs with no known MCP config file (sync cannot touch them). One that
* is not installed is left out, the same way a supported one that is not installed reads `absent`.
*/
export function mcpUnsupportedLabels(availability: Record<string, boolean>): string[] {
return enabledClis()
.filter((e) => e.kind === 'agent' && !e.capabilities.mcpConfig && isCliEntryInstalled(e, availability))
.map((e) => e.label);
}
async function gate(req: FastifyRequest): Promise<ApiResponse<never> | null> {
if (isMultiUserMode() && !isAdmin(req)) {
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
}
if (!(await readMcpSyncEnabled())) {
return createErrorResponse(
ApiErrorCode.FORBIDDEN,
'MCP sync is disabled. Turn on "Enable MCP server sync" in Settings and save first.'
);
}
return null;
}
export function registerMcpSyncRoutes(app: FastifyInstance): void {
const run = async (req: FastifyRequest, reply: FastifyReply, apply: boolean): Promise<ApiResponse<McpSyncResult>> => {
const denied = await gate(req);
if (denied) {
reply.code(403);
return denied;
}
try {
const availability = await probeStockCliAvailability();
const targets = mcpSyncTargets(availability);
const data = await syncMcpServers(targets, { apply, env: process.env }, mcpUnsupportedLabels(availability));
return { success: true, data };
} catch (err) {
if (err instanceof McpSyncBusyError) {
reply.code(409);
return createErrorResponse(ApiErrorCode.CONFLICT, err.message);
}
reply.code(500);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
};
app.get('/api/mcp-sync', (req, reply) => run(req, reply, false));
app.post('/api/mcp-sync', (req, reply) => run(req, reply, true));
}
+67 -18
View File
@@ -5,7 +5,7 @@
*/
import { FastifyInstance } from 'fastify';
import { join, dirname, resolve, relative, isAbsolute } from 'node:path';
import { join, resolve, relative, isAbsolute } from 'node:path';
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import fs from 'node:fs/promises';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
@@ -16,20 +16,27 @@ import { SseEvent } from '../sse-events.js';
import {
autoConfigureRalph,
getAuthUser,
isAdmin,
ownerFor,
resolveCasesDir,
sessionCapacityMessage,
SETTINGS_PATH,
findSessionOrFail,
parseBody,
} from '../route-helpers.js';
import { mutateSettingsFile } from '../settings-writer.js';
import { resolveClaudeModeForUsername } from '../../user-store.js';
import { writeHooksConfig, stripCaseEnvKeys } from '../../hooks-config.js';
import { generateClaudeMd } from '../../templates/claude-md.js';
import { buildRalphLoopPrompt } from '../../prompts/index.js';
import { getLifecycleLog } from '../../session-lifecycle-log.js';
import { remoteProbePaths, remoteReadFile, RemoteFileAccessError } from '../../remote-files.js';
import type { SessionRemote } from '../../types/session.js';
import { isPathWithinRoot } from './file-routes.js';
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
/** Cap on a remote `@fix_plan.md` read over ssh (a task list, not a document). */
const MAX_REMOTE_FIX_PLAN_BYTES = 1024 * 1024;
export function registerRalphRoutes(
app: FastifyInstance,
ctx: SessionPort & EventPort & RespawnPort & ConfigPort & InfraPort
@@ -201,6 +208,15 @@ export function registerRalphRoutes(
app.post('/api/sessions/:id/fix-plan/write', async (req) => {
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id, req);
// A remote session's workingDir is a path on the remote host. Writing it with local fs would land
// the file in a same-named local folder (or fail), and remote writes are out of scope by design
// (src/remote-files.ts is read-only), so it is refused, like the File Viewer's remote edit.
if (session.remote) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'Writing @fix_plan.md is not supported for a remote (SSH) session'
);
}
const workingDir = session.workingDir;
if (!workingDir) {
@@ -237,7 +253,14 @@ export function registerRalphRoutes(
const filePath = join(workingDir, '@fix_plan.md');
try {
const content = await fs.readFile(filePath, 'utf-8');
// A remote session's workingDir is a path on the remote host: read the file there, over the
// same ssh path the File Viewer uses, never a same-named local file.
const content = session.remote
? await readRemoteFixPlan(session.remote, workingDir, filePath)
: await fs.readFile(filePath, 'utf-8');
if (content === null) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, '@fix_plan.md not found in working directory');
}
const importedCount = session.ralphTracker.importFixPlanMarkdown(content);
ctx.persistSessionState(session);
@@ -253,7 +276,8 @@ export function registerRalphRoutes(
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return createErrorResponse(ApiErrorCode.NOT_FOUND, '@fix_plan.md not found in working directory');
}
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${error}`);
const reason = error instanceof RemoteFileAccessError ? error.message : String(error);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${reason}`);
}
});
@@ -267,6 +291,13 @@ export function registerRalphRoutes(
const { id } = req.params as { id: string };
const { content } = parseBody(RalphPromptWriteSchema, req.body, 'Invalid request body');
const session = findSessionOrFail(ctx, id, req);
// Same rule as fix-plan/write: never write a remote session's file on the local disk.
if (session.remote) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'Writing @ralph_prompt.md is not supported for a remote (SSH) session'
);
}
const workingDir = session.workingDir;
if (!workingDir) {
@@ -304,6 +335,7 @@ export function registerRalphRoutes(
planItems,
envOverrides,
effort,
advisorModel,
} = parseBody(RalphLoopStartSchema, req.body);
// Multi-user: cases live in the requesting user's space.
@@ -354,6 +386,7 @@ export function registerRalphRoutes(
allowedTools: rlClaudeModeConfig.allowedTools,
envOverrides,
effort,
advisorModel,
owner: rlOwner,
});
@@ -421,8 +454,8 @@ export function registerRalphRoutes(
const promptPath = join(casePath, '@ralph_prompt.md');
writeFileSync(promptPath, fullPrompt, 'utf-8');
// Register session
await ctx.addSession(session);
// Register session (in its case's default session group, if one is set)
await ctx.addSession(session, { caseName });
ctx.store.incrementSessionsCreated();
ctx.persistSessionState(session);
await ctx.setupSessionListeners(session);
@@ -472,20 +505,17 @@ export function registerRalphRoutes(
});
}
// Save lastUsedCase
try {
let settings: Record<string, unknown> = {};
// Save lastUsedCase. It is a key of the server-wide settings.json, readable by every
// user through GET /api/settings, so a non-admin's case name is not written there in
// multi-user mode (writes are admin-only: PUT /api/settings). isAdmin is always true
// in single-user mode.
if (isAdmin(req)) {
try {
settings = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf-8'));
} catch {
/* ignore */
await mutateSettingsFile((settings) => ({ ...settings, lastUsedCase: caseName }));
} catch (err) {
// Non-critical: the loop starts regardless.
console.warn('[Server] Failed to save settings (lastUsedCase):', getErrorMessage(err));
}
settings.lastUsedCase = caseName;
const dir = dirname(SETTINGS_PATH);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
fs.writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2)).catch(() => {}); // Ignore - persisting lastUsedCase is non-critical
} catch {
/* non-critical */
}
const sessionId = session.id;
@@ -522,3 +552,22 @@ export function registerRalphRoutes(
};
});
}
/**
* Read a remote session's `@fix_plan.md` on its host. `null` = no such file, or one that resolves outside
* the workspace (the caller's `NOT_FOUND`, as for a local ENOENT); anything that is not a regular file, a
* file over the cap, or an ssh failure throws `RemoteFileAccessError`, which the route reports as a failed
* read.
*/
async function readRemoteFixPlan(remote: SessionRemote, workingDir: string, filePath: string): Promise<string | null> {
// The file and the workspace in one round trip, so a symlinked @fix_plan.md that resolves outside the
// workspace is refused like any missing file (the File Viewer's confinement, file-routes.ts).
const [probe, rootProbe] = await remoteProbePaths(remote, [filePath, workingDir]);
if (!probe) return null;
if (!isPathWithinRoot(rootProbe?.realPath ?? resolve(workingDir), probe.realPath)) return null;
if (probe.kind !== 'file') throw new RemoteFileAccessError('@fix_plan.md is not a regular file');
if (probe.size > MAX_REMOTE_FIX_PLAN_BYTES) {
throw new RemoteFileAccessError(`@fix_plan.md is larger than ${MAX_REMOTE_FIX_PLAN_BYTES / 1024 / 1024}MB`);
}
return (await remoteReadFile(remote, probe.realPath, MAX_REMOTE_FIX_PLAN_BYTES)).toString('utf-8');
}
+7 -3
View File
@@ -41,7 +41,7 @@ import { clampEnvOverridesForOwner } from '../../session-env-clamp.js';
import { Session } from '../../session.js';
import { resolveClaudeModeForUsername } from '../../user-store.js';
import { getCli } from '../../config/cli-registry/registry.js';
import { applyWorkspaceHooks, seedAgentSessionPreamble } from '../../hooks-config.js';
import { agentSkillDir, applyWorkspaceHooks, seedAgentSessionPreamble } from '../../hooks-config.js';
import { getLifecycleLog } from '../../session-lifecycle-log.js';
import { STATS_COLLECTION_INTERVAL_MS } from '../../config/server-timing.js';
import { SseEvent } from '../sse-events.js';
@@ -195,10 +195,14 @@ export function registerRebootRestoreRoutes(app: FastifyInstance, ctx: RebootRes
(saved as { __envOverrides?: Record<string, string> }).__envOverrides
),
effort: saved.effort,
model: saved.model,
advisorModel: saved.advisorModel,
attachmentHistory:
(saved as { __attachmentHistory?: SessionAttachmentHistoryItem[] }).__attachmentHistory ??
saved.attachmentHistory,
lastSubmitAt: saved.lastSubmitAt,
lastRealPromptAt: saved.lastRealPromptAt,
keepWarmEnabled: saved.keepWarmEnabled,
claudeSessionChain: saved.claudeSessionChain,
lastActivityAt: saved.lastActivityAt,
owner: saved.owner,
@@ -245,7 +249,7 @@ export function registerRebootRestoreRoutes(app: FastifyInstance, ctx: RebootRes
// skill falls back to writing out the whole ~150-line §0 preamble. Remote and
// docker sessions never reach here (the plan rejects them as
// `remote-or-docker`), so the local-only condition is structural.
if (getCli(session.mode)?.capabilities.agentSkillInjection && (await ctx.getAgentSkillEnabled())) {
if (agentSkillDir(getCli(session.mode)?.capabilities) && (await ctx.getAgentSkillEnabled())) {
await seedAgentSessionPreamble(session.id).catch((err: unknown) =>
console.warn(`[agent-skill] preamble seed failed for ${session.id}: ${getErrorMessage(err)}`)
);
@@ -273,7 +277,7 @@ export function registerRebootRestoreRoutes(app: FastifyInstance, ctx: RebootRes
// count this session's historical tokens into the lifetime totals, demote
// a pinned record to `stopped` (which this pass reads as an intentional
// kill, making the session permanently unrestorable) and delete the
// workspace's `.claude-images`. This undoes only the construction.
// workspace's `.codeman-uploads`. This undoes only the construction.
await ctx
.discardPartiallyBuiltSession(entry.sessionId)
.catch((discardErr: unknown) =>
File diff suppressed because it is too large. Load diff
+41 -3
View File
@@ -7,6 +7,9 @@
* the header "Plan Usage Limits" chip. Auth-exempt like `/api/hook-event`
* (localhost-only; hook-secret-gated while a tunnel runs — see middleware/auth).
*
* Also records the session's model (`model.display_name`) as its reported model, which
* `session:updated` publishes as `displayModel` for the session headers.
*
* Returns a compact plain-text status string for the exporter to print as the
* in-terminal footer (print-through) when it has no statusline of the user's
* own to wrap. An unknown session gets an EMPTY body: the old brand-word
@@ -17,17 +20,19 @@ import { FastifyInstance } from 'fastify';
import { StatusTelemetrySchema } from '../schemas.js';
import { parseBody } from '../route-helpers.js';
import {
parsePromptCache,
parseStatusTelemetry,
parseSessionStatus,
formatSessionStatusText,
telemetrySignature,
type RawStatuslinePayload,
} from '../../usage-telemetry.js';
import { SessionStatusTelemetry } from '../sse-events.js';
import { SessionPromptCache, SessionStatusTelemetry } from '../sse-events.js';
import { setLatestPlanUsage } from '../plan-usage-latest.js';
import type { SessionPort, EventPort } from '../ports/index.js';
import type { SessionPort, EventPort, KeepWarmPort } from '../ports/index.js';
import { getCli } from '../../config/cli-registry/index.js';
export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: SessionPort & EventPort): void {
export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: SessionPort & EventPort & KeepWarmPort): void {
// Last broadcast telemetry signature per session — the statusline fires on
// every assistant message, so we only rebroadcast when the value changes.
const lastSig = new Map<string, string>();
@@ -46,6 +51,39 @@ export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: Session
const payload = data as RawStatuslinePayload | undefined;
// Only a CLI that has a statusline exporter at all speaks for its own model and
// prompt-cache state: both ride the SAME statusline payload the exporter POSTs, so
// both are gated on capabilities.statusLineTelemetry (the gate that also decides
// whether the exporter is injected at all, in tmux-manager._resolveStatusLine).
const session = ctx.sessions.get(sessionId);
const reportsStatusLine = !!session && !!getCli(session.mode)?.capabilities.statusLineTelemetry;
// The model the CLI says it is running, for the session headers (displayModel). It
// rides every render, so it follows an in-session `/model`; the session dedupes and
// broadcasts only a change.
if (session && reportsStatusLine) {
session.noteReportedModel('statusline', payload?.model?.display_name);
}
// Prompt-cache state of the main conversation (Claude Code v2.1.251+): kept on the
// session for the Session Options readout and `toState()`, broadcast only when the
// report changes (the statusline fires on every assistant message). The broadcast
// carries the STORED object, so a compaction stamp set in setPromptCache rides along.
// Reports are applied in arrival order. When the exporter wraps a user's own statusline
// it backgrounds this POST (hooks-config.ts), so two sub-second loopback reports can
// arrive out of order; a stale one then briefly overwrites newer state until the next
// render corrects it. Accepted for a display-only readout rather than ordered here.
const promptCache = reportsStatusLine ? parsePromptCache(payload) : null;
if (promptCache) {
if (session?.setPromptCache(promptCache)) {
ctx.broadcast(SessionPromptCache, { sessionId, ...session.promptCache });
// Persisted on change, like a model change, so a crash restores the latest report.
ctx.persistSessionState(session);
}
// Every report moves `expires_at`, so the keep-warm re-evaluates on each one.
void ctx.keepWarm.schedule(sessionId);
}
// Plan-usage limits (account-wide) → broadcast to the header chip, when
// present and changed (the statusline fires on every assistant message).
const telemetry = parseStatusTelemetry(payload);
+247 -80
View File
@@ -13,11 +13,12 @@ import { totalmem, freemem, loadavg, cpus } from 'node:os';
import { execSync, spawn } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { dataPath } from '../../config/instance.js';
import { readHostSensors } from '../../host-sensors.js';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } from '../../types.js';
import { isUnauthenticatedNetworkAcknowledged } from '../network-auth-policy.js';
import { isExplicitlyEnabled, isUnauthenticatedTunnelAcknowledged } from '../network-auth-policy.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import { findUser, canUsernameRunPrivilegedCommands } from '../../user-store.js';
import { getAuthUser, requireAdmin, canAccessOwned } from '../route-helpers.js';
import { getAuthUser, isAdmin, requireAdmin, canAccessOwned, revokeUserSessions } from '../route-helpers.js';
import {
ConfigUpdateSchema,
SettingsUpdateSchema,
@@ -47,15 +48,26 @@ import {
toggleService,
SETTINGS_PATH,
} from '../route-helpers.js';
import { mutateSettingsFile, settingsWriteErrorResponse } from '../settings-writer.js';
import { SseEvent } from '../sse-events.js';
import { getInstallInfo, checkForUpdate, startUpdate, getUpdateStatusForApi } from '../self-update.js';
import { getRepositoryStatus } from '../repo-status.js';
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort, TabLayoutPort } from '../ports/index.js';
import type {
SessionPort,
EventPort,
ConfigPort,
InfraPort,
AuthPort,
TabLayoutPort,
KeepWarmPort,
} from '../ports/index.js';
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
import { QR_AUTH_FAILURE_MAX } from '../../config/tunnel-config.js';
import { AUTH_SESSION_TTL_MS } from '../../config/auth-config.js';
import { resolveTerminalHistoryConfig } from '../../config/terminal-history.js';
import { agentInboxPersistence } from '../agent-inbox-persistence.js';
import { agentInbox } from '../agent-inbox.js';
/**
* Defaults for `POST /api/deepseek/install-profile`.
@@ -144,9 +156,68 @@ export function resolveSpanUrl(hostHeader: string | undefined, fallbackPort = '3
return `http://localhost:${port}`;
}
/**
* What GET /api/settings answers a NON-ADMIN in multi-user mode: settings.json minus what
* belongs to the server's admin rather than to the reader. `voiceSettings.apiKey` is the
* Deepgram key an admin saved, a credential (the reader's browser seeded it into its own
* voice config and dictated on the admin's account); `caseOrder` and `lastUsedCase` name the
* admin's cases. Everything else stays readable, because the page reads the synced feature
* switches from it. Writes are admin-only there (PUT /api/settings). Exported for unit testing.
*/
export function settingsForNonAdmin(settings: unknown): unknown {
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) return settings;
const out: Record<string, unknown> = { ...(settings as Record<string, unknown>) };
delete out.caseOrder;
delete out.lastUsedCase;
const voice = out.voiceSettings;
if (voice && typeof voice === 'object' && !Array.isArray(voice) && 'apiKey' in voice) {
const voiceOut: Record<string, unknown> = { ...(voice as Record<string, unknown>) };
delete voiceOut.apiKey;
out.voiceSettings = voiceOut;
}
return out;
}
/**
* True when a login protects the server: CODEMAN_PASSWORD is set, or multi-user mode is
* on (every person has their own credential). The tunnel guard and the `loginConfigured`
* field of GET /api/tunnel/status both read this, so the page's answer mirrors the guard.
*/
function isLoginConfigured(): boolean {
return !!process.env.CODEMAN_PASSWORD || isMultiUserMode();
}
/**
* The refusal of a tunnel enable with no login and no tunnel opt-in or acknowledgement: logs
* the diagnostic line and returns the message for the 403. `late` is the case where the tunnel
* was running when the request arrived and had stopped by the time it would be started; the
* request's other settings were saved by then.
*/
function unauthenticatedTunnelRefusal(late: boolean): string {
// Someone who set the network variable to get here before #706 is told why it stopped working.
const networkAcknowledged = isExplicitlyEnabled(process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK);
console.warn(
`[tunnel] Refused an unauthenticated tunnel ${late ? 'restart after a concurrent stop' : 'enable'} (no login, no tunnel opt-in, no acknowledgement${networkAcknowledged ? '; CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK does not cover the tunnel' : ''})`
);
return (
(late
? 'The Cloudflare tunnel stopped while these settings were being saved and was not restarted; ' +
'the other settings were saved. '
: '') +
'Refusing to start the Cloudflare tunnel without authentication: it would publish ' +
'full terminal control to a public URL with no password. Set CODEMAN_PASSWORD to ' +
'require login, set CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1, or resend with ' +
'acknowledgeUnauthTunnel:true to acknowledge an unauthenticated public tunnel.' +
(networkAcknowledged
? ' CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK is set, but it only acknowledges the network ' +
'bind and no longer covers the tunnel.'
: '')
);
}
export function registerSystemRoutes(
app: FastifyInstance,
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort & KeepWarmPort
): void {
const windowStatesPath = dataPath('subagent-window-states.json');
const parentMapPath = dataPath('subagent-parents.json');
@@ -161,14 +232,26 @@ export function registerSystemRoutes(
// ========== Tunnel ==========
app.get('/api/tunnel/status', async () => ctx.tunnelManager.getStatus());
// `loginConfigured` (#706) tells the page whether a tunnel would have a login in front
// of it, so it can ask before an enable even when the server would accept one. It is
// the guard's own login test (isLoginConfigured above), never TunnelManager's, which
// knows nothing about auth.
app.get('/api/tunnel/status', async () => ({
...ctx.tunnelManager.getStatus(),
loginConfigured: isLoginConfigured(),
}));
app.get('/api/tunnel/info', async () => {
app.get('/api/tunnel/info', async (req) => {
const status = ctx.tunnelManager.getStatus();
const sseClients = ctx.getSseClientCount();
const sessions: Array<{ ip: string; ua: string; createdAt: number; method: string }> = [];
// Multi-user mode: a non-admin is shown only their own logins (address and browser of
// every other user's sessions are not theirs to read), matching what their Revoke All
// can revoke (POST /api/auth/revoke). null = everyone's (admin, single-user mode).
const onlyUser = isMultiUserMode() && !isAdmin(req) ? getAuthUser(req).username : null;
if (ctx.authSessions) {
for (const [, record] of ctx.authSessions) {
if (onlyUser !== null && record.username !== onlyUser) continue;
sessions.push({ ip: record.ip, ua: record.ua, createdAt: record.createdAt, method: record.method });
}
}
@@ -300,7 +383,10 @@ export function registerSystemRoutes(
// ========== QR Regeneration ==========
app.post('/api/tunnel/qr/regenerate', async () => {
app.post('/api/tunnel/qr/regenerate', async (req, reply) => {
// Clears EVERY outstanding QR login code (each user's minted one too) and rotates the
// server's token: admin-only in multi-user mode, like the other tunnel controls.
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
ctx.tunnelManager.regenerateQrToken();
return {};
});
@@ -309,6 +395,19 @@ export function registerSystemRoutes(
app.post('/api/auth/revoke', async (req) => {
const result = RevokeSessionSchema.safeParse(req.body);
// Multi-user mode: a NON-ADMIN revokes only their own login sessions. The bare form
// below clears every session on the server, so for them it used to sign out every
// user, admins included; a named token is revoked only when it is one of theirs.
if (isMultiUserMode() && !isAdmin(req)) {
const username = getAuthUser(req).username;
const token = result.success ? result.data.sessionToken : undefined;
if (token) {
if (ctx.authSessions?.peek(token)?.username === username) ctx.authSessions?.delete(token);
} else {
revokeUserSessions(ctx.authSessions, username);
}
return {};
}
if (result.success && result.data.sessionToken) {
ctx.authSessions?.delete(result.data.sessionToken);
} else {
@@ -327,6 +426,8 @@ export function registerSystemRoutes(
// panels can be dragged across the physical monitor seam. macOS only; needs
// the one-time "Displays have separate Spaces" OFF prerequisite (see script).
app.post('/api/system/span-displays', async (req, reply) => {
// Launches a browser window on the SERVER's desktop: admin-only in multi-user mode.
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
// macOS only: the launcher uses osascript + Finder desktop bounds and Chrome
// --app geometry flags. Fail clearly elsewhere instead of spawning a bash
// that errors out invisibly (the toast would otherwise lie "Opening…").
@@ -379,7 +480,9 @@ export function registerSystemRoutes(
// Kick off a detached update to the latest release. Returns immediately; the
// browser then polls /api/system/update/status across the service restart.
app.post('/api/system/update', async (_req, reply) => {
app.post('/api/system/update', async (req, reply) => {
// Updates and restarts the server under every user: admin-only in multi-user mode.
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
const result = await startUpdate();
if (result.ok) {
return { updateId: result.updateId, toTag: result.toTag, toVersion: result.toVersion };
@@ -617,13 +720,16 @@ export function registerSystemRoutes(
// synchronous-spawn precedent this endpoint is modelled on.
detached: true,
// Inherit the environment: this needs a HOME to resolve $DSH_HOME
// against, and a PATH carrying `pnpm`. ⚠️ `dsh plugin` does NOT bundle a
// against, and a PATH carrying `pnpm` (see the PATH below). ⚠️ `dsh plugin` does NOT bundle a
// package manager — it `spawnSync`s a literal `pnpm` with no npm
// fallback, so on a host without one this exits 127 and dsh's own
// stderr ("pnpm not found on PATH") is what reaches the caller through
// the OPERATION_FAILED detail below. That is the same missing
// dependency that broke the docker agent image in issue #352.
env: process.env,
// PATH leads with the directory `dsh` itself resolved to: CLI management installs
// `dsh` and `pnpm` into the user prefix (`~/.local/bin`), which a service's own PATH
// usually lacks, so `dsh plugin` could not find the `pnpm` sitting next to it.
env: { ...process.env, PATH: [dir, process.env.PATH].filter(Boolean).join(':') },
});
} catch (err) {
resolve({ code: null, output: `spawn failed: ${getErrorMessage(err)}`, timedOut: false });
@@ -712,13 +818,30 @@ export function registerSystemRoutes(
};
});
// ========== GitHub Copilot ==========
// Carries `version` like pi/grok/omp: `copilot` is a generic binary name, so the resolver
// only accepts one whose `--version` says "GitHub Copilot CLI".
app.get('/api/copilot/status', async () => {
const { isCopilotAvailable, resolveCopilotDir, getCopilotCliVersion } =
await import('../../utils/copilot-cli-resolver.js');
return {
available: isCopilotAvailable(),
path: resolveCopilotDir(),
version: getCopilotCliVersion(),
};
});
// ═══════════════════════════════════════════════════════════════
// State & Lifecycle (cleanup, lifecycle log, stats)
// ═══════════════════════════════════════════════════════════════
// ========== State & Lifecycle ==========
app.post('/api/cleanup-state', async () => {
app.post('/api/cleanup-state', async (req, reply) => {
// Drops every user's stale session records (and their tab-layout entries): admin-only
// in multi-user mode, like the other server-wide maintenance operations.
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
const activeSessionIds = new Set(ctx.sessions.keys());
const result = await ctx.tabLayouts.runStaleSessionCleanup(activeSessionIds, (ids) =>
ctx.store.cleanupSessionsByIds(ids)
@@ -726,6 +849,14 @@ export function registerSystemRoutes(
const lifecycleLog = getLifecycleLog();
for (const s of result.cleaned) {
lifecycleLog.log({ event: 'stale_cleaned', sessionId: s.id, name: s.name });
// A detached session kept its agent inbox. Its record going is the delete only
// when no tmux session stands behind it: a detached pane the multiplexer still
// lists is re-adopted under the same id at the next boot (restoreMuxSessions,
// with or without a state.json record), and its mail must come back with it.
// Keeping a pane that died while detached errs toward the 12 h TTL and the boot
// prune, the same trade the boot keep set makes.
if (!ctx.mux.getSession(s.id)) agentInbox.drop(s.id);
ctx.forgetEditHistory(s.id);
}
return { cleanedSessions: result.count };
});
@@ -866,7 +997,10 @@ export function registerSystemRoutes(
return { config: ctx.store.getConfig() };
});
app.put('/api/config', async (req) => {
app.put('/api/config', async (req, reply) => {
// Process-wide app config in state.json (poll interval, timeouts, the session cap,
// respawn defaults): admin-only in multi-user mode, like PUT /api/settings.
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
const configData = parseBody(ConfigUpdateSchema, req.body, 'Invalid config');
ctx.store.setConfig(configData as Partial<ReturnType<typeof ctx.store.getConfig>>);
return { config: ctx.store.getConfig() };
@@ -929,28 +1063,44 @@ export function registerSystemRoutes(
// ========== System Stats ==========
app.get('/api/system/stats', async () => {
return getSystemStats();
// GPU (load, memory, clock) and CPU clock readings are opt-in: the browser adds ?gpu=1 and ?cpumhz=1 only while
// the matching header setting is on, so a default install never runs nvidia-smi or reads sysfs.
app.get('/api/system/stats', async (req) => {
const query = req.query as { gpu?: string; cpumhz?: string };
const sensors = await readHostSensors({
gpu: query.gpu === '1',
cpuMhz: query.cpumhz === '1',
});
return { ...getSystemStats(), ...sensors };
});
// ========== Settings ==========
app.get('/api/settings', async () => {
app.get('/api/settings', async (req) => {
// A plain read. This route must NEVER write settings.json: readJsonConfig()
// answers `{}` for ANY read failure (a parse error, EACCES, EMFILE, a read
// that lands inside PUT's non-atomic write), not only for a missing file,
// and every page load calls this route, so a "persist the default when the
// key is absent" reconcile here replaced a whole settings file with one key
// on the first unlucky read. The plan-usage default is resolved by the
// answers `{}` for ANY read failure (a parse error, EACCES, EMFILE), not
// only for a missing file, and every page load calls this route, so a
// "persist the default when the key is absent" reconcile here replaced a
// whole settings file with one key on the first unlucky read. The plan-usage default is resolved by the
// READERS instead: an absent `showPlanUsageLimits` means ON to
// readPlanUsageTelemetryEnabled() (hooks-config.ts), the same way an absent
// `workspaceHooksEnabled` means ON, and the client resolves its own display
// default through planUsageChipEnabled(). Pinned by
// test/routes/system-routes-settings-get-plan-usage-default.test.ts.
return readJsonConfig(SETTINGS_PATH, 'settings', {});
const settings = await readJsonConfig<unknown>(SETTINGS_PATH, 'settings', {});
// Readable by every signed-in user, but a non-admin in multi-user mode gets it without
// the admin's credential and case names (settingsForNonAdmin). isAdmin is always true in
// single-user mode, so that answer is the file as it is.
return isAdmin(req) ? settings : settingsForNonAdmin(settings);
});
app.put('/api/settings', async (req) => {
app.put('/api/settings', async (req, reply) => {
// Multi-user mode: settings.json is ONE file for the whole server (feature switches,
// the plan-usage collection switch, run mode, respawn presets, the tunnel), so a write
// is admin-only, like every other server-wide setting (docs/multi-user-plan.md,
// Settings row). Refused FIRST: nothing is parsed, written, toggled or started for a
// non-admin. GET above stays readable by everyone. No-op in single-user mode.
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
const settings = parseBody(SettingsUpdateSchema, req.body, 'Invalid settings') as Record<string, unknown>;
// COD-55: enabling the Cloudflare tunnel publishes the whole app (full terminal
@@ -958,54 +1108,41 @@ export function registerSystemRoutes(
// tunnel binds to loopback, server.ts's non-loopback bind guard never trips, and
// with no CODEMAN_PASSWORD the auth middleware is inactive — so the tunnel URL is
// unauthenticated. Refuse to start a tunnel unless auth is configured OR exposure
// is acknowledged: either the CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK env var, or an
// is acknowledged: either the CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL opt-in, or an
// explicit per-request `acknowledgeUnauthTunnel:true` (the UI sends this after a
// confirm dialog). This keeps curl/API/CLI callers protected by default while
// letting an operator opt in from the browser without setting the env var.
// Guard runs BEFORE persisting so a refused tunnelEnabled:true is not saved.
if (settings.tunnelEnabled === true && !ctx.tunnelManager.isRunning()) {
// Multi-user mode makes the tunnel authenticated (every person has their own
// credential), so it satisfies the same requirement as CODEMAN_PASSWORD.
const acknowledged =
isMultiUserMode() || isUnauthenticatedNetworkAcknowledged() || settings.acknowledgeUnauthTunnel === true;
if (!acknowledged) {
const msg =
'Refusing to start the Cloudflare tunnel without authentication: it would publish ' +
'full terminal control to a public URL with no password. Set CODEMAN_PASSWORD to ' +
'require login, set CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1, or resend with ' +
'acknowledgeUnauthTunnel:true to acknowledge an unauthenticated public tunnel.';
throw Object.assign(new Error(msg), {
statusCode: 403,
body: createErrorResponse(ApiErrorCode.OPERATION_FAILED, msg),
});
}
// Loud warning whenever a public tunnel is started with no password — whether
// acknowledged via env var or the per-request UI confirmation.
if (!process.env.CODEMAN_PASSWORD) {
console.warn(
'⚠️ [tunnel] Starting an UNAUTHENTICATED public Cloudflare tunnel — no CODEMAN_PASSWORD set. ' +
'Anyone with the tunnel URL gets full terminal control (effectively RCE). ' +
'Set CODEMAN_PASSWORD to require login.'
);
}
// The tunnel opt-in is CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL / --allow-unauthenticated-tunnel,
// never the network one (#706): install.sh writes CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK
// for every passwordless LAN install, and that acknowledges the LAN bind only.
// Decided ONCE per request, whether or not the tunnel is running now, and required again
// where the tunnel is really started below: the settings file awaits in between leave room
// for a concurrent stop, and a save that found the tunnel running (so nothing was refused
// here) must not then restart it unguarded.
const tunnelEnableAuthorized =
settings.tunnelEnabled === true &&
(isLoginConfigured() ||
isUnauthenticatedTunnelAcknowledged(ctx.allowUnauthenticatedTunnel) ||
settings.acknowledgeUnauthTunnel === true);
if (settings.tunnelEnabled === true && !tunnelEnableAuthorized && !ctx.tunnelManager.isRunning()) {
const msg = unauthenticatedTunnelRefusal(false);
throw Object.assign(new Error(msg), {
statusCode: 403,
body: createErrorResponse(ApiErrorCode.OPERATION_FAILED, msg),
});
}
try {
const dir = dirname(SETTINGS_PATH);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
let existing: Record<string, unknown> = {};
try {
existing = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf-8'));
} catch {
/* ignore */
}
// acknowledgeUnauthTunnel is an ACTION field (not a stored setting) — strip
// it before persisting so settings.json stays clean.
const { acknowledgeUnauthTunnel, ...settingsToStore } = settings;
// An enable that is not authorized only got this far because the tunnel was running.
// It must never write tunnelEnabled:true: a concurrent stop has written false by now,
// and true on disk would show a stopped tunnel as on. The stored value stays as it is.
if (settings.tunnelEnabled === true && !tunnelEnableAuthorized) delete settingsToStore.tunnelEnabled;
const { before: existing } = await mutateSettingsFile((current) => ({ ...current, ...settingsToStore }));
const merged = { ...existing, ...settingsToStore };
await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2));
// tmux 3.7+ resizes tracked panes; older versions apply this to new panes.
// Already-evicted history cannot be recovered on either version.
@@ -1023,6 +1160,15 @@ export function registerSystemRoutes(
// drift. Same convention as the tmuxHistoryLimit block above.
// Handle subagent tracking toggle dynamically
toggleService((merged.subagentTrackingEnabled as boolean) ?? true, subagentWatcher, 'Subagent watcher');
// The prompt-cache keep-warm keeps its default and cap in memory; a changed default or cap
// makes it re-read them and re-evaluate every session now rather than at its next cache report.
// Compared on `merged`, not tested for in the body: the browser sends both keys on every save.
if (
merged.promptCacheKeepWarm !== existing.promptCacheKeepWarm ||
merged.promptCacheKeepWarmMaxHours !== existing.promptCacheKeepWarmMaxHours
) {
ctx.keepWarm.rescheduleAll();
}
// Handle ultracode/workflow run watcher toggle dynamically (default OFF).
// Either the docked panel OR the floating windows keep the watcher running.
@@ -1032,11 +1178,16 @@ export function registerSystemRoutes(
'Workflow run watcher'
);
// Agent inbox snapshot (default OFF). From `merged` like the toggles above: a
// partial PUT that omits the key must not switch it off and delete the file.
agentInboxPersistence.setEnabled(merged.agentInboxPersistEnabled === true);
// Handle image watcher toggle dynamically
toggleService((merged.imageWatcherEnabled as boolean) ?? false, imageWatcher, 'Image watcher', () => {
// Re-watch all active sessions that have image watcher enabled
// Re-watch all active sessions that have image watcher enabled (never a remote session: its
// workingDir is a path on the remote host, not on this disk)
for (const session of ctx.sessions.values()) {
if (session.imageWatcherEnabled) {
if (session.imageWatcherEnabled && !session.remote) {
imageWatcher.watchSession(session.id, session.workingDir);
}
}
@@ -1064,6 +1215,20 @@ export function registerSystemRoutes(
if ('tunnelEnabled' in settings) {
const tunnelEnabled = settings.tunnelEnabled as boolean;
if (tunnelEnabled && !ctx.tunnelManager.isRunning()) {
// The tunnel stopped since the guard above saw it running: the same authorization applies.
if (!tunnelEnableAuthorized) {
reply.code(403);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, unauthenticatedTunnelRefusal(true));
}
// Loud warning whenever a public tunnel is started with no login in front of it, whether
// acknowledged via the tunnel opt-in or the per-request UI confirmation.
if (!isLoginConfigured()) {
console.warn(
'⚠️ [tunnel] Starting an UNAUTHENTICATED public Cloudflare tunnel — no CODEMAN_PASSWORD set. ' +
'Anyone with the tunnel URL gets full terminal control (effectively RCE). ' +
'Set CODEMAN_PASSWORD to require login.'
);
}
ctx.tunnelManager.start(ctx.port, ctx.https);
console.log('Tunnel started via settings change');
} else if (tunnelEnabled && ctx.tunnelManager.isRunning() && ctx.tunnelManager.getUrl()) {
@@ -1078,7 +1243,7 @@ export function registerSystemRoutes(
return {};
} catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
return settingsWriteErrorResponse(err);
}
});
@@ -1089,29 +1254,17 @@ export function registerSystemRoutes(
return { success: true, data: settings.modelConfig || {} };
});
app.put('/api/execution/model-config', async (req) => {
app.put('/api/execution/model-config', async (req, reply) => {
// The `modelConfig` key of the server-wide settings.json (cron's and the plan
// orchestrator's default model): admin-only in multi-user mode, like PUT /api/settings.
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
const modelConfig = parseBody(ModelConfigUpdateSchema, req.body, 'Invalid model config') as Record<string, unknown>;
try {
let existingSettings: Record<string, unknown> = {};
try {
const content = await fs.readFile(SETTINGS_PATH, 'utf-8');
existingSettings = JSON.parse(content);
} catch {
// File doesn't exist yet, start fresh
}
existingSettings.modelConfig = modelConfig;
const dir = dirname(SETTINGS_PATH);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
await fs.writeFile(SETTINGS_PATH, JSON.stringify(existingSettings, null, 2));
await mutateSettingsFile((current) => ({ ...current, modelConfig }));
return {};
} catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
return settingsWriteErrorResponse(err);
}
});
@@ -1295,8 +1448,20 @@ export function registerSystemRoutes(
// ═══════════════════════════════════════════════════════════════
// ========== Screenshots ==========
// Deprecated (the upload page is gone): removed in a later MAJOR per
// docs/versioning-policy.md. Warns once per process on first use.
let screenshotsDeprecationWarned = false;
const warnScreenshotsDeprecated = (): void => {
if (screenshotsDeprecationWarned) return;
screenshotsDeprecationWarned = true;
console.warn(
'[deprecated] /api/screenshots is deprecated and will be removed in a future major release. ' +
'Use POST /api/sessions/:id/paste-image to hand a file to a session.'
);
};
app.post('/api/screenshots', async (req, reply) => {
warnScreenshotsDeprecated();
const contentType = req.headers['content-type'] ?? '';
if (!contentType.includes('multipart/form-data')) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Expected multipart/form-data');
@@ -1383,6 +1548,7 @@ export function registerSystemRoutes(
});
app.get('/api/screenshots', async () => {
warnScreenshotsDeprecated();
if (!existsSync(SCREENSHOTS_DIR)) {
return { files: [] };
}
@@ -1396,6 +1562,7 @@ export function registerSystemRoutes(
});
app.get('/api/screenshots/:name', async (req, reply) => {
warnScreenshotsDeprecated();
const { name } = req.params as { name: string };
// Prevent path traversal
if (name.includes('/') || name.includes('\\') || name.includes('..')) {
+109
View File
@@ -0,0 +1,109 @@
/**
* @fileoverview Webhook notification settings (src/webhook-notify.ts).
*
* GET /api/webhook — the config WITHOUT its URL (scheme + host only), and the last delivery result
* PUT /api/webhook — change enabled / kind / url / scope; an empty `url` clears it
* POST /api/webhook/test — send one test message with the saved config
*
* The URL is a bearer secret (anyone holding a Slack/Discord webhook URL can post as it), so it is
* stored in its own 0600 file and never returned. In multi-user mode all three routes are admin only:
* the channel receives every session's events, the same reach an admin's own Web Push has.
*/
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import {
ApiErrorCode,
createErrorResponse,
getErrorMessage,
type ApiResponse,
type WebhookResult,
type WebhookStatus,
} from '../../types.js';
import { isAdmin, parseBody } from '../route-helpers.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import { WebhookUpdateSchema } from '../schemas.js';
import {
maskWebhookUrl,
readWebhookConfig,
webhookUrlProblem,
writeWebhookConfig,
type WebhookNotifier,
} from '../../webhook-notify.js';
export interface WebhookRouteDeps {
notifier: WebhookNotifier;
configDir: string;
/** The instance's window title, so a test message says which machine sent it. */
hostTitle: () => string;
}
export function registerWebhookRoutes(app: FastifyInstance, deps: WebhookRouteDeps): void {
const denied = (req: FastifyRequest, reply: FastifyReply): ApiResponse<never> | null => {
if (isMultiUserMode() && !isAdmin(req)) {
reply.code(403);
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
}
return null;
};
const status = async (): Promise<WebhookStatus> => {
const cfg = await readWebhookConfig(deps.configDir);
return {
enabled: cfg.enabled,
kind: cfg.kind,
scope: cfg.scope,
hasUrl: cfg.url !== '',
urlMasked: maskWebhookUrl(cfg.url),
lastResult: deps.notifier.lastResult,
};
};
app.get('/api/webhook', async (req, reply): Promise<ApiResponse<WebhookStatus>> => {
const no = denied(req, reply);
if (no) return no;
return { success: true, data: await status() };
});
app.put('/api/webhook', async (req, reply): Promise<ApiResponse<WebhookStatus>> => {
const no = denied(req, reply);
if (no) return no;
const patch = parseBody(WebhookUpdateSchema, req.body, 'Invalid webhook settings');
const current = await readWebhookConfig(deps.configDir);
const next = {
enabled: patch.enabled ?? current.enabled,
kind: patch.kind ?? current.kind,
scope: patch.scope ?? current.scope,
url: patch.url !== undefined ? patch.url.trim() : current.url,
};
if (next.url) {
const problem = webhookUrlProblem(next.url);
if (problem) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, problem);
}
}
if (next.enabled && !next.url) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Add a webhook URL before enabling notifications');
}
try {
await writeWebhookConfig(deps.configDir, next);
} catch (err) {
reply.code(500);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
return { success: true, data: await status() };
});
app.post('/api/webhook/test', async (req, reply): Promise<ApiResponse<WebhookResult>> => {
const no = denied(req, reply);
if (no) return no;
const cfg = await readWebhookConfig(deps.configDir);
if (!cfg.url) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Save a webhook URL first');
}
// 200 even when delivery failed: the request to Codeman worked, `data.ok` says whether the webhook did.
return { success: true, data: await deps.notifier.sendTest(cfg, deps.hostTitle()) };
});
}
+1
View File
@@ -473,6 +473,7 @@ async function proxyRequest(
sessionCookieName: AUTH_COOKIE_NAME,
refererPath:
typeof req.headers.referer === 'string' ? stripProxyPrefix(req.headers.referer, cap, basePath) : undefined,
relayBodyLength: hasBody && req.body !== undefined,
});
// #237: the timeout bounds TIME-TO-HEADERS only. A plain AbortSignal.timeout on
+290
View File
@@ -0,0 +1,290 @@
/**
* @fileoverview Git worktrees of a local case (src/git-worktrees.ts holds the rules):
*
* GET /api/cases/:name/worktrees the case repository's worktrees, which of them
* Codeman manages, and the live sessions in each
* POST /api/cases/:name/worktrees create `<case>/.claude/worktrees/<name>` on a
* new branch (`{ name, branch?, base? }`)
* DELETE /api/cases/:name/worktrees/:worktree `git worktree remove` WITHOUT --force, refused
* while a live session runs inside; optional
* `?deleteBranch=true` is `git branch -d`
*
* A session starts in a worktree through the EXISTING create paths, never a new spawn path here:
* `POST /api/quick-start` with `worktree: <name>` (validated against this module's rules), or
* `POST /api/sessions` with the worktree path as `workingDir`.
*
* The case is resolved the way quick-start resolves it: an owned remote or Docker case is refused
* with a 400 (git must never run on the host over a Docker workspace, and a remote case's folder
* is on another machine); a linked case resolves for admins only in multi-user mode; anything else
* is the caller's own case space, confined with `isWorkingDirAllowed`. The case folder is probed
* BOUNDED (#516): a case on a network mount that stopped answering is a 422, never a 404.
*
* Statuses are set explicitly from the error code, so the route answers the same with or without
* the server's envelope hook.
*/
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { ApiErrorCode, createErrorResponse, httpStatusForErrorCode, type ApiResponse } from '../../types.js';
import { readDockerCases } from '../../docker-hosts.js';
import { readRemoteCases } from '../../remote-hosts.js';
import { dataPath, getDataDir } from '../../config/instance.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import { listClis } from '../../config/cli-registry/registry.js';
import { agentSkillDir } from '../../hooks-config.js';
import { describeUnknownPath, probePathKind } from '../../utils/index.js';
import {
createCaseWorktree,
isPathWithin,
listCaseWorktrees,
managedWorktreesDir,
removeCaseWorktree,
resolveCaseRepository,
respellUnder,
worktreeNameError,
type CaseRepository,
type CaseWorktree,
type CreatedWorktree,
type RemovedWorktree,
type WorktreeFailure,
type WorktreeGitRunner,
} from '../../git-worktrees.js';
import { CreateWorktreeSchema, RemoveWorktreeQuerySchema } from '../schemas.js';
import {
canAccessOwned,
getAuthUser,
isAdmin,
isWorkingDirAllowed,
parseBody,
readJsonConfig,
resolveCasesDir,
validatePathWithinBase,
} from '../route-helpers.js';
import type { SessionPort } from '../ports/index.js';
const SAFE_CASE_NAME = /^[a-zA-Z0-9_-]+$/;
/** Host paths of every Docker case workspace: git never runs for a case at or inside one. */
const defaultDockerWorkspaces = async (): Promise<string[]> =>
(await readDockerCases(getDataDir()).catch(() => [])).map((c) => c.hostWorkspacePath).filter(Boolean);
/**
* Every workspace skills folder a session of some registry CLI gets Codeman's agent skill in
* (`capabilities.agentSkillDir`), read from the registry at call time as data, never by CLI id.
*/
const registryAgentSkillDirs = (): string[] => {
const dirs = new Set<string>();
for (const cli of listClis()) {
const dir = agentSkillDir(cli.capabilities);
if (dir) dirs.add(dir);
}
return [...dirs];
};
/** A live session in a worktree, as the list reports it to a caller who may see it. */
export interface WorktreeSessionRef {
id: string;
name: string;
}
/** One row of `GET /api/cases/:name/worktrees`. */
export interface CaseWorktreeInfo extends CaseWorktree {
/** Live sessions running in this worktree (deepest worktree wins), all owners. */
sessionCount: number;
/** The ones the caller may see (all of them outside multi-user mode). */
sessions: WorktreeSessionRef[];
}
export interface CaseWorktreeList {
caseName: string;
repoRoot: string;
/** `<repo>/.claude/worktrees`, where Codeman creates and removes worktrees. */
worktreesDir: string;
worktrees: CaseWorktreeInfo[];
truncated: boolean;
}
type Fail = ApiResponse<never>;
function failWith(reply: FastifyReply, code: ApiErrorCode, message: string): Fail {
reply.code(httpStatusForErrorCode(code));
return createErrorResponse(code, message);
}
function failFrom(reply: FastifyReply, failure: WorktreeFailure): Fail {
return failWith(reply, ApiErrorCode[failure.code], failure.message);
}
/**
* The LOCAL folder of case `name` for this caller, or the refusal. Mirrors quick-start's
* resolution (remote, then Docker, then linked, then the caller's case space) so a name means
* the same case here as it does to the Run button.
*/
async function resolveLocalCaseFolder(
req: FastifyRequest,
name: string
): Promise<{ ok: true; casePath: string } | { ok: false; code: ApiErrorCode; message: string }> {
if (!SAFE_CASE_NAME.test(name)) {
return { ok: false, code: ApiErrorCode.INVALID_INPUT, message: 'Invalid case name' };
}
const user = getAuthUser(req);
const configDir = getDataDir();
// Ownership folded into the match, as quick-start does: a foreign same-named remote/Docker
// case falls through to the caller's own local case instead of shadowing it.
if ((await readRemoteCases(configDir)).some((c) => c.name === name && canAccessOwned(user, c.owner))) {
return {
ok: false,
code: ApiErrorCode.INVALID_INPUT,
message: 'Worktrees are not available for remote (SSH) cases: the repository is on another machine.',
};
}
if ((await readDockerCases(configDir)).some((c) => c.name === name && canAccessOwned(user, c.owner))) {
return {
ok: false,
code: ApiErrorCode.INVALID_INPUT,
message:
'Worktrees are not available for Docker cases: a container can write to the workspace, and git would run its hooks and filters on this machine.',
};
}
const linkedCases = await readJsonConfig<Record<string, string>>(dataPath('linked-cases.json'), 'linked cases', {});
// Linked cases carry no owner: only admins resolve them in multi-user mode.
const linked = !isMultiUserMode() || isAdmin(req) ? linkedCases[name] : undefined;
const casePath = linked || validatePathWithinBase(name, resolveCasesDir(user));
if (!casePath) return { ok: false, code: ApiErrorCode.INVALID_INPUT, message: 'Invalid case path' };
if (!isWorkingDirAllowed(user, casePath)) {
return { ok: false, code: ApiErrorCode.FORBIDDEN, message: 'case path is outside your workspace' };
}
const kind = await probePathKind(casePath, { pastCap: true });
if (kind === 'unknown') {
return {
ok: false,
code: ApiErrorCode.OPERATION_FAILED,
message: describeUnknownPath('Case folder', casePath, { pastCap: true }),
};
}
if (kind === 'absent') return { ok: false, code: ApiErrorCode.NOT_FOUND, message: `Case "${name}" not found` };
if (kind !== 'directory')
return { ok: false, code: ApiErrorCode.INVALID_INPUT, message: 'Case path is not a folder' };
return { ok: true, casePath };
}
/**
* Live local sessions, with the folder each runs in as the case's repository spells it
* (`respellUnder`: a folder named through the case's own path, which can run through a symlink,
* is moved onto the repository's real top level, where git lists the worktrees). Remote sessions
* have no local folder.
*/
function liveLocalSessions(
ctx: SessionPort,
casePath: string,
repoRoot: string
): Array<{ id: string; name: string; owner?: string; dir: string }> {
const out: Array<{ id: string; name: string; owner?: string; dir: string }> = [];
for (const session of ctx.sessions.values()) {
if (session.remote || !session.workingDir || !session.workingDir.startsWith('/')) continue;
const dir = respellUnder(session.workingDir, casePath, repoRoot);
out.push({ id: session.id, name: session.name, owner: session.owner, dir });
}
return out;
}
export function registerWorktreeRoutes(
app: FastifyInstance,
ctx: SessionPort,
deps: { git?: WorktreeGitRunner; dockerWorkspaces?: () => Promise<string[]> } = {}
): void {
const dockerWorkspaces = deps.dockerWorkspaces ?? defaultDockerWorkspaces;
/**
* Case folder → its repository, or the refusal already rendered. `casePath` is the folder as
* the case resolves it (possibly through a symlink); `repo.repoRoot` is its real path.
*/
async function caseRepository(
req: FastifyRequest,
reply: FastifyReply,
name: string
): Promise<{ ok: true; repo: CaseRepository; casePath: string; dockerRoots: string[] } | { ok: false; fail: Fail }> {
const folder = await resolveLocalCaseFolder(req, name);
if (!folder.ok) return { ok: false, fail: failWith(reply, folder.code, folder.message) };
const dockerRoots = await dockerWorkspaces();
const repo = await resolveCaseRepository(folder.casePath, { git: deps.git, dockerWorkspaces: dockerRoots });
if (!repo.ok) return { ok: false, fail: failFrom(reply, repo) };
return { ok: true, repo: repo.repo, casePath: folder.casePath, dockerRoots };
}
app.get('/api/cases/:name/worktrees', async (req, reply): Promise<ApiResponse<CaseWorktreeList>> => {
const { name } = req.params as { name: string };
const resolved = await caseRepository(req, reply, name);
if (!resolved.ok) return resolved.fail;
const { repo, casePath } = resolved;
const listed = await listCaseWorktrees(repo, { git: deps.git });
if (!listed.ok) return failFrom(reply, listed);
// Each live session belongs to the DEEPEST worktree holding its folder, so a session in a
// managed worktree is not also counted against the main checkout that contains it.
const user = getAuthUser(req);
const rows: CaseWorktreeInfo[] = listed.worktrees.map((wt) => ({ ...wt, sessionCount: 0, sessions: [] }));
for (const session of liveLocalSessions(ctx, casePath, repo.repoRoot)) {
let best: CaseWorktreeInfo | undefined;
for (const row of rows) {
if (!isPathWithin(session.dir, row.path)) continue;
if (!best || row.path.length > best.path.length) best = row;
}
if (!best) continue;
best.sessionCount += 1;
if (canAccessOwned(user, session.owner)) best.sessions.push({ id: session.id, name: session.name });
}
return {
success: true,
data: {
caseName: name,
repoRoot: repo.repoRoot,
worktreesDir: managedWorktreesDir(repo.repoRoot),
worktrees: rows,
truncated: listed.truncated,
},
};
});
app.post('/api/cases/:name/worktrees', async (req, reply): Promise<ApiResponse<{ worktree: CreatedWorktree }>> => {
const { name } = req.params as { name: string };
const body = parseBody(CreateWorktreeSchema, req.body);
const nameError = worktreeNameError(body.name);
if (nameError) return failWith(reply, ApiErrorCode.INVALID_INPUT, nameError);
const resolved = await caseRepository(req, reply, name);
if (!resolved.ok) return resolved.fail;
const created = await createCaseWorktree(resolved.repo, body, {
git: deps.git,
agentSkillDirs: registryAgentSkillDirs(),
});
if (!created.ok) return failFrom(reply, created);
reply.code(201);
return { success: true, data: { worktree: created.worktree } };
});
app.delete(
'/api/cases/:name/worktrees/:worktree',
async (req, reply): Promise<ApiResponse<{ removed: RemovedWorktree }>> => {
const { name, worktree } = req.params as { name: string; worktree: string };
const query = parseBody(RemoveWorktreeQuerySchema, req.query ?? {});
const nameError = worktreeNameError(worktree);
if (nameError) return failWith(reply, ApiErrorCode.INVALID_INPUT, nameError);
const resolved = await caseRepository(req, reply, name);
if (!resolved.ok) return resolved.fail;
const removed = await removeCaseWorktree(
resolved.repo,
{
name: worktree,
deleteBranch: query.deleteBranch === 'true' || query.deleteBranch === '1',
// Any owner's session blocks: removing the folder under a running agent is the damage
// this refusal exists to prevent, whoever started it.
isInUse: (path) =>
liveLocalSessions(ctx, resolved.casePath, resolved.repo.repoRoot).some((s) => isPathWithin(s.dir, path)),
},
// The worktree folder itself must not be (or hold) a Docker case workspace either.
{ git: deps.git, dockerWorkspaces: resolved.dockerRoots }
);
if (!removed.ok) return failFrom(reply, removed);
return { success: true, data: { removed: removed.removed } };
}
);
}
+83 -45
View File
@@ -29,6 +29,11 @@
* {"t":"i","d":"...","seq":N,"cid":"..."} — input (keystroke or paste). seq+cid are
* optional reliable-delivery tags: the server applies each
* (cid,seq) at-most-once and ACKs with {"t":"ia","seq":N}.
* A frame that arrives while a guarded submit holds the
* session's input lease is held, then applied and ACKed in
* arrival order once it is released; when that hold queue
* is full the socket is closed with 1013 and nothing later
* from it is applied, so the client resends in order.
* {"t":"z","c":N,"r":N,"f":bool} — resize terminal (f=true forces SIGWINCH even if dims unchanged)
*/
@@ -169,12 +174,69 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost
const sizingToken = Symbol('ws-desktop-sizing');
let holdsDesktopClaim = false;
// Set once an input frame could not be held behind a guarded submit's lease
// (the hold queue was full). Nothing later from this socket is applied: it is
// closed, and the browser reconnects and resends everything unACKed, in order.
let inputStalled = false;
// Apply one input frame and ACK it. Runs inline, or later and in arrival order
// when a guarded submit held the input lease as the frame arrived.
const applyInputFrame = (msg: { d: string; seq?: unknown; cid?: unknown }) => {
// Reliable delivery: when the frame carries a clientId + seq, apply it
// exactly once (skip a duplicate redelivery) but ACK it regardless so
// the client can drop it from its durable queue. Frames without seq
// (legacy/other tools) are applied as-is — no behavior change.
const cid = typeof msg.cid === 'string' ? msg.cid : null;
const seq = Number.isInteger(msg.seq) ? (msg.seq as number) : null;
const apply = cid && seq !== null ? session.shouldApplyInput(cid, seq) : true;
let delivered = true;
if (apply) {
// Typed input from a claim-holding desktop keeps the claim "hot"
// and re-asserts the desktop layout after a mobile override.
if (holdsDesktopClaim) session.noteDesktopActivity();
// Browser keystrokes are the user's own, so they may name the tab.
delivered = session.write(msg.d, { fromUser: true });
// A session whose PTY is gone swallows the write. ACKing anyway told
// the client to drop the frame from its durable queue and left the seq
// burnt, so the retry that reliable delivery exists for was rejected as
// a duplicate: the input was lost for good.
if (!delivered && cid && seq !== null) session.forgetInputSeq(cid, seq);
}
if (seq !== null && socket.readyState === 1) {
if (apply) {
if (delivered) socket.send(`{"t":"ia","seq":${seq}}`);
} else {
// REJECTED as a duplicate. ACK it — the client must still drop it
// from its durable queue — but say so, and hand back our watermark.
//
// A plain ACK here is indistinguishable from "applied", which is
// what made a client with a rolled-back counter unrecoverable: its
// seqs persist to localStorage on a DEBOUNCED write, so a tab killed
// between a send and that write comes back with a counter BELOW this
// watermark, every later keystroke lands at or under it, and each one
// is dropped-but-ACKed. The UI stays clean, nothing is delivered, and
// a reload restores the same stale counter. `last` is what lets the
// client lift itself out.
// ⚠️ Defensive: the session arrives through a structural port, and an
// implementation without this method must not take the whole input
// path down with it — a throw here aborts the message handler and the
// frame is never ACKed at all, which strands it in the client's queue.
const watermark =
typeof (session as { lastInputSeq?: (c: string) => number }).lastInputSeq === 'function'
? (session as { lastInputSeq: (c: string) => number }).lastInputSeq(cid as string)
: seq;
socket.send(`{"t":"ia","seq":${seq},"dup":true,"last":${watermark}}`);
}
}
};
// Attach message handler synchronously BEFORE any async work
// (@fastify/websocket requirement to avoid dropped messages).
socket.on('message', (raw) => {
try {
const msg = JSON.parse(String(raw));
if (msg.t === 'i' && typeof msg.d === 'string') {
if (inputStalled) return;
if (msg.d.length > MAX_INPUT_LENGTH) {
// Refused for good, so say so: a silent return left the frame
// unACKed and the client redelivered it every few seconds forever
@@ -185,51 +247,27 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost
}
return;
}
// Reliable delivery: when the frame carries a clientId + seq, apply it
// exactly once (skip a duplicate redelivery) but ACK it regardless so
// the client can drop it from its durable queue. Frames without seq
// (legacy/other tools) are applied as-is — no behavior change.
const cid = typeof msg.cid === 'string' ? msg.cid : null;
const seq = Number.isInteger(msg.seq) ? (msg.seq as number) : null;
const apply = cid && seq !== null ? session.shouldApplyInput(cid, seq) : true;
let delivered = true;
if (apply) {
// Typed input from a claim-holding desktop keeps the claim "hot"
// and re-asserts the desktop layout after a mobile override.
if (holdsDesktopClaim) session.noteDesktopActivity();
// Browser keystrokes are the user's own, so they may name the tab.
delivered = session.write(msg.d, { fromUser: true });
// A session whose PTY is gone swallows the write. ACKing anyway told
// the client to drop the frame from its durable queue and left the seq
// burnt, so the retry that reliable delivery exists for was rejected as
// a duplicate: the input was lost for good.
if (!delivered && cid && seq !== null) session.forgetInputSeq(cid, seq);
}
if (seq !== null && socket.readyState === 1) {
if (apply) {
if (delivered) socket.send(`{"t":"ia","seq":${seq}}`);
} else {
// REJECTED as a duplicate. ACK it — the client must still drop it
// from its durable queue — but say so, and hand back our watermark.
//
// A plain ACK here is indistinguishable from "applied", which is
// what made a client with a rolled-back counter unrecoverable: its
// seqs persist to localStorage on a DEBOUNCED write, so a tab killed
// between a send and that write comes back with a counter BELOW this
// watermark, every later keystroke lands at or under it, and each one
// is dropped-but-ACKed. The UI stays clean, nothing is delivered, and
// a reload restores the same stale counter. `last` is what lets the
// client lift itself out.
// ⚠️ Defensive: the session arrives through a structural port, and an
// implementation without this method must not take the whole input
// path down with it — a throw here aborts the message handler and the
// frame is never ACKed at all, which strands it in the client's queue.
const watermark =
typeof (session as { lastInputSeq?: (c: string) => number }).lastInputSeq === 'function'
? (session as { lastInputSeq: (c: string) => number }).lastInputSeq(cid as string)
: seq;
socket.send(`{"t":"ia","seq":${seq},"dup":true,"last":${watermark}}`);
}
// A guarded submit (agent-staging.ts) holds the input lease across its
// awaited Enter, so frames keep arriving while it is held. Refusing one
// lost it: this path does not wait for ACKs, so the next seq landed after
// the lease, moved the watermark past the refused one, and its redelivery
// was then dropped as a duplicate. Hold the frame instead; the session runs
// it, ACK included, in arrival order the moment the lease is released.
// ⚠️ Defensive, like `lastInputSeq` below: the session arrives through a
// structural port, and one without this method must still deliver input
// (a throw here would drop the frame unACKed).
const held =
typeof session.holdForGuardedLease === 'function'
? session.holdForGuardedLease(() => applyInputFrame(msg), msg.d.length, msg.d)
: 'free';
if (held === 'free') {
applyInputFrame(msg);
} else if (held === 'full') {
// Not held, not applied, not ACKed, and no later frame from this socket
// is applied either, so nothing can overtake it.
inputStalled = true;
console.warn('[ws] input backlog during a guarded submit; closing for redelivery', { sessionId: id });
socket.close(1013, 'Input backlog during guarded submit');
}
} else if (
msg.t === 'z' &&