mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 10:59:32 +01:00
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:
commit
57b2dcc466
858 files changed
+173626
-7078
No files matched your search
@@ -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 } };
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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: [] };
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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 } };
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
+1136
-322
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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('..')) {
|
||||
|
||||
@@ -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()) };
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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' &&
|
||||
|
||||
Reference in new issue
Block a user