fix: refuse to git fetch while the Docker case list cannot be read (release review)

readDockerCases() reads an unreadable or half-written docker-cases.json as [], so a fetch (which runs
repository hooks, and runs unattended on the interval) could run inside a Docker workspace. The fetch
paths now read it through a strict reader: the Fetch button answers a failed run (state 'error') and the
scheduler skips the tick, undoes its picks and logs once. The status and diff routes are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-27 02:58:37 +01:00
1 parent b89f15c12f
commit 1752d7ee2c
8 files changed
+221 -18

No files matched your search

+29 -5
View File
@@ -17,7 +17,7 @@ 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 { readDockerCases } from '../../docker-hosts.js';
import { readDockerCasesOrThrow } from '../../docker-hosts.js';
import { getDataDir } from '../../config/instance.js';
import { findSessionOrFail, persistAndBroadcastSession } from '../route-helpers.js';
import {
@@ -38,9 +38,18 @@ 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 readDockerCases(getDataDir()).catch(() => [])).map((c) => c.hostWorkspacePath).filter(Boolean);
(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):
@@ -78,7 +87,7 @@ export function registerGitStatusRoutes(
git,
fresh: fresh === '1',
...limits,
dockerWorkspaces: await dockerWorkspaces(),
dockerWorkspaces: await dockerWorkspacesOrNone(dockerWorkspaces),
}),
};
});
@@ -102,7 +111,7 @@ export function registerGitStatusRoutes(
? await findWorkspaceRepo(session.workingDir, repo, {
git,
...limits,
dockerWorkspaces: await dockerWorkspaces(),
dockerWorkspaces: await dockerWorkspacesOrNone(dockerWorkspaces),
})
: null;
const status = repoRoot
@@ -150,9 +159,24 @@ export function registerGitStatusRoutes(
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: await dockerWorkspaces() },
{ git, ...limits, dockerWorkspaces: dockerRoots },
'click'
);
// The response answers the click, and the run is also the session's current truth: what the panel's
+2 -1
View File
@@ -406,7 +406,8 @@ export class WebServer extends EventEmitter {
sessionIds: () => this.sessions.keys(),
getSession: (id) => this.sessions.get(id),
readSettings: readGitFetchSettings,
// The same Docker list the panel's routes use: the rule has to be one rule.
// The same Docker list the panel's Fetch button uses: the rule has to be one rule. It rejects on a list
// that cannot be read, and the tick then fetches nothing (fail closed).
dockerWorkspaces: defaultDockerWorkspaces,
// Bounded probe, DEFAULT options (rule #516): a tick is a bulk scan, so it keeps the stall cap and must
// never be handed `pastCap`. A folder on a mount that stopped answering is skipped, not fetched.