mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-07 16:09:43 +02:00
Merge branch 'master' into feat/codex-resume
master and this branch both rewrote the two `_claudeSessionId` resets inside
`start()`, so `src/session.ts` conflicted at both of them.
master's commit ccfda623 puts `restoredConversation` at the head of each
fallback chain. A restored mux attach means the CLI never stopped, so a
`/clear` before the Codeman restart may already have moved it to a
conversation the launch id knows nothing about. The persisted chain's tail is
that conversation, and the CLI's own hook reported it first-hand.
This branch adds `this._codexConfig?.resumeSessionId` to the same two chains,
so a resumed codex session keeps its thread-id alias across every mux reattach
and boot recovery.
Both fixes belong. Each chain now reads restoredConversation, then
_resumeSessionId, then omp's alias, then codex's alias, then the launch id.
The comments from both sides are kept.
test/session-claude-conversation-chain.test.ts pins the shape of those two
assignments by matching the source text, and its pattern named omp's alias as
the last term before `this.id`. Codex's alias now sits between the two, so the
pattern widens to pin the ends of the chain and let the middle grow. A `[^;]`
run cannot cross a statement boundary, so each match is still one assignment.
Checked on the merged tree: typecheck, lint, prettier and the frontend syntax
check all pass, and the CI suite runs 6721 tests green across 349 files.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,7 +13,7 @@ import fs from 'node:fs/promises';
|
||||
import { join, resolve, basename } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { homedir } from 'node:os';
|
||||
import type { ApiResponse, CaseInfo, DockerHost, RemoteSessionInfo, SessionDocker } from '../../types.js';
|
||||
import type { ApiResponse, CaseInfo, DockerHost, RemoteSessionInfo, SessionDocker, SessionMode } from '../../types.js';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
||||
import {
|
||||
CreateCaseSchema,
|
||||
@@ -24,6 +24,9 @@ import {
|
||||
RemoteCaseLinkSchema,
|
||||
RemoteHostSchema,
|
||||
DockerCaseLinkSchema,
|
||||
DockerCaseAdoptSchema,
|
||||
DockerAdoptPreflightSchema,
|
||||
DockerBrowseSchema,
|
||||
DockerHostSchema,
|
||||
DockerExportSchema,
|
||||
DockerImportSchema,
|
||||
@@ -66,6 +69,10 @@ import {
|
||||
DEFAULT_AGENT_IMAGE,
|
||||
dockerContainerName,
|
||||
dockerDisplayPath,
|
||||
probeAdoptableContainer,
|
||||
listDockerContainers,
|
||||
browseInContainer,
|
||||
dockerAdoptProbeModes,
|
||||
readDockerCases,
|
||||
readDockerHosts,
|
||||
removeDockerContainer,
|
||||
@@ -73,6 +80,7 @@ import {
|
||||
writeDockerCases,
|
||||
writeDockerHosts,
|
||||
} from '../../docker-hosts.js';
|
||||
import type { AdoptedContainerProbe, DockerBrowseResult, DockerContainerInfo } from '../../docker-hosts.js';
|
||||
import { buildDockerRemoveCommand } from '../../tmux-manager.js';
|
||||
import {
|
||||
checkRemoteTmuxAvailable,
|
||||
@@ -291,6 +299,8 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
image: host.image,
|
||||
path: dockerCase.hostWorkspacePath,
|
||||
network: host.network ?? 'bridge',
|
||||
...(dockerCase.availableModes ? { availableModes: dockerCase.availableModes } : {}),
|
||||
...(dockerCase.owned === false ? { owned: false } : {}),
|
||||
},
|
||||
};
|
||||
const existingIndex = cases.findIndex((item) => item.name === dockerCase.name);
|
||||
@@ -771,6 +781,191 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* ADOPT an already-running container (`owned: false`). The mirror of the
|
||||
* remote-SSH attach path: Codeman execs into a container the user built and
|
||||
* runs, and never creates, starts, stops, restarts or removes it.
|
||||
*
|
||||
* Everything here is read-only toward the container. The preflight refuses at
|
||||
* LINK time — missing, stopped, or no tmux inside — because the alternative is
|
||||
* failing at session launch, where the only ways out would be a dead pane or
|
||||
* starting a container we do not own. There is no image gate and no
|
||||
* `ensureCaseImage`: adoption never runs `docker create`, so the container's
|
||||
* image is the user's business.
|
||||
*/
|
||||
app.post(
|
||||
'/api/cases/docker-adopt',
|
||||
async (req, reply): Promise<ApiResponse<{ case: unknown; image?: string; availableModes?: SessionMode[] }>> => {
|
||||
// ⚠️ Admin-only in multi-user mode, unlike `docker-link` right above. Linking
|
||||
// creates OUR container, whose only bind mount is a workspace `isWorkingDirAllowed`
|
||||
// has already confined. Adoption names a container someone else built, and its
|
||||
// mounts are whatever its owner gave it — a container mounting `/` hands the
|
||||
// adopter a shell over the whole host, which is exactly the workspace scoping this
|
||||
// mode exists to enforce. Same machine-level reasoning as the docker HOST routes.
|
||||
const denied = adminOnly(req, reply);
|
||||
if (denied) return denied;
|
||||
const dockerCase = {
|
||||
...parseBody(DockerCaseAdoptSchema, req.body),
|
||||
type: 'docker' as const,
|
||||
owner: ownerFor(req),
|
||||
owned: false as const,
|
||||
};
|
||||
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
|
||||
|
||||
const linkedCases = await readLinkedCases();
|
||||
const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR);
|
||||
if (
|
||||
dockerCases.some((item) => item.name === dockerCase.name) ||
|
||||
linkedCases[dockerCase.name] ||
|
||||
existsSync(join(resolveCasesDir(getAuthUser(req)), dockerCase.name))
|
||||
) {
|
||||
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists');
|
||||
}
|
||||
// Two cases must never share one adopted container: session close kills the
|
||||
// in-container tmux by session id, but a shared adoption would let one case's
|
||||
// teardown and another's launch race over the same tmux server.
|
||||
const container = dockerCase.container;
|
||||
if (dockerCases.some((item) => (item.container ?? dockerContainerName(item.name)) === container)) {
|
||||
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, `Container "${container}" is already linked to a case`);
|
||||
}
|
||||
|
||||
if (!isWorkingDirAllowed(getAuthUser(req), dockerCase.hostWorkspacePath)) {
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'hostWorkspacePath is outside your workspace');
|
||||
}
|
||||
// The workspace must ALREADY exist: it mirrors a path inside a container we
|
||||
// did not create, so silently mkdir-ing it would invent a host directory that
|
||||
// does not correspond to whatever is actually mounted there.
|
||||
if (!existsSync(dockerCase.hostWorkspacePath)) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'hostWorkspacePath does not exist. Adoption mirrors an existing container, so point this at the real host directory already mounted into it.'
|
||||
);
|
||||
}
|
||||
|
||||
const availability = await checkDockerAvailable(host.engine);
|
||||
if (!availability.ok) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
availability.error || 'docker daemon is not available'
|
||||
);
|
||||
}
|
||||
// The container workdir is validated INSIDE the container. It defaults to
|
||||
// hostWorkspacePath only because that is what an owned container's bind
|
||||
// mount guarantees; adoption mounts nothing, so the probe has to prove it.
|
||||
const adoptDocker = toSessionDocker(host, dockerCase);
|
||||
const probe = await probeAdoptableContainer(adoptDocker, dockerAdoptProbeModes(), adoptDocker.containerWorkdir);
|
||||
if (!probe.ok) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, probe.error || 'container is not adoptable');
|
||||
}
|
||||
|
||||
// Persist what the container actually has: the run-mode picker gates on
|
||||
// HOST CLIs, which is the wrong question for a case whose agents run inside
|
||||
// a container the host knows nothing about.
|
||||
const adoptedCase = { ...dockerCase, availableModes: probe.availableModes };
|
||||
await writeDockerCases(CODEMAN_CONFIG_DIR, [...dockerCases, adoptedCase]);
|
||||
ctx.broadcast(SseEvent.CaseLinked, {
|
||||
name: adoptedCase.name,
|
||||
path: adoptedCase.hostWorkspacePath,
|
||||
type: 'docker',
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
data: { case: adoptedCase, image: probe.image, availableModes: probe.availableModes },
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Preflight an existing container WITHOUT linking anything, so the UI can tell
|
||||
* the user "not running" / "no tmux" / "codex present, claude missing" before
|
||||
* they commit to a case name. Read-only; never touches container lifecycle.
|
||||
*/
|
||||
/**
|
||||
* Containers on the host's engine, for the adoption picker. Read-only and
|
||||
* best-effort (mirror of the remote `:hostId/sessions` discovery route): an
|
||||
* unreachable daemon yields an empty list rather than an error, because the
|
||||
* container name is a free-text field the user can always type by hand.
|
||||
*/
|
||||
app.get(
|
||||
'/api/docker-hosts/:hostId/containers',
|
||||
async (req, reply): Promise<ApiResponse<{ containers: DockerContainerInfo[] }>> => {
|
||||
// Enumerating every container on the engine is machine-level information (names,
|
||||
// images, uptime), so it follows the docker-host policy rather than the case one.
|
||||
const denied = adminOnly(req, reply);
|
||||
if (denied) return denied;
|
||||
const { hostId } = req.params as { hostId: string };
|
||||
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === hostId);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
|
||||
const containers = await listDockerContainers({
|
||||
engine: host.engine ?? 'docker',
|
||||
context: host.context,
|
||||
daemonHost: host.daemonHost,
|
||||
});
|
||||
return { success: true, data: { containers } };
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Browse a directory INSIDE a container, for the adoption form's
|
||||
* container-workdir picker. The host picker cannot answer this: for an adopted
|
||||
* container nothing is mounted at a matching host path, so the field would
|
||||
* otherwise be typed blind. Read-only — one `ls` through `docker exec`.
|
||||
*/
|
||||
app.post('/api/docker-cases/browse', async (req, reply): Promise<ApiResponse<DockerBrowseResult>> => {
|
||||
// Reads a directory listing inside an ARBITRARY named container, so it is gated with
|
||||
// the adopt flow it serves rather than with the (owner-scoped) case file routes.
|
||||
const denied = adminOnly(req, reply);
|
||||
if (denied) return denied;
|
||||
const body = parseBody(DockerBrowseSchema, req.body);
|
||||
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === body.hostId);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
|
||||
const result = await browseInContainer(
|
||||
{
|
||||
engine: host.engine ?? 'docker',
|
||||
context: host.context,
|
||||
daemonHost: host.daemonHost,
|
||||
containerName: body.container,
|
||||
},
|
||||
body.path || '/'
|
||||
);
|
||||
return { success: true, data: result };
|
||||
});
|
||||
|
||||
app.post('/api/docker-cases/adopt-preflight', async (req, reply): Promise<ApiResponse<AdoptedContainerProbe>> => {
|
||||
const body = parseBody(DockerAdoptPreflightSchema, req.body);
|
||||
const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR);
|
||||
// ⚠️ NOT plain `adminOnly`, unlike the two routes above: the run menu probes this for
|
||||
// every docker case to learn which CLIs the CONTAINER has, so an admin-only gate would
|
||||
// hide every agent mode from a non-admin's own docker case. A non-admin may therefore
|
||||
// probe a container ALREADY linked to a case they can access — never an arbitrary one,
|
||||
// which is the adopt-time question and stays admin-only with the rest of that flow.
|
||||
if (!isAdmin(req)) {
|
||||
const owns = dockerCases.some(
|
||||
(item) =>
|
||||
(item.container ?? dockerContainerName(item.name)) === body.container &&
|
||||
canAccessOwned(getAuthUser(req), item.owner)
|
||||
);
|
||||
if (!owns) {
|
||||
reply.code(403);
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
|
||||
}
|
||||
}
|
||||
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === body.hostId);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
|
||||
const probe = await probeAdoptableContainer(
|
||||
{
|
||||
engine: host.engine ?? 'docker',
|
||||
context: host.context,
|
||||
daemonHost: host.daemonHost,
|
||||
containerName: body.container,
|
||||
},
|
||||
dockerAdoptProbeModes(),
|
||||
body.containerWorkdir
|
||||
);
|
||||
return { success: true, data: probe };
|
||||
});
|
||||
|
||||
// One-click "Run in Docker": create a NORMAL case (folder in CASES_DIR, scaffolded)
|
||||
// AND link it to a hardened container with default settings, auto-provisioning a
|
||||
// shared `default` docker host so the user never touches host/image/network fields.
|
||||
@@ -899,6 +1094,17 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
|
||||
|
||||
const sessionDocker = toSessionDocker(host, dockerCase);
|
||||
// A full export `docker commit`s the container into an image. For an ADOPTED
|
||||
// container that means packaging someone else's container — with whatever
|
||||
// credentials its owner logged in with — into a bundle Codeman then hands out,
|
||||
// and it is the one export step that touches the container at all. The
|
||||
// workspace-only export is a plain host-directory tar and stays available.
|
||||
if (mode === 'full' && dockerCase.owned === false) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.FORBIDDEN,
|
||||
`Case "${name}" adopted an existing container. Codeman does not own it and will not commit it to an image — use a workspace-only export, or build the image yourself.`
|
||||
);
|
||||
}
|
||||
if (mode === 'full' && !sessionDocker.mountCredentials) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
@@ -1042,8 +1248,22 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
'/api/docker-cases/:name/recreate',
|
||||
async (req): Promise<ApiResponse<{ name: string; container: string }>> => {
|
||||
const { name } = req.params as { name: string };
|
||||
const dockerCase = (await readDockerCases(CODEMAN_CONFIG_DIR)).find((item) => item.name === name);
|
||||
// Ownership gate: recreate DESTROYS a container, so it must be scoped like
|
||||
// delete is (`canAccessOwned`). Without it any user could rebuild another
|
||||
// user's container by name.
|
||||
const dockerCase = (await readDockerCases(CODEMAN_CONFIG_DIR)).find(
|
||||
(item) => item.name === name && canAccessOwned(getAuthUser(req), item.owner)
|
||||
);
|
||||
if (!dockerCase) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker case not found');
|
||||
// An ADOPTED container is the user's own: there is nothing to recreate it
|
||||
// from (no create-config, no image gate) and destroying it is exactly what
|
||||
// adoption promises never to do.
|
||||
if (dockerCase.owned === false) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.FORBIDDEN,
|
||||
`Case "${name}" adopted an existing container. Codeman does not own its lifecycle and will not recreate it — rebuild it yourself, or unlink the case.`
|
||||
);
|
||||
}
|
||||
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
|
||||
const sessionDocker = toSessionDocker(host, dockerCase);
|
||||
@@ -1154,7 +1374,13 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
);
|
||||
// Best-effort `docker rm -f` the per-case container (case-delete is the
|
||||
// explicit teardown that removes it; the bind-mounted workspace survives).
|
||||
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
|
||||
// An ADOPTED container is skipped entirely: unlinking the case must leave
|
||||
// the user's own container running and untouched. The seed file is skipped
|
||||
// with it — adoption never wrote one.
|
||||
const host =
|
||||
dockerCase.owned === false
|
||||
? undefined
|
||||
: (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
|
||||
if (host) {
|
||||
const sessionDocker = toSessionDocker(host, dockerCase);
|
||||
try {
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
import { generateFirstPageThumbnail } from '../../document-thumbnailer.js';
|
||||
import { getOfficePreviewPdfPath, getPreviewPdfDownloadName } from '../../document-preview-cache.js';
|
||||
import { sanitizeAttachmentHistoryItem } from '../../session-attachment-history.js';
|
||||
import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from '../../config/attachment-guard.js';
|
||||
import { isBlockedAttachmentPath, isUnderTree, loadAttachmentGuardConfig } from '../../config/attachment-guard.js';
|
||||
import { isMultiUserMode, userSpacePath } from '../../config/multiuser.js';
|
||||
import {
|
||||
CASES_DIR,
|
||||
@@ -425,6 +425,40 @@ function getFilesystemPreviewKind(fileName: string): FilesystemPreviewKind | und
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocked trees, minus any tree that would swallow a configured picker root
|
||||
* whole.
|
||||
*
|
||||
* `/root` is a default blocked tree, and Codeman running as root (containers,
|
||||
* plenty of servers) makes `homedir()` exactly `/root` — so the picker's own
|
||||
* allowlisted Home root was blocked by the attachment guard, every other
|
||||
* candidate lives under it or does not exist, and the endpoint answered 403
|
||||
* "No filesystem browse roots are available" with no root the user could reach.
|
||||
*
|
||||
* Dropping the tree does NOT expose secrets: `isSensitivePath` independently
|
||||
* matches `.ssh/`, `.env`, `credentials*` and friends at any depth, and it is
|
||||
* what the directory probe below asks about. Trees with no configured root
|
||||
* beneath them (`/etc`) are untouched.
|
||||
*/
|
||||
function pickerBlockedTrees(blockedTrees: readonly string[], roots: readonly string[]): readonly string[] {
|
||||
if (roots.length === 0) return blockedTrees;
|
||||
return blockedTrees.filter((tree) => !roots.some((root) => isUnderTree(root, tree)));
|
||||
}
|
||||
|
||||
/** Resolve candidate roots to realpaths, dropping the ones that do not exist. */
|
||||
function resolveCandidateRootPaths(candidates: ReadonlyArray<{ path: string }>): string[] {
|
||||
const out: string[] = [];
|
||||
for (const candidate of candidates) {
|
||||
if (!isAbsolute(candidate.path)) continue;
|
||||
try {
|
||||
out.push(realpathSync(candidate.path));
|
||||
} catch {
|
||||
// Optional roots (for example /mnt/d on non-WSL hosts) are omitted.
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function isBlockedPickerPath(path: string, blockedTrees: readonly string[], directory = false): boolean {
|
||||
if (isBlockedAttachmentPath(path, blockedTrees)) return true;
|
||||
// The shared sensitive-path matcher describes file locations such as
|
||||
@@ -491,13 +525,14 @@ async function resolveFilesystemPickerRoots(
|
||||
}
|
||||
|
||||
const guard = await loadAttachmentGuardConfig();
|
||||
const trees = pickerBlockedTrees(guard.blockedTrees, resolveCandidateRootPaths(candidates));
|
||||
const roots: FilesystemBrowseRoot[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const candidate of candidates) {
|
||||
if (!isAbsolute(candidate.path)) continue;
|
||||
try {
|
||||
const resolved = realpathSync(candidate.path);
|
||||
if (seen.has(resolved) || isBlockedPickerPath(resolved, guard.blockedTrees, true)) continue;
|
||||
if (seen.has(resolved) || isBlockedPickerPath(resolved, trees, true)) continue;
|
||||
const stat = await fs.stat(resolved);
|
||||
if (!stat.isDirectory()) continue;
|
||||
seen.add(resolved);
|
||||
@@ -536,8 +571,21 @@ async function resolveFilesystemPickerPath(
|
||||
throwFilesystemPickerError(403, ApiErrorCode.INVALID_INPUT, 'No filesystem browse roots are available');
|
||||
}
|
||||
|
||||
// With no explicit path (the "Link Existing" case picker, which passes no
|
||||
// sessionId and an empty initialPath until the user has typed something),
|
||||
// land on the shared cases root rather than falling through to whichever
|
||||
// root happens to be first. `Codeman Cases` sits inside `Home` only on the
|
||||
// native default (~/codeman-cases); a Docker deployment binds them at
|
||||
// unrelated host paths (CODEMAN_APPDATA_PATH vs CODEMAN_CASES_PATH), so a
|
||||
// Home-first fallback opened the picker somewhere with no cases in sight —
|
||||
// and, worse, made an OLD case folder left behind by a since-changed
|
||||
// CODEMAN_CASES_PATH look like a normal thing to stumble across while
|
||||
// browsing for one to link.
|
||||
const fallbackRoot =
|
||||
roots.find((root) => root.label === 'Current Folder') ?? roots.find((root) => root.path === '/mnt/d') ?? roots[0];
|
||||
roots.find((root) => root.label === 'Current Folder') ??
|
||||
roots.find((root) => root.label === 'Codeman Cases') ??
|
||||
roots.find((root) => root.path === '/mnt/d') ??
|
||||
roots[0];
|
||||
const candidatePath = resolve(requestedPath ?? fallbackRoot.path);
|
||||
|
||||
let resolvedPath: string;
|
||||
@@ -556,7 +604,19 @@ async function resolveFilesystemPickerPath(
|
||||
}
|
||||
|
||||
const guard = await loadAttachmentGuardConfig();
|
||||
return { candidatePath, resolvedPath, roots, matchingRoot, blockedTrees: guard.blockedTrees };
|
||||
// Navigation must use the SAME narrowed list the roots were selected with.
|
||||
// Handing the raw trees down here would admit a root and then refuse every
|
||||
// path inside it, which reads as a picker that opens and then does nothing.
|
||||
return {
|
||||
candidatePath,
|
||||
resolvedPath,
|
||||
roots,
|
||||
matchingRoot,
|
||||
blockedTrees: pickerBlockedTrees(
|
||||
guard.blockedTrees,
|
||||
roots.map((root) => root.path)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function appendDownloadFlag(url: string): string {
|
||||
|
||||
@@ -126,10 +126,34 @@ export function registerHookEventRoutes(
|
||||
// Sync Claude's current conversation id. Interactive PTY mode never emits
|
||||
// `session_id` on stdout, so hooks are the only reliable way to learn that
|
||||
// the user ran `/clear` (which spins up a new conversation jsonl).
|
||||
let conversationChanged = false;
|
||||
if (data && typeof data.session_id === 'string' && data.session_id) {
|
||||
const session = ctx.sessions.get(sessionId);
|
||||
const prevClaudeSessionId = session?.claudeSessionId;
|
||||
session?.adoptClaudeSessionId(data.session_id);
|
||||
const prevChainLength = session?.claudeSessionChain.length ?? 0;
|
||||
// FIRST-HAND: this payload came from the CLI process itself and reached us
|
||||
// because the pane's own $CODEMAN_SESSION_ID addressed it. No cwd, no
|
||||
// timestamp, nothing a sibling pane on the same folder could win — so the
|
||||
// response viewer can stop guessing entirely (see
|
||||
// resolveActiveClaudeSessionIdFromHistory).
|
||||
session?.adoptClaudeSessionId(data.session_id, { firstHand: true });
|
||||
if (event === 'prompt_submitted') {
|
||||
// Repairs `lastSubmitAt` for a pane driven straight from tmux: it was
|
||||
// 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();
|
||||
}
|
||||
// Persist when the conversation actually moved: `/clear` emits no
|
||||
// completion event, so without this the successor id is lost on restart
|
||||
// and recovery falls back to the launch conversation.
|
||||
if (
|
||||
session &&
|
||||
(session.claudeSessionId !== prevClaudeSessionId || session.claudeSessionChain.length !== prevChainLength)
|
||||
) {
|
||||
conversationChanged = true;
|
||||
ctx.persistSessionState(session);
|
||||
}
|
||||
// Docker sessions: keep the case's resume seed following the LIVE
|
||||
// conversation (post-/clear id switches), so a container stop/reboot
|
||||
// relaunch resumes the right transcript.
|
||||
@@ -207,9 +231,13 @@ export function registerHookEventRoutes(
|
||||
...(approvalId && session?.mode !== 'deepseek' && { approvalId }),
|
||||
});
|
||||
|
||||
// Track in run summary
|
||||
// Track in run summary. `prompt_submitted` fires on EVERY prompt of every
|
||||
// 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).
|
||||
const summaryTracker = ctx.runSummaryTrackers.get(sessionId);
|
||||
if (summaryTracker) {
|
||||
if (summaryTracker && (event !== 'prompt_submitted' || conversationChanged)) {
|
||||
summaryTracker.recordHookEvent(event, safeData);
|
||||
}
|
||||
|
||||
|
||||
@@ -134,6 +134,7 @@ import {
|
||||
import {
|
||||
checkDockerAvailable,
|
||||
checkDockerConfigDrift,
|
||||
probeAdoptableContainer,
|
||||
checkDockerTmuxAvailable,
|
||||
ensureAgentBaseImage,
|
||||
DEFAULT_AGENT_IMAGE,
|
||||
@@ -1840,8 +1841,17 @@ export function registerSessionRoutes(
|
||||
session: Session,
|
||||
projectsDir: string
|
||||
): Promise<string | null> {
|
||||
// A pane whose conversation id came from its OWN hook needs no correlation:
|
||||
// $CODEMAN_SESSION_ID (the pane's env) -> data.session_id (the CLI's own
|
||||
// stdin JSON) is a first-hand binding that never looks at cwd, so it cannot
|
||||
// be stolen by a sibling pane, a closed tab, or a bare `claude` in a
|
||||
// terminal. Guessing can only be worse than the fact. This is also what
|
||||
// closes the hole below for a pane driven straight from tmux: it never
|
||||
// reaches `if (!submitAt)`.
|
||||
if (session.claudeSessionIdIsFirstHand) return null;
|
||||
|
||||
const submitAt = session.lastSubmitAt;
|
||||
if (!submitAt) return null; // never typed through Codeman — nothing to credit
|
||||
if (!submitAt) return null; // no anchor at all — nothing to credit
|
||||
const cached = claudeHistoryPinCache.get(session.id);
|
||||
if (cached && cached.submitAt === submitAt) return cached.claudeSessionId;
|
||||
|
||||
@@ -1906,9 +1916,13 @@ export function registerSessionRoutes(
|
||||
}
|
||||
|
||||
interface ClaudeResponseMessage {
|
||||
kind: 'prompt' | 'response';
|
||||
label: 'Prompt' | 'Response';
|
||||
role: 'user' | 'assistant';
|
||||
text: string;
|
||||
timestamp?: string;
|
||||
turn: number;
|
||||
queued?: boolean;
|
||||
}
|
||||
|
||||
interface ClaudeTranscriptEntry {
|
||||
@@ -1918,6 +1932,19 @@ export function registerSessionRoutes(
|
||||
isSidechain?: boolean;
|
||||
isCompactSummary?: boolean;
|
||||
message?: { content?: unknown };
|
||||
// A prompt typed while Claude is working is absorbed mid-turn and recorded
|
||||
// ONLY here — the CLI never re-emits it as a `user` row. Every field stays
|
||||
// optional and unvalidated: `queued_command` is not a documented CLI
|
||||
// contract, so a missing/renamed field must mean "skip", which is also what
|
||||
// the CLI's own non-human queue entries (commandMode 'task-notification',
|
||||
// no `origin` key) require. Shape observed on Claude Code 2.1.220-2.1.251.
|
||||
attachment?: {
|
||||
type?: string;
|
||||
prompt?: string;
|
||||
commandMode?: string;
|
||||
timestamp?: string;
|
||||
origin?: { kind?: string };
|
||||
};
|
||||
}
|
||||
|
||||
function extractClaudeText(content: unknown, separator: string): string {
|
||||
@@ -1943,10 +1970,17 @@ export function registerSessionRoutes(
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude writes one logical turn as many JSONL rows: text, thinking and tool
|
||||
* blocks share message ids, while tool results are represented as user rows.
|
||||
* Build viewer cards from real user boundaries instead of treating every row
|
||||
* as a separate chat message.
|
||||
* Claude writes an append-only event log: tool results arrive as user rows,
|
||||
* thinking/tool_use rows carry no text, and a prompt typed while the agent is
|
||||
* working is only ever recorded as an `attachment/queued_command` row. But one
|
||||
* assistant row IS one whole model message: measured across ~/.claude/projects
|
||||
* (CLI 2.1.220-2.1.251) no assistant row carries more than one content block
|
||||
* and no message id carries more than one text block, so there is nothing to
|
||||
* reassemble. Emit one card per row and group them with `turn` instead of
|
||||
* concatenating a human turn's replies into a single card (#169), which fused
|
||||
* up to 74 distinct model messages into one card. Splitting is safe for
|
||||
* markdown: no adjacent pair of assistant text rows in the corpus continues a
|
||||
* table, a list, or an open code fence.
|
||||
*/
|
||||
function parseClaudeResponseTranscript(
|
||||
content: string,
|
||||
@@ -1955,8 +1989,23 @@ export function registerSessionRoutes(
|
||||
let lastText = '';
|
||||
let lastTimestamp = '';
|
||||
const messages: ClaudeResponseMessage[] = [];
|
||||
let currentUserFragments = new Set<string>();
|
||||
let currentAssistantFragments = new Set<string>();
|
||||
// #169's replay guards, kept: they now SKIP a duplicated row instead of
|
||||
// concatenating it into the previous card.
|
||||
const currentUserFragments = new Set<string>();
|
||||
const currentAssistantFragments = new Set<string>();
|
||||
// Turn 0 is reserved for anything emitted before the first human prompt.
|
||||
let turn = 0;
|
||||
|
||||
const pushUserMessage = (text: string, timestamp: string | undefined, queued: boolean): void => {
|
||||
// A run of consecutive human inputs (a mid-turn queued burst) is ONE turn,
|
||||
// so the viewer renders it under one badge instead of one badge per line.
|
||||
if (messages.at(-1)?.role !== 'user') turn += 1;
|
||||
const message: ClaudeResponseMessage = { kind: 'prompt', label: 'Prompt', role: 'user', text, timestamp, turn };
|
||||
if (queued) message.queued = true;
|
||||
messages.push(message);
|
||||
currentUserFragments.add(text);
|
||||
currentAssistantFragments.clear();
|
||||
};
|
||||
|
||||
for (const line of content.split('\n')) {
|
||||
if (!line) continue;
|
||||
@@ -1970,25 +2019,37 @@ export function registerSessionRoutes(
|
||||
// rows include repeated image dimensions and other UI-generated context.
|
||||
if (entry.isSidechain) continue;
|
||||
|
||||
// A prompt typed while Claude is working is absorbed mid-turn and lives
|
||||
// ONLY in an attachment row, so it was lost outright. `origin.kind` and
|
||||
// `commandMode` separate the human's queue entries from the CLI's own:
|
||||
// measured over 57 real transcripts on 2026-09-01, 322 queued_command rows
|
||||
// split 163 `prompt`/`human` and 159 `task-notification`, and not one of
|
||||
// those 159 carries an `origin` key. The 163 human rows become 162 user
|
||||
// cards here — one is a verbatim repeat inside a still-unanswered user run
|
||||
// and is collapsed by the dedup guard below — out of 353 user cards total.
|
||||
if (entry.type === 'attachment') {
|
||||
if (!full) continue;
|
||||
const queued = entry.attachment;
|
||||
if (!queued || queued.type !== 'queued_command') continue;
|
||||
if (queued.origin?.kind !== 'human' || queued.commandMode !== 'prompt') continue;
|
||||
const text = typeof queued.prompt === 'string' ? queued.prompt.trim() : '';
|
||||
if (!text || isClaudeSyntheticUserMessage(entry, text)) continue;
|
||||
if (currentUserFragments.has(text)) continue;
|
||||
pushUserMessage(text, queued.timestamp || entry.timestamp, true);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.type === 'user') {
|
||||
const text = extractClaudeText(entry.message?.content, '\n').trim();
|
||||
// A tool_result block has no text block and naturally drops out here.
|
||||
if (!text || isClaudeSyntheticUserMessage(entry, text)) continue;
|
||||
if (!full) continue;
|
||||
|
||||
const previous = messages.at(-1);
|
||||
if (previous?.role === 'user') {
|
||||
// Claude can replay the initial user row while restoring a transcript.
|
||||
// Only collapse duplicates within the same unanswered user turn; the
|
||||
// same prompt after an assistant response remains a legitimate turn.
|
||||
if (currentUserFragments.has(text)) continue;
|
||||
previous.text += `\n\n${text}`;
|
||||
currentUserFragments.add(text);
|
||||
} else {
|
||||
messages.push({ role: 'user', text, timestamp: entry.timestamp });
|
||||
currentUserFragments = new Set([text]);
|
||||
}
|
||||
currentAssistantFragments.clear();
|
||||
// Claude replays the initial user row while restoring a transcript, and
|
||||
// a CLI that also wrote an absorbed prompt as a user row would double it.
|
||||
// Both collapse here. The same prompt sent again AFTER a reply is a
|
||||
// legitimate second turn, because that reply cleared the set.
|
||||
if (currentUserFragments.has(text)) continue;
|
||||
pushUserMessage(text, entry.timestamp, false);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1999,18 +2060,10 @@ export function registerSessionRoutes(
|
||||
lastTimestamp = entry.timestamp || '';
|
||||
if (!full) continue;
|
||||
|
||||
const previous = messages.at(-1);
|
||||
if (previous?.role === 'assistant') {
|
||||
// Replayed snapshots sometimes repeat an identical text block. Distinct
|
||||
// progress/final blocks are kept, but remain inside one Claude card.
|
||||
if (currentAssistantFragments.has(text)) continue;
|
||||
previous.text += `\n\n${text}`;
|
||||
previous.timestamp = entry.timestamp || previous.timestamp;
|
||||
currentAssistantFragments.add(text);
|
||||
} else {
|
||||
messages.push({ role: 'assistant', text, timestamp: entry.timestamp });
|
||||
currentAssistantFragments = new Set([text]);
|
||||
}
|
||||
// Replayed snapshots repeat an identical text block inside one turn.
|
||||
if (currentAssistantFragments.has(text)) continue;
|
||||
messages.push({ kind: 'response', label: 'Response', role: 'assistant', text, timestamp: entry.timestamp, turn });
|
||||
currentAssistantFragments.add(text);
|
||||
currentUserFragments.clear();
|
||||
}
|
||||
|
||||
@@ -3020,25 +3073,48 @@ export function registerSessionRoutes(
|
||||
);
|
||||
}
|
||||
const sessionDocker = toSessionDocker(host, dockerCase);
|
||||
// Ensure the base image exists, auto-building the default image on first use so
|
||||
// it is never a blocker. Dedup'd with any build kicked off at case-create, so
|
||||
// this awaits the SAME in-flight build rather than starting a second one.
|
||||
const ensured = await ensureAgentBaseImage(sessionDocker, sessionDocker.image, {
|
||||
onProgress: (line) => ctx.broadcast(SseEvent.DockerImageBuildProgress, { name: dockerCase.name, line }),
|
||||
});
|
||||
if (!ensured.ok) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, ensured.error || 'base image not available');
|
||||
}
|
||||
if (ensured.built) {
|
||||
ctx.broadcast(SseEvent.DockerImageBuildComplete, { name: dockerCase.name, image: sessionDocker.image });
|
||||
}
|
||||
// tmux is a hard prerequisite (the in-container tmux makes reconnect durable).
|
||||
// Skip the extra container-run probe for our OWN default image (the baked
|
||||
// Dockerfile always contains tmux); still verify a custom image.
|
||||
if (sessionDocker.image !== DEFAULT_AGENT_IMAGE) {
|
||||
const tmuxCheck = await checkDockerTmuxAvailable(sessionDocker);
|
||||
if (!tmuxCheck.ok) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, tmuxCheck.error || 'base image is missing tmux');
|
||||
// An ADOPTED container skips every image-side gate: we never run `docker
|
||||
// create`, so the image is the user's business, and `ensureAgentBaseImage`
|
||||
// would build/require an image that has nothing to do with their container.
|
||||
// The prerequisite that DOES still hold is tmux inside it, so probe the live
|
||||
// container (not the image) and refuse before launch rather than dead-paning.
|
||||
if (sessionDocker.owned === false) {
|
||||
const probe = await probeAdoptableContainer(sessionDocker, [mode]);
|
||||
if (!probe.ok) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, probe.error || 'container is not usable');
|
||||
}
|
||||
// The probe already exec'd into the container; carry its facts onto the
|
||||
// live session so the launch chain does not have to re-ask.
|
||||
sessionDocker.runsAsRoot = probe.runsAsRoot;
|
||||
// No `mode !== 'shell'` arm: a mode with no binary of its own is reported
|
||||
// available by the probe unconditionally, so this reads the same answer for it.
|
||||
if (!probe.availableModes?.includes(mode)) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
`"${mode}" is not installed in container "${sessionDocker.containerName}". Adoption never modifies the container — install it inside, or pick another mode.`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Ensure the base image exists, auto-building the default image on first use so
|
||||
// it is never a blocker. Dedup'd with any build kicked off at case-create, so
|
||||
// this awaits the SAME in-flight build rather than starting a second one.
|
||||
const ensured = await ensureAgentBaseImage(sessionDocker, sessionDocker.image, {
|
||||
onProgress: (line) => ctx.broadcast(SseEvent.DockerImageBuildProgress, { name: dockerCase.name, line }),
|
||||
});
|
||||
if (!ensured.ok) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, ensured.error || 'base image not available');
|
||||
}
|
||||
if (ensured.built) {
|
||||
ctx.broadcast(SseEvent.DockerImageBuildComplete, { name: dockerCase.name, image: sessionDocker.image });
|
||||
}
|
||||
// tmux is a hard prerequisite (the in-container tmux makes reconnect durable).
|
||||
// Skip the extra container-run probe for our OWN default image (the baked
|
||||
// Dockerfile always contains tmux); still verify a custom image.
|
||||
if (sessionDocker.image !== DEFAULT_AGENT_IMAGE) {
|
||||
const tmuxCheck = await checkDockerTmuxAvailable(sessionDocker);
|
||||
if (!tmuxCheck.ok) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, tmuxCheck.error || 'base image is missing tmux');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -102,14 +102,14 @@ function withWebviews<T>(fn: (list: Webview[]) => Promise<T> | T): Promise<T> {
|
||||
return next;
|
||||
}
|
||||
|
||||
export function registerWebviewRoutes(app: FastifyInstance, ctx: EventPort & TabLayoutPort): void {
|
||||
registerCrudRoutes(app, ctx);
|
||||
registerProxyRoutes(app);
|
||||
export function registerWebviewRoutes(app: FastifyInstance, ctx: EventPort & TabLayoutPort, basePath = ''): void {
|
||||
registerCrudRoutes(app, ctx, basePath);
|
||||
registerProxyRoutes(app, basePath);
|
||||
}
|
||||
|
||||
// ───────────────────────────── CRUD ─────────────────────────────
|
||||
|
||||
function registerCrudRoutes(app: FastifyInstance, ctx: EventPort & TabLayoutPort): void {
|
||||
function registerCrudRoutes(app: FastifyInstance, ctx: EventPort & TabLayoutPort, basePath: string): void {
|
||||
app.get('/api/webviews', async (req) => {
|
||||
const user = getAuthUser(req);
|
||||
const all = await readWebviews(configDir());
|
||||
@@ -279,7 +279,7 @@ function registerCrudRoutes(app: FastifyInstance, ctx: EventPort & TabLayoutPort
|
||||
}
|
||||
|
||||
const capability = webviewCapabilities.mint(webview.id, webview.owner);
|
||||
const data: WebviewOpenData = { webview, embedUrl: proxyPrefixFor(capability) };
|
||||
const data: WebviewOpenData = { webview, embedUrl: proxyPrefixFor(capability, basePath) };
|
||||
return { success: true, data };
|
||||
});
|
||||
}
|
||||
@@ -347,7 +347,7 @@ async function probeUrl(url: string): Promise<WebviewProbe> {
|
||||
|
||||
// ───────────────────────────── Proxy ─────────────────────────────
|
||||
|
||||
function registerProxyRoutes(app: FastifyInstance): void {
|
||||
function registerProxyRoutes(app: FastifyInstance, basePath: string): void {
|
||||
app.register(async (scope) => {
|
||||
// Encapsulated to this plugin only. The proxy must relay request bodies
|
||||
// BYTE-FOR-BYTE, so every parser is replaced with a pass-through that hands
|
||||
@@ -358,11 +358,11 @@ function registerProxyRoutes(app: FastifyInstance): void {
|
||||
|
||||
// A single GET route serving both roles: `handler` for normal requests,
|
||||
// `wsHandler` for upgrades. Registering them as two routes on one URL would
|
||||
// collide.
|
||||
// collide. (The WS leg produces no browser-facing URLs, so it needs no base.)
|
||||
scope.route<{ Params: ProxyParams }>({
|
||||
method: 'GET',
|
||||
url: `${WEBVIEW_PROXY_PREFIX}/:cap/*`,
|
||||
handler: proxyHttp,
|
||||
handler: (req, reply) => proxyHttp(req, reply, basePath),
|
||||
wsHandler: proxyWebSocket,
|
||||
});
|
||||
|
||||
@@ -371,14 +371,14 @@ function registerProxyRoutes(app: FastifyInstance): void {
|
||||
scope.route<{ Params: ProxyParams }>({
|
||||
method: ['POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
url: `${WEBVIEW_PROXY_PREFIX}/:cap/*`,
|
||||
handler: proxyHttp,
|
||||
handler: (req, reply) => proxyHttp(req, reply, basePath),
|
||||
});
|
||||
|
||||
// `/webview/<cap>` with no trailing slash: redirect rather than serve, so the
|
||||
// browser's notion of the base path ends in `/` and relative URLs in the
|
||||
// dashboard's HTML resolve inside the prefix instead of one level above it.
|
||||
scope.get<{ Params: { cap: string } }>(`${WEBVIEW_PROXY_PREFIX}/:cap`, (req, reply) => {
|
||||
return reply.redirect(proxyPrefixFor(req.params.cap), 302);
|
||||
return reply.redirect(proxyPrefixFor(req.params.cap, basePath), 302);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -406,8 +406,12 @@ async function lookupCapability(capability: string): Promise<Webview | null> {
|
||||
* streamed asset comes back zero-length. Returning the reply is what tells Fastify
|
||||
* the response is already owned by this handler.
|
||||
*/
|
||||
function proxyHttp(req: FastifyRequest<{ Params: ProxyParams }>, reply: FastifyReply): Promise<FastifyReply> {
|
||||
return proxyRequest(req, reply, req.params.cap, req.params['*'] ?? '');
|
||||
function proxyHttp(
|
||||
req: FastifyRequest<{ Params: ProxyParams }>,
|
||||
reply: FastifyReply,
|
||||
basePath: string
|
||||
): Promise<FastifyReply> {
|
||||
return proxyRequest(req, reply, req.params.cap, req.params['*'] ?? '', basePath);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -419,7 +423,8 @@ async function proxyRequest(
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
cap: string,
|
||||
wildcard: string
|
||||
wildcard: string,
|
||||
basePath = ''
|
||||
): Promise<FastifyReply> {
|
||||
const webview = await lookupCapability(cap);
|
||||
if (!webview) {
|
||||
@@ -454,7 +459,8 @@ async function proxyRequest(
|
||||
const headers = buildUpstreamRequestHeaders(req.headers, upstream, {
|
||||
forwardCookies: webview.trusted,
|
||||
sessionCookieName: AUTH_COOKIE_NAME,
|
||||
refererPath: typeof req.headers.referer === 'string' ? stripProxyPrefix(req.headers.referer, cap) : undefined,
|
||||
refererPath:
|
||||
typeof req.headers.referer === 'string' ? stripProxyPrefix(req.headers.referer, cap, basePath) : undefined,
|
||||
});
|
||||
|
||||
// #237: the timeout bounds TIME-TO-HEADERS only. A plain AbortSignal.timeout on
|
||||
@@ -546,7 +552,8 @@ async function proxyRequest(
|
||||
response.headers.getSetCookie(),
|
||||
cap,
|
||||
upstream,
|
||||
secureContext
|
||||
secureContext,
|
||||
basePath
|
||||
);
|
||||
|
||||
reply.code(response.status);
|
||||
@@ -575,7 +582,7 @@ async function proxyRequest(
|
||||
// Buffer only HTML, only under the cap: `<base>` injection needs the whole
|
||||
// document, and buffering an unbounded upstream body is a memory hazard.
|
||||
const html = await response.text();
|
||||
return reply.send(html.length <= MAX_WEBVIEW_HTML_REWRITE_BYTES ? rewriteHtml(html, cap) : html);
|
||||
return reply.send(html.length <= MAX_WEBVIEW_HTML_REWRITE_BYTES ? rewriteHtml(html, cap, basePath) : html);
|
||||
}
|
||||
|
||||
return reply.send(Readable.fromWeb(response.body as Parameters<typeof Readable.fromWeb>[0]));
|
||||
@@ -596,25 +603,34 @@ async function proxyRequest(
|
||||
*
|
||||
* @returns true when the request was handled (caller must not also reply).
|
||||
*/
|
||||
export async function tryWebviewRefererFallback(req: FastifyRequest, reply: FastifyReply): Promise<boolean> {
|
||||
export async function tryWebviewRefererFallback(
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
basePath = ''
|
||||
): Promise<boolean> {
|
||||
// Safe methods only. A write arriving here has already lost its raw body to the
|
||||
// root instance's JSON parser, so it could not be relayed faithfully anyway.
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') return false;
|
||||
|
||||
const capability = capabilityFromReferer(typeof req.headers.referer === 'string' ? req.headers.referer : undefined);
|
||||
const capability = capabilityFromReferer(
|
||||
typeof req.headers.referer === 'string' ? req.headers.referer : undefined,
|
||||
basePath
|
||||
);
|
||||
if (!capability) return false;
|
||||
if (!webviewCapabilities.resolve(capability)) return false;
|
||||
|
||||
// req.url is already base-stripped by the server's rewriteUrl, so this is the
|
||||
// internal path the upstream resolver expects.
|
||||
const path = req.url.split('?')[0].replace(/^\//, '');
|
||||
await proxyRequest(req, reply, capability, path);
|
||||
await proxyRequest(req, reply, capability, path, basePath);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Turn a proxy-side Referer back into the upstream path it corresponds to. */
|
||||
function stripProxyPrefix(referer: string, capability: string): string | undefined {
|
||||
function stripProxyPrefix(referer: string, capability: string, basePath = ''): string | undefined {
|
||||
try {
|
||||
const url = new URL(referer);
|
||||
const prefix = proxyPrefixFor(capability);
|
||||
const prefix = proxyPrefixFor(capability, basePath);
|
||||
if (!url.pathname.startsWith(prefix)) return undefined;
|
||||
return `/${url.pathname.slice(prefix.length)}${url.search}`;
|
||||
} catch {
|
||||
|
||||
Reference in New Issue
Block a user