diff --git a/src/docker-hosts.ts b/src/docker-hosts.ts index b49502a5..c039815d 100644 --- a/src/docker-hosts.ts +++ b/src/docker-hosts.ts @@ -1044,6 +1044,8 @@ export interface AdoptedContainerProbe { tmuxPath?: string; /** Modes whose CLI resolved inside the container (`command -v `). */ availableModes?: SessionMode[]; + /** Whether the requested working directory exists INSIDE the container. */ + workdirExists?: boolean; error?: string; } @@ -1059,10 +1061,18 @@ export interface AdoptedContainerProbe { */ export async function probeAdoptableContainer( docker: Pick, - modes: SessionMode[] = [] + modes: SessionMode[] = [], + containerWorkdir?: string ): Promise { if (IS_TEST_MODE) { - return { ok: true, exists: true, running: true, tmuxPath: '/usr/bin/tmux', availableModes: modes }; + return { + ok: true, + exists: true, + running: true, + tmuxPath: '/usr/bin/tmux', + availableModes: modes, + workdirExists: true, + }; } const argv = dockerEngineArgv(docker); let running = false; @@ -1096,7 +1106,19 @@ export async function probeAdoptableContainer( // One exec resolves tmux plus every requested CLI, so adoption costs a single // round trip. Binaries are fixed mode names, never user input. const probes = ['tmux', ...modes.filter((m) => m !== 'shell')]; - const script = probes.map((bin) => `command -v ${bin} >/dev/null 2>&1 && echo ${bin}`).join('; '); + // `; exit 0` is load-bearing: the script's status is its LAST command's, so a + // missing final CLI made the whole `sh -lc` exit 1 and the probe reported + // "could not exec into the container" for a container that was perfectly fine. + // Absence of a CLI is data here, not failure — only a real exec error is. + const steps = probes.map((bin) => `command -v ${bin} >/dev/null 2>&1 && echo ${bin}`); + // The workdir is checked INSIDE the container, and that is a fact independent + // of hostWorkspacePath: an owned container gets the host dir bind-mounted at the + // same absolute path at create time, but adoption mounts nothing, so the two + // paths only coincide if the user mounted it there themselves. `docker exec + // --workdir ` fails with an OCI chdir error the pane surfaces as a bare + // "execvp failed", so it is resolved here into an actionable message. + if (containerWorkdir) steps.push(`[ -d ${shellescape(containerWorkdir)} ] && echo __workdir__`); + const script = `${steps.join('; ')}; exit 0`; try { const { stdout } = await execFileAsync( argv[0], @@ -1118,6 +1140,17 @@ export async function probeAdoptableContainer( error: `container "${docker.containerName}" has no tmux (required for durable sessions; install it inside the container)`, }; } + const workdirExists = containerWorkdir ? found.has('__workdir__') : undefined; + if (containerWorkdir && !workdirExists) { + return { + ok: false, + exists: true, + running: true, + image, + workdirExists: false, + error: `"${containerWorkdir}" does not exist inside container "${docker.containerName}". Adoption mounts nothing, so the container workdir must already exist there — set it to a path inside the container (it need not match the host workspace path).`, + }; + } return { ok: true, exists: true, @@ -1125,6 +1158,7 @@ export async function probeAdoptableContainer( image, tmuxPath: 'tmux', availableModes: modes.filter((m) => m === 'shell' || found.has(m)), + workdirExists, }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); diff --git a/src/web/public/index.html b/src/web/public/index.html index bb5757b0..4d54096a 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -2840,6 +2840,11 @@ Must be running already. +
+ + + A path that already exists inside the container. Adoption mounts nothing, so this need not match the host workspace path — leave blank to reuse it only if you mounted it there yourself. +
diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 24c1f49b..9da4990d 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -2922,6 +2922,7 @@ Object.assign(CodemanApp.prototype, { async _dockerAdoptPreflight() { const statusEl = document.getElementById('dockerLinkStatus'); const container = document.getElementById('dockerContainerName')?.value.trim(); + const containerWorkdir = document.getElementById('dockerAdoptWorkdir')?.value.trim(); const hostId = document.getElementById('dockerHostId').value.trim() || 'local'; if (!container) { if (statusEl) statusEl.textContent = 'Enter a container name first.'; @@ -2933,7 +2934,7 @@ Object.assign(CodemanApp.prototype, { const probe = await this._apiJson('/api/docker-cases/adopt-preflight', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ hostId, container }), + body: JSON.stringify({ hostId, container, ...(containerWorkdir ? { containerWorkdir } : {}) }), }); if (!statusEl) return; if (!probe) { @@ -2956,6 +2957,7 @@ Object.assign(CodemanApp.prototype, { const hostId = document.getElementById('dockerHostId').value.trim() || 'local'; const adopting = !!document.getElementById('dockerAdoptExisting')?.checked; const container = document.getElementById('dockerContainerName')?.value.trim() || ''; + const adoptWorkdir = document.getElementById('dockerAdoptWorkdir')?.value.trim() || ''; const image = document.getElementById('dockerImage').value.trim() || 'codeman/agent:base'; const network = document.getElementById('dockerNetwork').value; const memory = document.getElementById('dockerMemory').value.trim(); @@ -3023,7 +3025,11 @@ Object.assign(CodemanApp.prototype, { const caseRes = await fetch(adopting ? '/api/cases/docker-adopt' : '/api/cases/docker-link', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(adopting ? { name, hostId, hostWorkspacePath, container } : { name, hostId, hostWorkspacePath }), + body: JSON.stringify( + adopting + ? { name, hostId, hostWorkspacePath, container, ...(adoptWorkdir ? { containerWorkdir: adoptWorkdir } : {}) } + : { name, hostId, hostWorkspacePath } + ), }); const caseData = await caseRes.json(); if (caseData.success) { diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts index 49c94e2e..6e526995 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -837,7 +837,15 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config availability.error || 'docker daemon is not available' ); } - const probe = await probeAdoptableContainer(toSessionDocker(host, dockerCase), [...DOCKER_ADOPT_PROBE_MODES]); + // 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, + [...DOCKER_ADOPT_PROBE_MODES], + adoptDocker.containerWorkdir + ); if (!probe.ok) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, probe.error || 'container is not adoptable'); } @@ -871,7 +879,8 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config daemonHost: host.daemonHost, containerName: body.container, }, - [...DOCKER_ADOPT_PROBE_MODES] + [...DOCKER_ADOPT_PROBE_MODES], + body.containerWorkdir ); return { success: true, data: probe }; }); diff --git a/src/web/schemas.ts b/src/web/schemas.ts index c5860388..65ef1395 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -834,6 +834,14 @@ export const DockerAdoptPreflightSchema = z.object({ .min(2) .max(128) .regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]+$/, 'Invalid container name'), + /** Optional: also verify this path exists INSIDE the container. */ + containerWorkdir: z + .string() + .min(1) + .max(2000) + .regex(/^\//, 'Container workdir must be absolute') + .regex(NO_SHELL_META, 'Invalid characters in container workdir') + .optional(), }); export const DockerExportSchema = z.object({