diff --git a/src/types/api.ts b/src/types/api.ts index b63268af..a57b2795 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -167,6 +167,13 @@ export interface CaseInfo { image?: string; path: string; network?: string; + /** + * CLIs available INSIDE the container. A container case runs its agents in + * the container, so HOST CLI availability says nothing about what it can + * run. Absent = unknown (an owned container runs our base image, which ships + * every CLI), which the UI reads as "do not gate". + */ + availableModes?: string[]; }; } diff --git a/src/types/session.ts b/src/types/session.ts index 7187dae4..21e117b9 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -261,6 +261,15 @@ export interface DockerCase { * all created by us). */ owned?: boolean; + /** + * CLIs found INSIDE the container by the adoption preflight. A container case + * runs its agents in the container, so host CLI availability says nothing about + * what this case can run — the base image ships every CLI, and an adopted + * container ships whatever its owner installed. Absent = unknown (owned cases, + * or a case linked before this field existed), which callers read as "do not + * gate". + */ + availableModes?: SessionMode[]; /** Last captured Claude conversation id, replayed via --resume on a fresh launch. */ lastClaudeSessionId?: string; } diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 2377c37d..b43e4288 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -474,9 +474,23 @@ Object.assign(CodemanApp.prototype, { * run modes like the rest, and neither `agy` nor `pi` is likely to be installed. */ _refreshRunModeAvailability(menu) { + // A DOCKER case runs its agents INSIDE the container, so host CLI + // availability answers the wrong question: the host may have no claude at + // all while the container ships one, and gating on the host hides a mode + // that would have worked. Adoption records what the container really has + // (`availableModes`); an owned container runs our base image, which ships + // every CLI, so an absent list means "do not gate" rather than "nothing". + // Same source every run* path reads the selected case from. + const caseName = document.getElementById('quickStartCase')?.value; + const activeCase = caseName ? (this.cases || []).find((c) => c.name === caseName) : null; + const containerModes = activeCase?.location === 'docker' ? activeCase.docker?.availableModes : null; for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek']) { const btn = menu.querySelector(`.run-mode-option[data-mode="${mode}"]`); - if (btn) btn.style.display = this.isCliAvailable(mode) ? 'flex' : 'none'; + if (!btn) continue; + let available; + if (activeCase?.location === 'docker') available = containerModes ? containerModes.includes(mode) : true; + else available = this.isCliAvailable(mode); + btn.style.display = available ? 'flex' : 'none'; } // DeepSeek is the one mode whose availability has two halves: `dsh` can be // perfectly installed while no pane-capable profile exists, because DeepSeek diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts index fc2079d3..11ac46e7 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -297,6 +297,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config image: host.image, path: dockerCase.hostWorkspacePath, network: host.network ?? 'bridge', + ...(dockerCase.availableModes ? { availableModes: dockerCase.availableModes } : {}), }, }; const existingIndex = cases.findIndex((item) => item.name === dockerCase.name); @@ -851,15 +852,19 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config return createErrorResponse(ApiErrorCode.OPERATION_FAILED, probe.error || 'container is not adoptable'); } - await writeDockerCases(CODEMAN_CONFIG_DIR, [...dockerCases, dockerCase]); + // 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: dockerCase.name, - path: dockerCase.hostWorkspacePath, + name: adoptedCase.name, + path: adoptedCase.hostWorkspacePath, type: 'docker', }); return { success: true, - data: { case: dockerCase, image: probe.image, availableModes: probe.availableModes }, + data: { case: adoptedCase, image: probe.image, availableModes: probe.availableModes }, }; } ); diff --git a/test/docker-adopted-container.test.ts b/test/docker-adopted-container.test.ts index cd15a443..1c14463c 100644 --- a/test/docker-adopted-container.test.ts +++ b/test/docker-adopted-container.test.ts @@ -194,6 +194,34 @@ describe('adopted container: the Add Case panel id contract', () => { }); }); +describe('adopted container: run modes come from the CONTAINER, not the host', () => { + const ui = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf8'); + /** Slice the method BODY. Anchored on the definition, not a call site: the + * menu opener calls _loadRunModeHistory() ABOVE this definition, so slicing + * between call sites silently yields an empty string and passes nothing. */ + const refreshFn = (src) => { + const start = src.indexOf('_refreshRunModeAvailability(menu) {'); + expect(start).toBeGreaterThan(-1); + return src.slice(start, start + 1600); + }; + + it('gates a docker case on availableModes instead of host CLI probes', () => { + // The sandbox host had codex but no claude while the adopted container had + // claude and no codex; gating on the host hid the only mode that worked. + const fn = refreshFn(ui); + expect(fn).toContain("location === 'docker'"); + expect(fn).toContain('availableModes'); + // Non-docker cases must keep the original host probe (#201). + expect(fn).toContain('this.isCliAvailable(mode)'); + }); + + it('leaves an owned container ungated when nothing was probed', () => { + // Our base image ships every CLI, so an absent list means "unknown", and + // treating unknown as "nothing available" would empty the menu. + expect(refreshFn(ui)).toMatch(/containerModes \?[^:]*:\s*true/); + }); +}); + describe('adopted container: drift is not evaluated', () => { it('reports no drift rather than demanding a recreate we may not perform', async () => { // An adopted container carries no codeman.confighash label, so a real