From 44bee19ede47077579e2adde06f73be08181fbf2 Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Mon, 11 May 2026 14:07:20 +0200 Subject: [PATCH 1/2] chore(cloud-agent-next): add DIND small sandbox with hardened devcontainer lifecycle --- services/cloud-agent-next/Dockerfile.dind | 141 +++++ services/cloud-agent-next/package.json | 6 +- .../scripts/dev-with-docker-proxy.sh | 39 ++ .../scripts/docker-privileged-proxy.mjs | 117 ++++ .../src/execution/orchestrator.ts | 23 + .../cloud-agent-next/src/execution/types.ts | 6 + .../src/kilo/devcontainer.test.ts | 251 ++++++++ .../cloud-agent-next/src/kilo/devcontainer.ts | 536 ++++++++++++++++++ .../src/kilo/sandbox-runtime.test.ts | 81 +++ .../src/kilo/sandbox-runtime.ts | 72 +++ .../cloud-agent-next/src/kilo/utils.test.ts | 20 +- services/cloud-agent-next/src/kilo/utils.ts | 10 + .../src/kilo/wrapper-client.test.ts | 38 +- .../src/kilo/wrapper-client.ts | 134 ++++- .../src/kilo/wrapper-manager.test.ts | 229 ++++++++ .../src/kilo/wrapper-manager.ts | 220 ++++++- .../src/persistence/CloudAgentSession.ts | 10 +- .../src/persistence/async-preparation.ts | 161 +++++- .../src/persistence/schemas.ts | 8 + .../cloud-agent-next/src/persistence/types.ts | 7 + .../cloud-agent-next/src/shared/protocol.ts | 1 + .../cloud-agent-next/src/workspace.test.ts | 87 ++- services/cloud-agent-next/src/workspace.ts | 12 +- .../worker-configuration.d.ts | 2 +- services/cloud-agent-next/wrangler.jsonc | 4 +- .../wrapper/src/restore-session.test.ts | 13 +- .../wrapper/src/restore-session.ts | 88 ++- 27 files changed, 2233 insertions(+), 83 deletions(-) create mode 100644 services/cloud-agent-next/Dockerfile.dind create mode 100755 services/cloud-agent-next/scripts/dev-with-docker-proxy.sh create mode 100644 services/cloud-agent-next/scripts/docker-privileged-proxy.mjs create mode 100644 services/cloud-agent-next/src/kilo/devcontainer.test.ts create mode 100644 services/cloud-agent-next/src/kilo/devcontainer.ts create mode 100644 services/cloud-agent-next/src/kilo/sandbox-runtime.test.ts create mode 100644 services/cloud-agent-next/src/kilo/sandbox-runtime.ts create mode 100644 services/cloud-agent-next/src/kilo/wrapper-manager.test.ts diff --git a/services/cloud-agent-next/Dockerfile.dind b/services/cloud-agent-next/Dockerfile.dind new file mode 100644 index 0000000000..028ae25ef0 --- /dev/null +++ b/services/cloud-agent-next/Dockerfile.dind @@ -0,0 +1,141 @@ +ARG SANDBOX_VERSION="0.8.9" + +FROM docker.io/cloudflare/sandbox:${SANDBOX_VERSION}-musl AS cloudflare-sandbox + +FROM docker:dind-rootless + +USER root + +# Build arguments for metadata (all optional with defaults) +ARG BUILD_DATE="" +ARG VCS_REF="" +ARG KILOCODE_CLI_VERSION="7.1.23" + +# Cloudflare Containers run without root privileges, so Docker must run in +# rootless mode. The Sandbox SDK server is copied into this image so the +# Durable Object can still control the container while dockerd runs as a child +# process. +COPY --from=cloudflare-sandbox /container-server/sandbox /sandbox +COPY --from=cloudflare-sandbox /usr/lib/libstdc++.so.6 /usr/lib/libstdc++.so.6 +COPY --from=cloudflare-sandbox /usr/lib/libgcc_s.so.1 /usr/lib/libgcc_s.so.1 +COPY --from=cloudflare-sandbox /bin/bash /bin/bash +COPY --from=cloudflare-sandbox /usr/lib/libreadline.so.8 /usr/lib/libreadline.so.8 +COPY --from=cloudflare-sandbox /usr/lib/libreadline.so.8.2 /usr/lib/libreadline.so.8.2 + +RUN apk add --no-cache \ + bash \ + curl \ + git \ + git-lfs \ + jq \ + nodejs \ + npm \ + openssh-client \ + tar \ + wget + +# Install GitHub CLI from the official release. Alpine packages can lag the +# Debian package used in Dockerfile, so pin the upstream binary archive here. +RUN GH_VERSION="2.82.1" \ + && wget -q -O /tmp/gh.tar.gz "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_amd64.tar.gz" \ + && tar -xzf /tmp/gh.tar.gz -C /tmp \ + && cp "/tmp/gh_${GH_VERSION}_linux_amd64/bin/gh" /usr/local/bin/gh \ + && chmod +x /usr/local/bin/gh \ + && rm -rf /tmp/gh.tar.gz "/tmp/gh_${GH_VERSION}_linux_amd64" + +# Install GitLab CLI from the official Linux amd64 binary archive. +RUN GLAB_VERSION="1.80.4" \ + && wget -q -O /tmp/glab.tar.gz "https://gitlab.com/gitlab-org/cli/-/releases/v${GLAB_VERSION}/downloads/glab_${GLAB_VERSION}_linux_amd64.tar.gz" \ + && tar -xzf /tmp/glab.tar.gz -C /tmp \ + && cp /tmp/bin/glab /usr/local/bin/glab \ + && chmod +x /usr/local/bin/glab \ + && rm -rf /tmp/glab.tar.gz /tmp/bin + +# Tools used by the outer sandbox. Kilo itself is still installed globally for +# the existing wrapper path; the platform package bundle under /opt/kilo-agent +# is intended for mounting or copying into inner dev containers. +RUN npm install -g bun pnpm @devcontainers/cli @kilocode/cli@${KILOCODE_CLI_VERSION} + +RUN mkdir -p /opt/kilo-agent/bin \ + /opt/kilo-agent/cli-linux-x64 \ + /opt/kilo-agent/cli-linux-x64-musl \ + && npm pack \ + "@kilocode/cli-linux-x64@${KILOCODE_CLI_VERSION}" \ + "@kilocode/cli-linux-x64-musl@${KILOCODE_CLI_VERSION}" \ + --pack-destination /tmp \ + && tar -xzf "/tmp/kilocode-cli-linux-x64-${KILOCODE_CLI_VERSION}.tgz" \ + -C /opt/kilo-agent/cli-linux-x64 --strip-components=1 \ + && tar -xzf "/tmp/kilocode-cli-linux-x64-musl-${KILOCODE_CLI_VERSION}.tgz" \ + -C /opt/kilo-agent/cli-linux-x64-musl --strip-components=1 \ + && rm -f /tmp/kilocode-cli-linux-x64-*.tgz \ + && rm -f /tmp/kilocode-cli-linux-x64-musl-*.tgz \ + && rm -f /opt/kilo-agent/cli-linux-x64/bin/*.map \ + && rm -f /opt/kilo-agent/cli-linux-x64-musl/bin/*.map \ + && chmod +x /opt/kilo-agent/cli-linux-x64/bin/kilo \ + && chmod +x /opt/kilo-agent/cli-linux-x64-musl/bin/kilo + +RUN cat > /opt/kilo-agent/bin/kilo <<'EOF' \ + && chmod +x /opt/kilo-agent/bin/kilo +#!/bin/sh +set -eu + +root="${KILO_AGENT_ROOT:-$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)}" +arch="$(uname -m)" + +if ldd --version 2>&1 | grep -qi musl; then + libc="musl" +else + libc="glibc" +fi + +case "$arch:$libc" in + x86_64:glibc) exec "$root/cli-linux-x64/bin/kilo" "$@" ;; + x86_64:musl) exec "$root/cli-linux-x64-musl/bin/kilo" "$@" ;; + *) echo "Unsupported devcontainer platform: $arch/$libc" >&2; exit 1 ;; +esac +EOF + +# === Build wrapper bundle inside container === +# This mirrors Dockerfile but builds on Alpine, matching the DIND base image. +# /opt/kilo-cloud/ is the canonical location so the wrapper bundle can be +# bind-mounted read-only into a dev container; /usr/local/bin/ keeps symlinks +# so existing outer-sandbox callers (`bun /usr/local/bin/kilocode-wrapper.js`) +# continue to work unchanged. +COPY wrapper /tmp/wrapper-build/wrapper +COPY src/shared /tmp/wrapper-build/src/shared + +RUN mkdir -p /opt/kilo-cloud \ + && cd /tmp/wrapper-build/wrapper \ + && bun install --production \ + && bun build src/main.ts --outfile=/opt/kilo-cloud/kilocode-wrapper.js --target=bun --minify \ + && bun build src/restore-session.ts --outfile=/opt/kilo-cloud/kilo-restore-session.js --target=bun --minify \ + && ln -sf /opt/kilo-cloud/kilocode-wrapper.js /usr/local/bin/kilocode-wrapper.js \ + && ln -sf /opt/kilo-cloud/kilo-restore-session.js /usr/local/bin/kilo-restore-session.js \ + && rm -rf /tmp/wrapper-build + +# Boot script for rootless Docker-in-Docker. +# +# `dockerd-entrypoint.sh` is the upstream rootless wrapper from the +# `docker:dind-rootless` image. It performs the rootlesskit/user namespace +# setup (mounting cgroup v2, /run, etc.) before exec'ing dockerd. Calling +# `dockerd` directly skips that setup and breaks rootless mode. +# +# `--iptables=false --ip6tables=false` disables in-container iptables setup +# because nf_tables / ip_tables modules are not available inside Cloudflare +# Containers. Inner Docker commands must therefore use `--network=host`. +# +# `--exec-opt native.cgroupdriver=cgroupfs` is required in production: the +# default systemd cgroup driver fails when starting inner containers with +# `unable to start unit ... No such process`. The cgroupfs driver avoids +# the systemd dependency. See cloudflare/sandbox-sdk#662. +RUN printf '#!/bin/sh\n\ +set -eu\n\ +dockerd-entrypoint.sh dockerd --iptables=false --ip6tables=false --exec-opt native.cgroupdriver=cgroupfs &\n\ +until docker version >/dev/null 2>&1; do sleep 0.2; done\n\ +echo "Docker is ready"\n\ +wait\n' > /home/rootless/boot-docker-for-dind.sh \ + && chmod +x /home/rootless/boot-docker-for-dind.sh \ + && chown rootless:rootless /home/rootless/boot-docker-for-dind.sh + +ENTRYPOINT ["/sandbox"] +CMD ["/home/rootless/boot-docker-for-dind.sh"] diff --git a/services/cloud-agent-next/package.json b/services/cloud-agent-next/package.json index 1022f3298d..cb6bb7bff3 100644 --- a/services/cloud-agent-next/package.json +++ b/services/cloud-agent-next/package.json @@ -10,9 +10,11 @@ "preinstall": "npx only-allow pnpm", "deploy": "wrangler deploy", "predev": "pnpm run build:wrapper", - "dev": "wrangler dev --env 'dev'", + "dev": "scripts/dev-with-docker-proxy.sh --env dev", + "dev:no-proxy": "wrangler dev --env 'dev'", + "dev:docker-proxy": "node scripts/docker-privileged-proxy.mjs", "prestart": "pnpm run build:wrapper", - "start": "wrangler dev", + "start": "scripts/dev-with-docker-proxy.sh", "types": "wrangler types", "lint": "pnpm -w exec oxlint --config .oxlintrc.json services/cloud-agent-next/src services/cloud-agent-next/wrapper/src", "format": "oxfmt src", diff --git a/services/cloud-agent-next/scripts/dev-with-docker-proxy.sh b/services/cloud-agent-next/scripts/dev-with-docker-proxy.sh new file mode 100755 index 0000000000..fabdef457e --- /dev/null +++ b/services/cloud-agent-next/scripts/dev-with-docker-proxy.sh @@ -0,0 +1,39 @@ +#!/bin/sh +# Run `wrangler dev` with a local Docker socket proxy that injects +# HostConfig.Privileged=true for SandboxSmall (Docker-in-Docker). +# +# See scripts/docker-privileged-proxy.mjs for context. +# Args after `--` are forwarded to wrangler dev. + +set -eu + +script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +service_dir="$(CDPATH= cd -- "$script_dir/.." && pwd)" + +# Unix-domain sockets have a ~104-byte path limit on macOS, so we cannot put +# the socket under the worktree's .wrangler/ directory. Derive a short stable +# path under $TMPDIR keyed on the service directory so multiple worktrees can +# coexist. +hash="$(printf '%s' "$service_dir" | shasum | cut -c1-10)" +socket="${DOCKER_PROXY_SOCKET:-${TMPDIR:-/tmp}/cloud-agent-dind-${hash}.sock}" +# Strip any trailing slash $TMPDIR may carry on macOS. +socket="$(printf '%s' "$socket" | sed 's:/\{1,\}:/:g')" +export DOCKER_PROXY_SOCKET="$socket" + +node "$script_dir/docker-privileged-proxy.mjs" & +proxy=$! +trap 'kill $proxy 2>/dev/null || true' EXIT INT TERM + +i=0 +while [ $i -lt 100 ]; do + [ -S "$socket" ] && break + sleep 0.1 + i=$((i + 1)) +done + +if [ ! -S "$socket" ]; then + echo "Docker proxy socket not found at $socket." >&2 + exit 1 +fi + +DOCKER_HOST="unix://$socket" exec wrangler dev "$@" diff --git a/services/cloud-agent-next/scripts/docker-privileged-proxy.mjs b/services/cloud-agent-next/scripts/docker-privileged-proxy.mjs new file mode 100644 index 0000000000..83f01dabcd --- /dev/null +++ b/services/cloud-agent-next/scripts/docker-privileged-proxy.mjs @@ -0,0 +1,117 @@ +// Docker socket proxy that injects HostConfig.Privileged=true into +// `POST /containers/create` requests. +// +// Why this exists +// --------------- +// Cloudflare Containers run our `SandboxSmall` image (Docker-in-Docker) +// privileged in production, but local `wrangler dev` has no supported way +// to set Docker container create options like `HostConfig.Privileged=true`. +// Without that, rootless dockerd inside the Sandbox container fails to set +// up its mounts and `/var/run/docker.sock` never appears. +// +// Workaround: run a small Unix-socket proxy on the developer machine that +// forwards Docker API calls to the host's real Docker socket and rewrites +// `POST /containers/create` bodies to set `HostConfig.Privileged=true`. +// `pnpm dev` then runs Wrangler with `DOCKER_HOST` pointed at this proxy. +// +// This matches the workaround documented in cloudflare/sandbox-sdk#662 and +// the `sandbox-dind-repro` reference repository. + +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import net from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; + +function normalizeSocket(socket) { + return socket?.startsWith('unix://') ? socket.slice('unix://'.length) : socket; +} + +function getDockerContextSocket() { + try { + const socket = execFileSync( + 'docker', + ['context', 'inspect', '--format', '{{.Endpoints.docker.Host}}'], + { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + } + ).trim(); + + return normalizeSocket(socket); + } catch { + return undefined; + } +} + +const listenPath = + process.env.DOCKER_PROXY_SOCKET ?? path.join(process.cwd(), '.wrangler/docker-privileged.sock'); +const targetPath = + normalizeSocket(process.env.DOCKER_SOCKET) ?? getDockerContextSocket() ?? '/var/run/docker.sock'; + +fs.mkdirSync(path.dirname(listenPath), { recursive: true }); +try { + fs.unlinkSync(listenPath); +} catch (error) { + if (error.code !== 'ENOENT') throw error; +} + +const server = net.createServer(client => { + let buffered = Buffer.alloc(0); + let patched = false; + const upstream = net.createConnection(targetPath); + + upstream.on('data', chunk => client.write(chunk)); + upstream.on('error', error => { + console.error(`Docker upstream error: ${error.message}`); + client.destroy(); + }); + client.on('error', () => upstream.destroy()); + client.on('end', () => upstream.end()); + upstream.on('end', () => client.end()); + + client.on('data', chunk => { + if (patched) { + upstream.write(chunk); + return; + } + + buffered = Buffer.concat([buffered, chunk]); + const headerEnd = buffered.indexOf('\r\n\r\n'); + + if (headerEnd === -1) return; + + const header = buffered.slice(0, headerEnd).toString('utf8'); + const bodyStart = headerEnd + 4; + const match = header.match(/^POST\s+\S*\/containers\/create(?:\?|\s)/); + const contentLength = header.match(/\r\nContent-Length:\s*(\d+)/i); + + if (!match || !contentLength) { + patched = true; + upstream.write(buffered); + return; + } + + const length = Number(contentLength[1]); + if (buffered.length < bodyStart + length) return; + + const body = buffered.slice(bodyStart, bodyStart + length).toString('utf8'); + const rest = buffered.slice(bodyStart + length); + const payload = JSON.parse(body); + payload.HostConfig = { ...payload.HostConfig, Privileged: true }; + const nextBody = Buffer.from(JSON.stringify(payload)); + const nextHeader = header.replace(/(\r\nContent-Length:\s*)\d+/i, `$1${nextBody.length}`); + + patched = true; + upstream.write(Buffer.concat([Buffer.from(`${nextHeader}\r\n\r\n`), nextBody, rest])); + }); +}); + +server.listen(listenPath, () => { + if (os.platform() !== 'win32') fs.chmodSync(listenPath, 0o600); + console.log(`Docker privileged proxy listening on ${listenPath}`); + console.log(`Forwarding to ${targetPath}`); +}); + +process.on('SIGINT', () => server.close(() => process.exit(0))); +process.on('SIGTERM', () => server.close(() => process.exit(0))); diff --git a/services/cloud-agent-next/src/execution/orchestrator.ts b/services/cloud-agent-next/src/execution/orchestrator.ts index d49b28b37f..e3a3244faa 100644 --- a/services/cloud-agent-next/src/execution/orchestrator.ts +++ b/services/cloud-agent-next/src/execution/orchestrator.ts @@ -15,12 +15,14 @@ import type { } from '../types.js'; import type { CloudAgentSession } from '../persistence/CloudAgentSession.js'; import type { ExecutionPlan, ExecutionResult } from './types.js'; +import type { DevContainerHandle } from '../kilo/devcontainer.js'; import { ExecutionError } from './errors.js'; import { SessionService, type PreparedSession } from '../session-service.js'; import { logger } from '../logger.js'; import { logSandboxOperationTimeout } from '../sandbox-timeout-logging.js'; import { updateGitRemoteToken } from '../workspace.js'; import { WrapperClient, type WrapperPromptOptions } from '../kilo/wrapper-client.js'; +import { bringUpDevContainer, KILO_CLI_VERSION } from '../kilo/devcontainer.js'; import { withDORetry } from '../utils/do-retry.js'; import { normalizeAgentMode } from '../schema.js'; import { buildImagePromptParts, downloadImagePromptParts } from './image-prompt-parts.js'; @@ -128,11 +130,15 @@ export class ExecutionOrchestrator { let wrapperClient: WrapperClient; let kiloSessionId: string; try { + const devcontainer = await this.ensureDevContainerHandleIfNeeded(prepared, plan); + const fixedPort = plan.workspace.existingMetadata?.devcontainer?.wrapperPort; const result = await WrapperClient.ensureWrapper(sandbox, prepared.session, { agentSessionId: sessionId, userId, workspacePath: prepared.context.workspacePath, sessionId: wrapper.kiloSessionId, + devcontainer, + fixedPort: devcontainer ? fixedPort : undefined, }); wrapperClient = result.client; kiloSessionId = result.sessionId; @@ -219,6 +225,23 @@ export class ExecutionOrchestrator { // Private Helpers // --------------------------------------------------------------------------- + private async ensureDevContainerHandleIfNeeded( + prepared: PreparedSession, + plan: ExecutionPlan + ): Promise { + const devcontainer = plan.workspace.existingMetadata?.devcontainer; + if (!devcontainer) return undefined; + + return bringUpDevContainer(prepared.session, { + workspacePath: devcontainer.workspacePath, + sessionHome: prepared.context.sessionHome, + agentSessionId: plan.sessionId, + wrapperPort: devcontainer.wrapperPort, + kiloCliVersion: KILO_CLI_VERSION, + configPath: devcontainer.configPath, + }); + } + /** * Prepare workspace based on the workspace plan. * Handles three paths: resume, fast path (fully prepared), and full init. diff --git a/services/cloud-agent-next/src/execution/types.ts b/services/cloud-agent-next/src/execution/types.ts index 3e0784dea4..898d9c8c8c 100644 --- a/services/cloud-agent-next/src/execution/types.ts +++ b/services/cloud-agent-next/src/execution/types.ts @@ -267,6 +267,12 @@ export type ExistingSessionMetadata = { branchName: string; sandboxId?: string; sessionHome?: string; + devcontainer?: { + workspacePath: string; + innerWorkspaceFolder: string; + wrapperPort: number; + configPath: string; + }; upstreamBranch?: string; appendSystemPrompt?: string; /** GitHub repo (for token updates) */ diff --git a/services/cloud-agent-next/src/kilo/devcontainer.test.ts b/services/cloud-agent-next/src/kilo/devcontainer.test.ts new file mode 100644 index 0000000000..f6bd0c0027 --- /dev/null +++ b/services/cloud-agent-next/src/kilo/devcontainer.test.ts @@ -0,0 +1,251 @@ +/** + * Unit tests for the dev container module. + * + * The orchestration helpers (`bringUpDevContainer`, `teardownDevContainer`) + * shell out via `session.exec`, so tests cover the surfaces that don't need a + * real container: + * - detection of `.devcontainer/...` configs + * - `devcontainer up --log-format json` outcome parsing + * - generated override shape and merge behavior + */ + +import { describe, expect, it, vi } from 'vitest'; +import { + buildOverrideConfig, + detectDevContainer, + getDevContainerOverridePath, + KILO_AGENT_SESSION_LABEL, + KILO_WRAPPER_PORT_LABEL, + mergeDevContainerConfig, + parseUpOutcome, + writeMergedOverrideConfig, +} from './devcontainer.js'; +import type { ExecutionSession } from '../types.js'; + +const mockSessionExec = (impl: (cmd: string) => { exitCode: number; stdout?: string }) => + ({ + exec: vi.fn(async (cmd: string) => impl(cmd)), + }) as unknown as ExecutionSession; + +describe('detectDevContainer', () => { + it('returns null when no devcontainer file exists', async () => { + const session = mockSessionExec(() => ({ exitCode: 0, stdout: '' })); + const result = await detectDevContainer(session, '/workspace/repo'); + expect(result).toBeNull(); + }); + + it('returns the canonical .devcontainer/devcontainer.json when present', async () => { + const session = mockSessionExec(cmd => { + // The shell script echoes the first matching path; here we simulate the + // canonical hit by returning that line. + expect(cmd).toContain('cd '); + expect(cmd).toContain('/workspace/repo'); + return { exitCode: 0, stdout: '.devcontainer/devcontainer.json\n' }; + }); + const result = await detectDevContainer(session, '/workspace/repo'); + expect(result).toEqual({ configPath: '.devcontainer/devcontainer.json' }); + }); + + it('falls back to a sub-folder devcontainer.json', async () => { + const session = mockSessionExec(() => ({ + exitCode: 0, + stdout: '.devcontainer/python/devcontainer.json\n', + })); + const result = await detectDevContainer(session, '/workspace/repo'); + expect(result).toEqual({ configPath: '.devcontainer/python/devcontainer.json' }); + }); + + it('returns null when the session exec fails', async () => { + const session = mockSessionExec(() => ({ exitCode: 1 })); + expect(await detectDevContainer(session, '/workspace/repo')).toBeNull(); + }); + + it('shell-quotes the workspace path', async () => { + const session = mockSessionExec(() => ({ exitCode: 0, stdout: '' })); + await detectDevContainer(session, "/work's space/repo"); + const calls = (session.exec as unknown as { mock: { calls: unknown[][] } }).mock.calls; + // Should escape the embedded single quote. + expect(calls[0][0]).toContain(`/work'\\''s space/repo`); + }); +}); + +describe('parseUpOutcome', () => { + it('returns null on empty stdout', () => { + expect(parseUpOutcome('')).toBeNull(); + }); + + it('extracts containerId and remoteWorkspaceFolder from a success line', () => { + const stdout = [ + '{"type":"progress","step":"build"}', + '{"outcome":"success","containerId":"deadbeef","remoteWorkspaceFolder":"/workspaces/repo"}', + ].join('\n'); + expect(parseUpOutcome(stdout)).toEqual({ + containerId: 'deadbeef', + remoteWorkspaceFolder: '/workspaces/repo', + }); + }); + + it('ignores non-success outcomes and non-JSON lines', () => { + const stdout = [ + 'plain log line — not JSON', + '{"outcome":"error","message":"build failed"}', + 'still plain text', + ].join('\n'); + expect(parseUpOutcome(stdout)).toBeNull(); + }); + + it('prefers the last success line if multiple are emitted', () => { + const stdout = [ + '{"outcome":"success","containerId":"first","remoteWorkspaceFolder":"/old"}', + '{"outcome":"success","containerId":"second","remoteWorkspaceFolder":"/new"}', + ].join('\n'); + expect(parseUpOutcome(stdout)).toEqual({ + containerId: 'second', + remoteWorkspaceFolder: '/new', + }); + }); + + it('returns null when success line is missing required fields', () => { + const stdout = '{"outcome":"success"}'; + expect(parseUpOutcome(stdout)).toBeNull(); + }); +}); + +describe('buildOverrideConfig', () => { + const baseOpts = { + sessionHome: '/home/agent_xyz', + wrapperPort: 5050, + agentSessionId: 'agent_xyz', + }; + + it('does not override workspaceMount or workspaceFolder', () => { + const cfg = buildOverrideConfig(baseOpts); + expect(cfg).not.toHaveProperty('workspaceMount'); + expect(cfg).not.toHaveProperty('workspaceFolder'); + }); + + it('includes the required mounts without exposing Docker', () => { + const cfg = buildOverrideConfig(baseOpts); + expect(cfg.mounts).toEqual([ + 'source=/opt/kilo-cloud,target=/opt/kilo-cloud,type=bind,readonly', + 'source=/home/agent_xyz,target=/home/agent_xyz,type=bind', + ]); + }); + + it('publishes the wrapper port to outer loopback and stamps the agent-session label', () => { + const cfg = buildOverrideConfig(baseOpts); + expect(cfg.runArgs).toEqual([ + '--network=host', + '--publish', + '127.0.0.1:5050:5050', + '--label', + `${KILO_AGENT_SESSION_LABEL}=agent_xyz`, + '--label', + `${KILO_WRAPPER_PORT_LABEL}=5050`, + ]); + }); + + it('sets HOME without exposing the outer Docker socket', () => { + const cfg = buildOverrideConfig(baseOpts); + expect(cfg.remoteEnv).toEqual({ + HOME: '/home/agent_xyz', + KILO_CLOUD_AGENT: '1', + }); + }); + + it('forces remoteUser to root so bind-mount ownership lines up without uid rewrites', () => { + const cfg = buildOverrideConfig(baseOpts); + expect(cfg.remoteUser).toBe('root'); + }); +}); + +describe('writeMergedOverrideConfig', () => { + it('writes a node script that merges additive Kilo config into the user config', async () => { + const session = mockSessionExec(cmd => { + expect(cmd).toContain('const outputPath = "/tmp/merged-devcontainer.json"'); + expect(cmd).toContain('source=/opt/kilo-cloud,target=/opt/kilo-cloud,type=bind,readonly'); + expect(cmd).toContain('source=/home/agent_xyz,target=/home/agent_xyz,type=bind'); + expect(cmd).toContain(`${KILO_AGENT_SESSION_LABEL}=agent_xyz`); + expect(cmd).toContain(`${KILO_WRAPPER_PORT_LABEL}=5050`); + return { exitCode: 0 }; + }); + + await writeMergedOverrideConfig(session, { + workspacePath: '/workspace/repo', + outputPath: '/tmp/merged-devcontainer.json', + baseConfig: { image: 'debian:bookworm', remoteUser: 'vscode' }, + sessionHome: '/home/agent_xyz', + wrapperPort: 5050, + agentSessionId: 'agent_xyz', + }); + }); + + it('throws when the merge script fails', async () => { + const session = mockSessionExec(() => ({ exitCode: 1, stderr: 'bad json' })); + + await expect( + writeMergedOverrideConfig(session, { + workspacePath: '/workspace/repo', + outputPath: '/tmp/merged-devcontainer.json', + baseConfig: { image: 'debian:bookworm' }, + sessionHome: '/home/agent_xyz', + wrapperPort: 5050, + agentSessionId: 'agent_xyz', + }) + ).rejects.toThrow('bad json'); + }); +}); + +describe('mergeDevContainerConfig', () => { + it('preserves user config while appending Kilo mounts, runArgs, and remoteEnv', () => { + const merged = mergeDevContainerConfig( + { + image: 'debian:bookworm', + mounts: ['source=/user,target=/user,type=bind'], + runArgs: ['--env', 'USER_FLAG=1'], + remoteEnv: { USER_ENV: '1' }, + }, + { sessionHome: '/home/agent_xyz', wrapperPort: 5050, agentSessionId: 'agent_xyz' } + ); + + expect(merged.image).toBe('debian:bookworm'); + expect(merged.mounts).toEqual([ + 'source=/user,target=/user,type=bind', + 'source=/opt/kilo-cloud,target=/opt/kilo-cloud,type=bind,readonly', + 'source=/home/agent_xyz,target=/home/agent_xyz,type=bind', + ]); + expect(merged.runArgs).toEqual([ + '--env', + 'USER_FLAG=1', + '--network=host', + '--publish', + '127.0.0.1:5050:5050', + '--label', + `${KILO_AGENT_SESSION_LABEL}=agent_xyz`, + '--label', + `${KILO_WRAPPER_PORT_LABEL}=5050`, + ]); + expect(merged.remoteEnv).toEqual({ + USER_ENV: '1', + HOME: '/home/agent_xyz', + KILO_CLOUD_AGENT: '1', + }); + }); + + it("overrides the user's remoteUser with root", () => { + const merged = mergeDevContainerConfig( + { image: 'debian:bookworm', remoteUser: 'vscode' }, + { sessionHome: '/home/agent_xyz', wrapperPort: 5050, agentSessionId: 'agent_xyz' } + ); + + expect(merged.remoteUser).toBe('root'); + }); +}); + +describe('getDevContainerOverridePath', () => { + it('is deterministic given an agent session ID so any subsystem can pass --config', () => { + expect(getDevContainerOverridePath('agent_xyz')).toBe( + '/tmp/devcontainer-override-agent_xyz/devcontainer.json' + ); + }); +}); diff --git a/services/cloud-agent-next/src/kilo/devcontainer.ts b/services/cloud-agent-next/src/kilo/devcontainer.ts new file mode 100644 index 0000000000..6efe2bd66a --- /dev/null +++ b/services/cloud-agent-next/src/kilo/devcontainer.ts @@ -0,0 +1,536 @@ +/** + * Dev container support for cloud-agent sessions. + * + * When a repo ships a `.devcontainer/` config, we run the wrapper *inside* + * that container instead of on the outer sandbox. The wrapper's HTTP port is + * published from the dev container to the outer sandbox's loopback so the + * Worker can keep talking to it via curl over `session.exec`. + * + * Architecture is documented in `.plans/devcontainer-support.md`. + */ + +import type { ExecutionSession } from '../types.js'; +import { logger } from '../logger.js'; +import { dockerSocketEnv, resolveDockerSocketPath, waitForDocker } from './sandbox-runtime.js'; +import { shellQuote } from './utils.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Result of dev container detection. We only return enough information to + * surface in logs / progress events — `devcontainer up` discovers the config + * itself given `--workspace-folder`. + */ +export type DevContainerConfig = { + /** Path relative to the workspace root, e.g. `.devcontainer/devcontainer.json`. */ + configPath: string; +}; + +/** + * Handle to a running dev container. Pass through to the wrapper start path + * so `devcontainer exec` is used in place of `session.startProcess`. + */ +export type DevContainerHandle = { + /** Docker container ID returned by `devcontainer up`. */ + containerId: string; + /** Path to the workspace as seen *inside* the container (`remoteWorkspaceFolder`). */ + innerWorkspaceFolder: string; + /** Outer/host workspace path — used as `--workspace-folder` for subsequent `devcontainer exec`s. */ + workspacePath: string; + /** Agent session ID — also stamped on the container as the `kilo.agentSession` label for discovery. */ + agentSessionId: string; + /** + * Path on the outer sandbox to the merged override `devcontainer.json`. Must + * be passed as `--config` to every `devcontainer exec` so the CLI keeps + * using our `remoteUser: root` + `remoteEnv.HOME` overrides; without it the + * CLI re-reads the user's on-disk `.devcontainer/devcontainer.json` and + * falls back to its `remoteUser` (typically `vscode`), breaking writes into + * the bind-mounted sessionHome. + */ + overrideConfigPath: string; + /** Best-effort teardown. Safe to invoke after the outer sandbox is gone. */ + teardown: () => Promise; +}; + +export type BringUpOptions = { + /** Outer/host workspace path that contains `.devcontainer/`. */ + workspacePath: string; + /** Per-session HOME (`/home/`) on the outer sandbox; bind-mounted at the same path inside. */ + sessionHome: string; + /** Cloud-agent session ID — used as the docker container label for discovery. */ + agentSessionId: string; + /** Wrapper HTTP port (so we can publish it to the outer sandbox's loopback). */ + wrapperPort: number; + /** Pinned `@kilocode/cli` version installed inside the dev container. */ + kiloCliVersion: string; + /** + * Path (relative to workspace root) of the detected devcontainer config, + * as returned by `detectDevContainer`. Passed through so `readDevContainerConfig` + * reads the exact file that was detected, avoiding a redundant path resolution + * that could diverge from the detection logic. + */ + configPath: string; +}; + +type DevContainerJson = Record; + +type ExecOptions = NonNullable[1]>; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Label that identifies the container as belonging to a given cloud-agent session. */ +export const KILO_AGENT_SESSION_LABEL = 'kilo.agentSession'; + +/** + * Deterministic outer-sandbox path to the merged override `devcontainer.json`. + * + * Built from the agent session ID so any subsystem (orchestrator, wrapper + * manager, restore-session helper) can reconstruct it without threading the + * value through every call. The file is created by `bringUpDevContainer` and + * removed by `teardownDevContainer`. + */ +export function getDevContainerOverridePath(agentSessionId: string): string { + return `/tmp/devcontainer-override-${agentSessionId}/devcontainer.json`; +} + +/** Label that records the wrapper HTTP port published by the dev container. */ +export const KILO_WRAPPER_PORT_LABEL = 'kilo.wrapperPort'; + +/** + * Pinned kilo CLI version installed *inside* the dev container. + * + * Keep this in sync with `KILOCODE_CLI_VERSION` in `Dockerfile.dind` / + * `wrangler.jsonc#image_vars` so the kilo running in the dev container + * matches the one we use on the outer sandbox. + */ +export const KILO_CLI_VERSION = '7.1.23'; + +/** `devcontainer up` prints multiple JSON lines on stdout — we look for this final line. */ +const UP_OUTCOME_SUCCESS = 'success'; + +// --------------------------------------------------------------------------- +// Detection +// --------------------------------------------------------------------------- + +/** + * Detect a dev container config inside the cloned workspace. + * + * Order matches VS Code's resolution: + * 1. `.devcontainer/devcontainer.json` + * 2. `.devcontainer.json` + * 3. `.devcontainer//devcontainer.json` (first found, alphabetical) + * + * Returns `null` if none of those exist. + */ +export async function detectDevContainer( + session: ExecutionSession, + workspacePath: string +): Promise { + const escaped = shellQuote(workspacePath); + const script = [ + `cd ${escaped}`, + `if [ -f .devcontainer/devcontainer.json ]; then echo .devcontainer/devcontainer.json`, + `elif [ -f .devcontainer.json ]; then echo .devcontainer.json`, + // glob is sorted alphabetically by `ls`, which is what we want for stability. + `else ls .devcontainer/*/devcontainer.json 2>/dev/null | head -n 1`, + `fi`, + ].join('; '); + + const result = await session.exec(script); + if (result.exitCode !== 0) { + return null; + } + const path = result.stdout?.trim(); + if (!path) return null; + return { configPath: path }; +} + +// --------------------------------------------------------------------------- +// Bring up +// --------------------------------------------------------------------------- + +/** + * Run `devcontainer up` for the cloned workspace and install kilo inside. + * + * Throws on failure (the orchestrator turns it into a `failed` progress event) + * — silently falling back to the non-devcontainer path would mismatch the + * project toolchain in confusing ways. + */ +export async function bringUpDevContainer( + session: ExecutionSession, + opts: BringUpOptions +): Promise { + const { workspacePath, sessionHome, agentSessionId, wrapperPort, kiloCliVersion, configPath } = + opts; + + // devcontainer/docker CLIs need DOCKER_HOST pointing at the sandbox dockerd + // socket, which may differ between local smoke images and Cloudflare runtime. + const dockerEnv = dockerSocketEnv(await resolveDockerSocketPath(session)); + + // The DIND sandbox image backgrounds dockerd from its CMD, but /sandbox starts + // accepting exec requests before dockerd has bound its socket. Block until + // `docker version` succeeds so `devcontainer up` doesn't race the daemon. + await waitForDocker(session, dockerEnv); + + const userConfig = await readDevContainerConfig(session, workspacePath, configPath); + + // Pre-create the cache subdirectories the outer sandbox writes into later + // (`.local/share/kilo` via writeAuthFile, `tmp` via the session import). + // Both the outer sandbox and the inner devcontainer run as root (we force + // `remoteUser: root` in `buildOverrideConfig`), so file ownership lines up + // by construction without any chown/chmod or uid-rewrite trickery. + await session.exec( + `mkdir -p "${sessionHome}/.cache" "${sessionHome}/.local/share/kilo" "${sessionHome}/tmp"`, + { timeout: 10_000 } + ); + + // 1. Build merged config. `@devcontainers/cli` treats an override file as the + // complete config unless the base config is passed explicitly via `--config`. + // Since our generated file lives outside the workspace, merge it ourselves so + // the user's `image`/`dockerFile`/`dockerComposeFile` stays intact. + const overridePath = getDevContainerOverridePath(agentSessionId); + const overrideDir = overridePath.substring(0, overridePath.lastIndexOf('/')); + await session.exec(`mkdir -p ${shellQuote(overrideDir)}`); + await writeMergedOverrideConfig(session, { + workspacePath, + outputPath: overridePath, + baseConfig: userConfig, + sessionHome, + wrapperPort, + agentSessionId, + }); + + // 2. devcontainer up + // + // `--update-remote-user-uid-default never` because we sidestep the uid + // alignment problem by forcing `remoteUser: root` in our override config + // (see buildOverrideConfig). Letting the CLI rewrite vscode's uid would + // require a `docker build` step that triggers binfmt_misc/Rosetta inside + // the inner runc — fine in production but blows up on Mac DIND smoke + // (rosetta error: failed to open elf at -exec-root=/var/run/docker). + // + // Note: `--config ` must also be passed to every subsequent + // `devcontainer exec`. Without it the CLI re-reads the user's on-disk + // `.devcontainer/devcontainer.json` and resets `remoteUser` to whatever + // the user declared (typically `vscode`), breaking writes into the + // bind-mounted sessionHome. See `getDevContainerOverridePath`. + logger.withFields({ agentSessionId, workspacePath }).info('Running devcontainer up'); + const upCmd = [ + 'devcontainer up', + `--workspace-folder ${shellQuote(workspacePath)}`, + `--config ${shellQuote(overridePath)}`, + `--id-label ${shellQuote(`${KILO_AGENT_SESSION_LABEL}=${agentSessionId}`)}`, + `--buildkit never`, + `--update-remote-user-uid-default never`, + `--log-format json`, + ].join(' '); + + const upResult = await session.exec(upCmd, { env: dockerEnv }); + if (upResult.exitCode !== 0) { + throw new DevContainerUpError( + `devcontainer up failed (exit ${upResult.exitCode})`, + upResult.stdout ?? '', + upResult.stderr ?? '' + ); + } + + const outcome = parseUpOutcome(upResult.stdout ?? ''); + if (!outcome) { + throw new DevContainerUpError( + 'devcontainer up succeeded but no outcome line was emitted', + upResult.stdout ?? '', + upResult.stderr ?? '' + ); + } + + logger + .withFields({ + agentSessionId, + containerId: outcome.containerId, + innerWorkspaceFolder: outcome.remoteWorkspaceFolder, + }) + .info('Dev container is up'); + + // The override config must stick around for the lifetime of the dev + // container: every subsequent `devcontainer exec` needs `--config` pointing + // at it so the CLI keeps applying our `remoteUser: root` + `remoteEnv.HOME` + // overrides instead of silently re-reading the user's on-disk + // `.devcontainer/devcontainer.json`. Cleanup happens in + // `teardownDevContainer`. + + // Verify the dev container has bun and kilo. Fail early with a clear + // message so users know what to add to their devcontainer.json. + const preflightCmd = [ + 'devcontainer exec', + `--workspace-folder ${shellQuote(workspacePath)}`, + `--config ${shellQuote(overridePath)}`, + `--id-label ${shellQuote(`${KILO_AGENT_SESSION_LABEL}=${agentSessionId}`)}`, + `--`, + `sh -c ${shellQuote('command -v bun >/dev/null && command -v kilo >/dev/null || echo MISSING')}`, + ].join(' '); + const preflight = await session.exec(preflightCmd, { env: dockerEnv }); + const missing = (preflight.stdout ?? '').includes('MISSING') || preflight.exitCode !== 0; + if (missing) { + throw new DevContainerUpError( + 'Dev container is missing required tools (bun and/or kilo). Add to your devcontainer.json:\n\n' + + ' "features": {\n' + + ' "ghcr.io/devcontainers/features/node:1": {}\n' + + ' },\n' + + ' "postCreateCommand": "curl -fsSL https://bun.sh/install | bash && ' + + 'npm install -g @kilocode/cli@' + + kiloCliVersion + + '"\n\n' + + 'Or install bun and kilo manually so both are on PATH.', + preflight.stdout ?? '', + preflight.stderr ?? '' + ); + } + + return { + containerId: outcome.containerId, + innerWorkspaceFolder: outcome.remoteWorkspaceFolder, + workspacePath, + agentSessionId, + overrideConfigPath: overridePath, + teardown: () => teardownDevContainer(session, workspacePath, agentSessionId, dockerEnv), + }; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Build the override JSON merged on top of the user's `devcontainer.json`. + * Strictly additive for `mounts`/`runArgs`/`remoteEnv` (we never touch + * `workspaceMount`/`workspaceFolder`); `remoteUser` is forced to `root` so + * that file ownership across the outer→inner bind mount lines up by + * construction. The user's `"remoteUser": "vscode"` (or similar) is replaced + * — we do this rather than relying on `--update-remote-user-uid-default on` + * because that flag forces a `docker build` for the uid rewrite, which trips + * binfmt_misc/Rosetta on local Mac DIND smoke runs (works in prod, breaks + * locally). Running as root inside the container is functionally equivalent + * for our use case (kilo doesn't care about user identity, postCreate scripts + * that use `sudo` no-op as root). + */ +export function buildOverrideConfig(opts: { + sessionHome: string; + wrapperPort: number; + agentSessionId: string; +}): Record { + const { sessionHome, wrapperPort, agentSessionId } = opts; + + return { + remoteUser: 'root', + mounts: [ + // Read-only wrapper bundle. + `source=/opt/kilo-cloud,target=/opt/kilo-cloud,type=bind,readonly`, + // HOME alignment — kilo's xdg-basedir paths must resolve identically inside and out. + `source=${sessionHome},target=${sessionHome},type=bind`, + ], + runArgs: [ + '--network=host', + // --publish is more universally honored than `appPort` across @devcontainers/cli versions. + '--publish', + `127.0.0.1:${wrapperPort}:${wrapperPort}`, + // Stamp the container so we can rediscover it after a wrapper restart. + '--label', + `${KILO_AGENT_SESSION_LABEL}=${agentSessionId}`, + '--label', + `${KILO_WRAPPER_PORT_LABEL}=${wrapperPort}`, + ], + remoteEnv: { + HOME: sessionHome, + KILO_CLOUD_AGENT: '1', + }, + }; +} + +export async function writeMergedOverrideConfig( + session: ExecutionSession, + opts: { + workspacePath: string; + outputPath: string; + baseConfig: DevContainerJson; + sessionHome: string; + wrapperPort: number; + agentSessionId: string; + } +): Promise { + const { outputPath, baseConfig, sessionHome, wrapperPort, agentSessionId } = opts; + const merged = mergeDevContainerConfig(baseConfig, { + sessionHome, + wrapperPort, + agentSessionId, + }); + const mergeScript = `node <<'__KILO_MERGE_EOF__' +const fs = require('fs'); +const outputPath = ${JSON.stringify(outputPath)}; +const merged = ${JSON.stringify(merged, null, 2)}; + +fs.writeFileSync(outputPath, JSON.stringify(merged, null, 2)); +__KILO_MERGE_EOF__`; + + const result = await session.exec(mergeScript, { timeout: 5_000 } satisfies ExecOptions); + if (result.exitCode !== 0) { + throw new Error( + `Failed to write devcontainer override config: ${(result.stderr ?? result.stdout ?? '').trim()}` + ); + } +} + +export function mergeDevContainerConfig( + baseConfig: DevContainerJson, + opts: { sessionHome: string; wrapperPort: number; agentSessionId: string } +): DevContainerJson { + const override = buildOverrideConfig(opts); + return { + ...baseConfig, + // `remoteUser` from override wins over base — see buildOverrideConfig for why. + ...(typeof override.remoteUser === 'string' ? { remoteUser: override.remoteUser } : {}), + mounts: [ + ...(Array.isArray(baseConfig.mounts) ? baseConfig.mounts : []), + ...(Array.isArray(override.mounts) ? override.mounts : []), + ], + runArgs: [ + ...(Array.isArray(baseConfig.runArgs) ? baseConfig.runArgs : []), + ...(Array.isArray(override.runArgs) ? override.runArgs : []), + ], + remoteEnv: { + ...(isRecord(baseConfig.remoteEnv) ? baseConfig.remoteEnv : {}), + ...(isRecord(override.remoteEnv) ? override.remoteEnv : {}), + }, + }; +} + +async function readDevContainerConfig( + session: ExecutionSession, + workspacePath: string, + configPath: string +): Promise { + const absoluteConfigPath = configPath.startsWith('/') + ? configPath + : `${workspacePath}/${configPath}`; + const script = `node <<'__KILO_READ_DEVCONTAINER_EOF__' +const fs = require('fs'); + +const configPath = ${JSON.stringify(absoluteConfigPath)}; + +process.stdout.write(fs.readFileSync(configPath, 'utf8')); +__KILO_READ_DEVCONTAINER_EOF__`; + + const result = await session.exec(script, { timeout: 5_000 } satisfies ExecOptions); + if (result.exitCode !== 0) { + throw new Error( + `Failed to read devcontainer config: ${(result.stderr ?? result.stdout ?? '').trim()}` + ); + } + return JSON.parse(result.stdout ?? '{}') as DevContainerJson; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +type UpOutcome = { + containerId: string; + remoteWorkspaceFolder: string; +}; + +/** + * Parse `devcontainer up --log-format json` stdout for the success outcome. + * + * The CLI prints a stream of JSON objects (build progress, etc.) followed by a + * final line with `{"outcome": "success", "containerId": "...", "remoteWorkspaceFolder": "..."}`. + * Lenient: ignores any non-JSON or non-outcome lines. + */ +export function parseUpOutcome(stdout: string): UpOutcome | null { + // Walk lines in reverse so we get the *final* outcome even if the CLI emits + // multiple — and so we don't waste time JSON-parsing every progress entry. + const lines = stdout.split('\n'); + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i].trim(); + if (!line.startsWith('{')) continue; + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + continue; + } + if (!parsed || typeof parsed !== 'object') continue; + const obj = parsed as Record; + if (obj.outcome !== UP_OUTCOME_SUCCESS) continue; + const containerId = typeof obj.containerId === 'string' ? obj.containerId : ''; + const remoteWorkspaceFolder = + typeof obj.remoteWorkspaceFolder === 'string' ? obj.remoteWorkspaceFolder : ''; + if (!containerId || !remoteWorkspaceFolder) continue; + return { containerId, remoteWorkspaceFolder }; + } + return null; +} + +async function teardownDevContainer( + session: ExecutionSession, + workspacePath: string, + agentSessionId: string, + dockerEnv: Record +): Promise { + // Best-effort: `devcontainer down` is the polite path; falling back to + // `docker rm -f` by label catches the case where the dev container was + // started by an older CLI that doesn't honour --workspace-folder for down. + const downCmd = `devcontainer down --workspace-folder ${shellQuote(workspacePath)}`; + try { + await session.exec(downCmd, { env: dockerEnv }); + } catch (error) { + logger + .withFields({ + agentSessionId, + error: error instanceof Error ? error.message : String(error), + }) + .warn('devcontainer down failed; falling back to docker rm -f'); + } + + const fallbackCmd = `docker rm -f $(docker ps -aq --filter ${shellQuote(`label=${KILO_AGENT_SESSION_LABEL}=${agentSessionId}`)}) 2>/dev/null || true`; + try { + await session.exec(fallbackCmd, { env: dockerEnv }); + } catch { + // Container may already be gone; not fatal. + } + + // Clean up the merged override config dir we kept alive for `devcontainer + // exec --config`. Best-effort — `/tmp` gets reaped on sandbox sleepAfter. + const overrideDir = getDevContainerOverridePath(agentSessionId).replace(/\/[^/]+$/, ''); + try { + await session.exec(`rm -rf ${shellQuote(overrideDir)}`); + } catch { + // tmp gets reaped on sandbox sleepAfter anyway. + } +} + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +export class DevContainerUpError extends Error { + constructor( + message: string, + public readonly stdout: string, + public readonly stderr: string + ) { + super(formatDiagnostic(message, stdout, stderr)); + this.name = 'DevContainerUpError'; + } +} + +function formatDiagnostic(message: string, stdout: string, stderr: string): string { + const parts = [message]; + const trimmedStdout = stdout.trim(); + const trimmedStderr = stderr.trim(); + if (trimmedStderr) parts.push(`stderr: ${trimmedStderr}`); + if (trimmedStdout) parts.push(`stdout: ${trimmedStdout}`); + return parts.join(' | '); +} diff --git a/services/cloud-agent-next/src/kilo/sandbox-runtime.test.ts b/services/cloud-agent-next/src/kilo/sandbox-runtime.test.ts new file mode 100644 index 0000000000..e0a231bd6c --- /dev/null +++ b/services/cloud-agent-next/src/kilo/sandbox-runtime.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + dockerSocketEnv, + dockerSocketEnvParts, + resolveDockerSocketPath, +} from './sandbox-runtime.js'; +import type { ExecutionSession } from '../types.js'; + +const mockExec = (impl: (cmd: string) => { exitCode: number; stdout?: string }) => + ({ + exec: vi.fn(async (cmd: string) => impl(cmd)), + }) as unknown as ExecutionSession; + +describe('resolveDockerSocketPath', () => { + it('returns the detected Docker socket path', async () => { + const session = mockExec(cmd => { + expect(cmd).toContain('/var/run/docker.sock'); + return { exitCode: 0, stdout: '/var/run/docker.sock' }; + }); + expect(await resolveDockerSocketPath(session)).toBe('/var/run/docker.sock'); + }); + + it('handles rootless socket paths', async () => { + const session = mockExec(() => ({ exitCode: 0, stdout: '/run/user/1000/docker.sock' })); + expect(await resolveDockerSocketPath(session)).toBe('/run/user/1000/docker.sock'); + }); + + it('falls back to /var/run/docker.sock on a non-zero exit code', async () => { + const session = mockExec(() => ({ exitCode: 1, stdout: '' })); + expect(await resolveDockerSocketPath(session)).toBe('/var/run/docker.sock'); + }); + + it('falls back to /var/run/docker.sock on empty output', async () => { + const session = mockExec(() => ({ exitCode: 0, stdout: '' })); + expect(await resolveDockerSocketPath(session)).toBe('/var/run/docker.sock'); + }); + + it('falls back to /var/run/docker.sock when exec throws', async () => { + const session = { + exec: vi.fn(() => Promise.reject(new Error('sandbox unreachable'))), + } as unknown as ExecutionSession; + expect(await resolveDockerSocketPath(session)).toBe('/var/run/docker.sock'); + }); + + it('shells out on every call (no cross-call memoisation)', async () => { + const exec = vi.fn(async () => ({ exitCode: 0, stdout: '/var/run/docker.sock' })); + const session = { exec } as unknown as ExecutionSession; + await resolveDockerSocketPath(session); + await resolveDockerSocketPath(session); + await resolveDockerSocketPath(session); + expect(exec).toHaveBeenCalledTimes(3); + }); +}); + +describe('dockerSocketEnvParts', () => { + it('emits DOCKER_HOST for the given socket path', () => { + expect(dockerSocketEnvParts('/var/run/docker.sock')).toEqual([ + 'DOCKER_HOST=unix:///var/run/docker.sock', + ]); + }); + + it('parameterises the socket path', () => { + expect(dockerSocketEnvParts('/run/user/1000/docker.sock')).toEqual([ + 'DOCKER_HOST=unix:///run/user/1000/docker.sock', + ]); + }); +}); + +describe('dockerSocketEnv', () => { + it('returns a record with DOCKER_HOST for the given socket path', () => { + expect(dockerSocketEnv('/var/run/docker.sock')).toEqual({ + DOCKER_HOST: 'unix:///var/run/docker.sock', + }); + }); + + it('parameterises the socket path', () => { + expect(dockerSocketEnv('/run/user/1000/docker.sock')).toEqual({ + DOCKER_HOST: 'unix:///run/user/1000/docker.sock', + }); + }); +}); diff --git a/services/cloud-agent-next/src/kilo/sandbox-runtime.ts b/services/cloud-agent-next/src/kilo/sandbox-runtime.ts new file mode 100644 index 0000000000..1434b907f9 --- /dev/null +++ b/services/cloud-agent-next/src/kilo/sandbox-runtime.ts @@ -0,0 +1,72 @@ +/** + * Sandbox runtime helpers shared by the wrapper client and the devcontainer + * orchestrator. These deal with inspecting the outer sandbox image (e.g. + * docker:dind-rootless) so we can talk to its dockerd or pass the right env + * vars into a child process. + */ + +import type { ExecutionSession } from '../types.js'; + +const DEFAULT_DOCKER_SOCKET = '/var/run/docker.sock'; + +type Executor = Pick; + +/** Resolve the Docker socket path exposed by the outer sandbox image. */ +export async function resolveDockerSocketPath(executor: Executor): Promise { + try { + const result = await executor.exec( + `if [ -S /var/run/docker.sock ]; then printf /var/run/docker.sock; elif [ -S /run/user/1000/docker.sock ]; then printf /run/user/1000/docker.sock; fi`, + { timeout: 5_000 } + ); + if (result.exitCode === 0) { + const path = result.stdout?.trim(); + if (path) return path; + } + } catch { + // best-effort — fall through to default + } + + return DEFAULT_DOCKER_SOCKET; +} + +/** Build the env-var fragment that points a child process at dockerd. */ +export function dockerSocketEnvParts(socketPath: string): string[] { + return [`DOCKER_HOST=unix://${socketPath}`]; +} + +/** Build the env-var record that points a child process at dockerd. */ +export function dockerSocketEnv(socketPath: string): Record { + return { + DOCKER_HOST: `unix://${socketPath}`, + }; +} + +/** + * Poll `docker version` inside the sandbox until dockerd is reachable. + * + * The DIND boot script (`Dockerfile.dind`) backgrounds dockerd and waits for + * readiness itself, but the /sandbox HTTP API answers exec requests before + * that script finishes — so the first docker-dependent command (e.g. + * `devcontainer up`) can race the daemon and fail with + * `connect: no such file or directory` on its socket. + */ +export async function waitForDocker( + executor: Executor, + dockerEnv: Record, + opts: { timeoutMs?: number; intervalMs?: number } = {} +): Promise { + const timeoutMs = opts.timeoutMs ?? 30_000; + const intervalMs = opts.intervalMs ?? 500; + const deadline = Date.now() + timeoutMs; + let lastStderr = ''; + while (Date.now() < deadline) { + const result = await executor.exec('docker version --format {{.Server.Version}}', { + env: dockerEnv, + timeout: 5_000, + }); + if (result.exitCode === 0) return; + lastStderr = result.stderr ?? ''; + await new Promise(resolve => setTimeout(resolve, intervalMs)); + } + throw new Error(`dockerd did not become ready within ${timeoutMs}ms: ${lastStderr.trim()}`); +} diff --git a/services/cloud-agent-next/src/kilo/utils.test.ts b/services/cloud-agent-next/src/kilo/utils.test.ts index 4c0e464fa1..987522eb5b 100644 --- a/services/cloud-agent-next/src/kilo/utils.test.ts +++ b/services/cloud-agent-next/src/kilo/utils.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { extractUlid } from './utils.js'; +import { extractUlid, shellQuote } from './utils.js'; describe('extractUlid', () => { it('extracts ulid portion from exc_ id', () => { @@ -7,3 +7,21 @@ describe('extractUlid', () => { expect(extractUlid('exc_abc')).toBe('abc'); }); }); + +describe('shellQuote', () => { + it('wraps a plain string in single quotes', () => { + expect(shellQuote('hello')).toBe("'hello'"); + }); + + it('escapes embedded single quotes', () => { + expect(shellQuote("it's")).toBe("'it'\\''s'"); + }); + + it('handles empty string', () => { + expect(shellQuote('')).toBe("''"); + }); + + it('handles string with multiple single quotes', () => { + expect(shellQuote("a'b'c")).toBe("'a'\\''b'\\''c'"); + }); +}); diff --git a/services/cloud-agent-next/src/kilo/utils.ts b/services/cloud-agent-next/src/kilo/utils.ts index faafece438..81c6ace9bb 100644 --- a/services/cloud-agent-next/src/kilo/utils.ts +++ b/services/cloud-agent-next/src/kilo/utils.ts @@ -6,3 +6,13 @@ import type { ExecutionId } from '../types/ids.js'; export const extractUlid = (id: ExecutionId): string => { return id.replace(/^exc_/, ''); }; + +/** + * Shell-quote a value for safe interpolation into a POSIX sh command line. + * + * Wraps the value in single quotes, escaping any embedded single quotes via + * the standard `'\\''` idiom (end current quote, escaped quote, start new quote). + */ +export function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; +} diff --git a/services/cloud-agent-next/src/kilo/wrapper-client.test.ts b/services/cloud-agent-next/src/kilo/wrapper-client.test.ts index 6b5f5d63db..7b341bb334 100644 --- a/services/cloud-agent-next/src/kilo/wrapper-client.test.ts +++ b/services/cloud-agent-next/src/kilo/wrapper-client.test.ts @@ -910,11 +910,47 @@ describe('WrapperClient', () => { expect(session.startProcess).toHaveBeenCalledWith( expect.stringMatching( - /^WRAPPER_PORT=5000 WORKSPACE_PATH=\/workspace\/test WRAPPER_LOG_PATH=\/tmp\/kilocode-wrapper-test-session-\d+\.log KILO_SESSION_RETRY_LIMIT=5 KILO_CLOUD_AGENT=1 bun run '\.\/wrapper'\\''s folder\/wrapper\.js; touch \/tmp\/pwned' --agent-session test-session --user-id 'test-user'$/ + /^WRAPPER_PORT=5000 WORKSPACE_PATH=\/workspace\/test WRAPPER_LOG_PATH=\/tmp\/kilocode-wrapper-test-session-\d+\.log KILO_SESSION_RETRY_LIMIT=5 KILO_CLOUD_AGENT=1 DOCKER_HOST=unix:\/\/\/var\/run\/docker\.sock bun run '\.\/wrapper'\\''s folder\/wrapper\.js; touch \/tmp\/pwned' --agent-session test-session --user-id 'test-user'$/ ), expect.objectContaining({ cwd: '/workspace' }) ); }); + + it('does not expose Docker socket env when starting inside a devcontainer', async () => { + const session = createMockSession(createCurlError(7, 'Connection refused')); + (session.startProcess as ReturnType).mockResolvedValue({ + id: 'mock-process-id', + waitForPort: vi.fn().mockResolvedValue(undefined), + getLogs: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }), + }); + + const client = new WrapperClient({ session, port: defaultPort }); + + await client.ensureRunning({ + agentSessionId: 'test-session', + userId: 'test-user', + workspacePath: '/workspace/test', + devcontainer: { + containerId: 'container-id', + innerWorkspaceFolder: '/workspaces/test', + workspacePath: '/workspace/test', + agentSessionId: 'test-session', + overrideConfigPath: '/tmp/devcontainer-override-test-session/devcontainer.json', + teardown: vi.fn(), + }, + }); + + const startProcessCall = (session.startProcess as ReturnType).mock.calls[0]; + const command = startProcessCall[0] as string; + expect(command).toContain('devcontainer exec'); + expect(command).toContain( + "--config '/tmp/devcontainer-override-test-session/devcontainer.json'" + ); + expect(command).toContain('WORKSPACE_PATH=/workspaces/test'); + expect(command).toContain('/opt/kilo-cloud/kilocode-wrapper.js'); + expect(command).not.toContain('DOCKER_HOST='); + expect(command).not.toContain('XDG_RUNTIME_DIR='); + }); }); // ------------------------------------------------------------------------- diff --git a/services/cloud-agent-next/src/kilo/wrapper-client.ts b/services/cloud-agent-next/src/kilo/wrapper-client.ts index 9685de88f4..3afce354ed 100644 --- a/services/cloud-agent-next/src/kilo/wrapper-client.ts +++ b/services/cloud-agent-next/src/kilo/wrapper-client.ts @@ -10,7 +10,14 @@ import type { ExecutionSession, SandboxInstance } from '../types.js'; import { logger } from '../logger.js'; import { findWrapperForSession, getWrapperSessionMarker } from './wrapper-manager.js'; import { randomPort } from './ports.js'; +import { + dockerSocketEnv, + dockerSocketEnvParts, + resolveDockerSocketPath, +} from './sandbox-runtime.js'; +import { KILO_AGENT_SESSION_LABEL, type DevContainerHandle } from './devcontainer.js'; import { WRAPPER_VERSION } from '../shared/wrapper-version.js'; +import { shellQuote } from './utils.js'; // --------------------------------------------------------------------------- // Types @@ -38,6 +45,13 @@ export type EnsureRunningOptions = { maxWaitMs?: number; workspacePath: string; sessionId?: string; + /** + * When set, launch the wrapper *inside* the dev container via `devcontainer + * exec` instead of `session.startProcess` on the outer sandbox. The wrapper + * runs from the bind-mounted bundle at `/opt/kilo-cloud/kilocode-wrapper.js` + * and its HTTP port is reached via the publish set up by `devcontainer up`. + */ + devcontainer?: DevContainerHandle; }; export type EnsureWrapperOptions = { @@ -45,6 +59,15 @@ export type EnsureWrapperOptions = { userId: string; workspacePath: string; sessionId?: string; + /** See {@link EnsureRunningOptions.devcontainer}. */ + devcontainer?: DevContainerHandle; + /** + * Force the wrapper to listen on this exact port instead of a random one. + * Used by the devcontainer flow because the port has to be chosen *before* + * `devcontainer up` (the publish mapping is fixed at container create time). + * When set, the per-attempt port-retry loop is skipped. + */ + fixedPort?: number; }; export type WrapperPromptOptions = { @@ -151,8 +174,30 @@ export class WrapperClient { private readonly port: number; private readonly baseUrl: string; - private shellQuote(value: string): string { - return `'${value.replace(/'/g, "'\\''")}'`; + /** + * Wrap a wrapper-start command line so it runs inside the dev container via + * `devcontainer exec --workspace-folder ... --id-label kilo.agentSession=...`. + * + * The inner string (env vars + `bun run …`) is passed to `sh -c` so the + * env-var prefix syntax keeps working unchanged. Double-shell escaping is + * handled by `shellQuote`. + */ + private buildDevContainerExecCommand( + devcontainer: DevContainerHandle, + innerCommand: string + ): string { + return [ + 'devcontainer exec', + `--workspace-folder ${shellQuote(devcontainer.workspacePath)}`, + // --config is required: without it the CLI re-reads the user's + // on-disk devcontainer.json and loses our remoteUser/remoteEnv + // overrides (see DevContainerHandle.overrideConfigPath). + `--config ${shellQuote(devcontainer.overrideConfigPath)}`, + `--id-label ${shellQuote(`${KILO_AGENT_SESSION_LABEL}=${devcontainer.agentSessionId}`)}`, + '--', + 'sh -c', + shellQuote(innerCommand), + ].join(' '); } private async runPreflightChecks(options: { @@ -160,7 +205,7 @@ export class WrapperClient { workspacePath: string; }): Promise { const { wrapperPath, workspacePath } = options; - const quotedWrapperPath = this.shellQuote(wrapperPath); + const quotedWrapperPath = shellQuote(wrapperPath); // Verify bun runtime and wrapper binary before the full start+waitForPort loop. // A fast `bun --version` catches SIGILL (exit 132) on hosts whose CPU lacks @@ -251,11 +296,11 @@ export class WrapperClient { if (body) { // Escape single quotes in JSON - const json = this.shellQuote(JSON.stringify(body)); + const json = shellQuote(JSON.stringify(body)); command += ` -d ${json}`; } - command += ` ${this.shellQuote(url)}`; + command += ` ${shellQuote(url)}`; // Execute curl in the container const result = await this.session.exec(command); @@ -314,6 +359,7 @@ export class WrapperClient { maxWaitMs = 30_000, workspacePath, sessionId, + devcontainer, } = options; // First, try to check health @@ -326,36 +372,66 @@ export class WrapperClient { logger.debug('WrapperClient: wrapper not running, starting...'); } - await this.runPreflightChecks({ wrapperPath, workspacePath }); + if (!devcontainer) { + // Outer-sandbox preflight: bun + wrapper bundle at /usr/local/bin/. + // For the devcontainer flow these checks would have to run inside the + // container (skip for now — failure surfaces clearly via waitForPort). + await this.runPreflightChecks({ wrapperPath, workspacePath }); + } // Start the wrapper process using startProcess so it's trackable via listProcesses() // The command includes a session marker so we can find this wrapper later const sessionMarker = getWrapperSessionMarker(agentSessionId); const wrapperLogPath = `/tmp/kilocode-wrapper-${agentSessionId}-${Date.now()}.log`; + // DOCKER_HOST lets the outer-sandbox wrapper (and anything kilo spawns) + // talk to the sandbox dockerd. Devcontainer sessions intentionally do not + // mount or expose that socket inside the user container. + const dockerSocketPath = await resolveDockerSocketPath(this.session); + const dockerEnvParts = devcontainer ? [] : dockerSocketEnvParts(dockerSocketPath); + const devContainerEnv = devcontainer ? dockerSocketEnv(dockerSocketPath) : undefined; + // When running inside a dev container, the wrapper sees the *inner* + // workspace path (set by `devcontainer up`'s remoteWorkspaceFolder). + const innerWorkspacePath = devcontainer?.innerWorkspaceFolder ?? workspacePath; const envParts = [ `WRAPPER_PORT=${this.port}`, - `WORKSPACE_PATH=${workspacePath}`, + `WORKSPACE_PATH=${innerWorkspacePath}`, `WRAPPER_LOG_PATH=${wrapperLogPath}`, `KILO_SESSION_RETRY_LIMIT=5`, `KILO_CLOUD_AGENT=1`, + ...dockerEnvParts, ]; - const argParts = [`--user-id ${this.shellQuote(userId)}`]; + const argParts = [`--user-id ${shellQuote(userId)}`]; if (sessionId) { - argParts.push(`--session-id ${this.shellQuote(sessionId)}`); + argParts.push(`--session-id ${shellQuote(sessionId)}`); } - const command = `${envParts.join(' ')} bun run ${this.shellQuote(wrapperPath)} ${sessionMarker} ${argParts.join(' ')}`; + // The wrapper bundle lives at `/opt/kilo-cloud/kilocode-wrapper.js` inside + // the dev container (bind-mounted read-only); on the outer sandbox we use + // the caller-provided `wrapperPath` (default `/usr/local/bin/...`). + const effectiveWrapperPath = devcontainer ? '/opt/kilo-cloud/kilocode-wrapper.js' : wrapperPath; + + const innerCommand = `${envParts.join(' ')} bun run ${shellQuote(effectiveWrapperPath)} ${sessionMarker} ${argParts.join(' ')}`; + const command = devcontainer + ? this.buildDevContainerExecCommand(devcontainer, innerCommand) + : innerCommand; + // The outer process cwd just needs to exist — `bun run` immediately + // re-chdirs to WORKSPACE_PATH in main.ts. Use the parent of the workspace + // path either way (the workspace itself may not exist outside the + // devcontainer if the user's `workspaceMount` differs). + const cwd = dirname(workspacePath); logger.debug('WrapperClient: starting wrapper process', { command, port: this.port, + devcontainer: devcontainer ? { containerId: devcontainer.containerId } : undefined, }); let proc: Awaited> | undefined; try { proc = await this.session.startProcess(command, { - cwd: dirname(workspacePath), + cwd, + env: devContainerEnv, }); // Wait for wrapper to become healthy via port check. @@ -497,7 +573,29 @@ export class WrapperClient { .warn('Existing wrapper version mismatch, restarting'); try { - await sandbox.exec(`pkill -f -- '${getWrapperSessionMarker(agentSessionId)}'`); + // The wrapper might be running in a dev container (different PID + // namespace — outer pkill can't see it). For that case kill only + // the inner wrapper process via `devcontainer exec ... -- pkill` + // so the dev container stays alive and the next attempt reuses it + // via its `--id-label`. + const sessionMarker = getWrapperSessionMarker(agentSessionId); + if (options.devcontainer) { + const dc = options.devcontainer; + const innerPkill = `pkill -f -- ${shellQuote(sessionMarker)}`; + await sandbox.exec( + [ + 'devcontainer exec', + `--workspace-folder ${shellQuote(dc.workspacePath)}`, + `--config ${shellQuote(dc.overrideConfigPath)}`, + `--id-label ${shellQuote(`${KILO_AGENT_SESSION_LABEL}=${dc.agentSessionId}`)}`, + '--', + 'sh -c', + shellQuote(innerPkill), + ].join(' ') + ); + } else { + await sandbox.exec(`pkill -f -- ${shellQuote(sessionMarker)}`); + } } catch (error) { logger .withFields({ @@ -514,11 +612,15 @@ export class WrapperClient { } } - // 2. Try starting a new wrapper, retrying with a new random port on failure + // 2. Try starting a new wrapper, retrying with a new random port on failure. + // Port retry only applies when the caller hasn't pinned a port — the + // devcontainer flow has to commit to a port at `devcontainer up` time + // because the publish mapping is fixed at container create. let lastError: Error | undefined; + const maxAttempts = options.fixedPort !== undefined ? 1 : MAX_PORT_ATTEMPTS; - for (let attempt = 0; attempt < MAX_PORT_ATTEMPTS; attempt++) { - const port = randomPort(); + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const port = options.fixedPort ?? randomPort(); logger .withFields({ agentSessionId, port, attempt: attempt + 1 }) .info('Starting new wrapper'); @@ -538,7 +640,7 @@ export class WrapperClient { } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); - if (attempt + 1 < MAX_PORT_ATTEMPTS) { + if (attempt + 1 < maxAttempts) { logger .withFields({ agentSessionId, port, attempt: attempt + 1, error: lastError.message }) .warn('Wrapper startup failed, retrying with different port'); diff --git a/services/cloud-agent-next/src/kilo/wrapper-manager.test.ts b/services/cloud-agent-next/src/kilo/wrapper-manager.test.ts new file mode 100644 index 0000000000..408aa2cb8d --- /dev/null +++ b/services/cloud-agent-next/src/kilo/wrapper-manager.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + extractPublishedWrapperPort, + findWrapperContainerForSession, + isWrapperLiveInProcessesOrContainers, + listWrapperContainers, + stopWrapper, +} from './wrapper-manager.js'; + +const mockExec = (impl: (cmd: string) => { exitCode: number; stdout?: string }) => ({ + exec: vi.fn(async (cmd: string) => impl(cmd)), +}); + +describe('extractPublishedWrapperPort', () => { + it('parses 0.0.0.0:5050->5050/tcp', () => { + expect(extractPublishedWrapperPort('0.0.0.0:5050->5050/tcp')).toBe(5050); + }); + + it('parses 127.0.0.1:5050->5050/tcp', () => { + expect(extractPublishedWrapperPort('127.0.0.1:5050->5050/tcp')).toBe(5050); + }); + + it('returns null when no tcp publish is present', () => { + expect(extractPublishedWrapperPort('')).toBeNull(); + expect(extractPublishedWrapperPort('5050/udp')).toBeNull(); + }); + + it('returns the first valid mapping when multiple are listed', () => { + expect(extractPublishedWrapperPort('0.0.0.0:9000->9000/tcp, 127.0.0.1:5050->5050/tcp')).toBe( + 9000 + ); + }); + + it('ignores IPv6 mappings the docker runtime might emit alongside IPv4', () => { + // We deliberately don't match `[::]:5050->5050/tcp`; an IPv4 binding is + // always present beside it for published ports. + expect(extractPublishedWrapperPort('[::]:5050->5050/tcp, 0.0.0.0:5050->5050/tcp')).toBe(5050); + }); +}); + +describe('listWrapperContainers', () => { + it('returns an empty list when docker ps reports no rows', async () => { + const sandbox = mockExec(() => ({ exitCode: 0, stdout: '' })); + expect(await listWrapperContainers(sandbox)).toEqual([]); + }); + + it('returns an empty list when docker ps fails', async () => { + const sandbox = mockExec(() => ({ exitCode: 1, stdout: '' })); + expect(await listWrapperContainers(sandbox)).toEqual([]); + }); + + it('returns an empty list when docker exec throws (no docker binary)', async () => { + const sandbox = { + exec: vi.fn(() => Promise.reject(new Error('docker: command not found'))), + }; + expect(await listWrapperContainers(sandbox)).toEqual([]); + }); + + it('parses a tab-separated docker ps row into agentSessionId + port', async () => { + const sandbox = mockExec(() => ({ + exitCode: 0, + stdout: 'cont-deadbeef\t0.0.0.0:5050->5050/tcp\tkilo.agentSession=agent_abc\n', + })); + expect(await listWrapperContainers(sandbox)).toEqual([ + { containerId: 'cont-deadbeef', agentSessionId: 'agent_abc', port: 5050 }, + ]); + }); + + it('prefers the wrapper port label over unrelated published ports', async () => { + const sandbox = mockExec(() => ({ + exitCode: 0, + stdout: + 'cont-deadbeef\t0.0.0.0:3000->3000/tcp, 127.0.0.1:5050->5050/tcp\tkilo.agentSession=agent_abc,kilo.wrapperPort=5050\n', + })); + expect(await listWrapperContainers(sandbox)).toEqual([ + { containerId: 'cont-deadbeef', agentSessionId: 'agent_abc', port: 5050 }, + ]); + }); + + it('skips rows missing the agent-session label', async () => { + const sandbox = mockExec(() => ({ + exitCode: 0, + stdout: 'cont-1\t0.0.0.0:5050->5050/tcp\tother.label=xyz\n', + })); + expect(await listWrapperContainers(sandbox)).toEqual([]); + }); + + it('skips rows with no published port', async () => { + const sandbox = mockExec(() => ({ + exitCode: 0, + stdout: 'cont-2\t\tkilo.agentSession=agent_abc\n', + })); + expect(await listWrapperContainers(sandbox)).toEqual([]); + }); + + it('parses multiple rows', async () => { + const sandbox = mockExec(() => ({ + exitCode: 0, + stdout: + 'a\t0.0.0.0:5000->5000/tcp\tkilo.agentSession=agent_a\n' + + 'b\t127.0.0.1:5001->5001/tcp\tkilo.agentSession=agent_b\n', + })); + expect(await listWrapperContainers(sandbox)).toEqual([ + { containerId: 'a', agentSessionId: 'agent_a', port: 5000 }, + { containerId: 'b', agentSessionId: 'agent_b', port: 5001 }, + ]); + }); + + it('finds the agent-session label when other labels precede it', async () => { + const sandbox = mockExec(() => ({ + exitCode: 0, + stdout: 'c\t0.0.0.0:5050->5050/tcp\tk1=v1,kilo.agentSession=agent_xyz,k2=v2\n', + })); + expect(await listWrapperContainers(sandbox)).toEqual([ + { containerId: 'c', agentSessionId: 'agent_xyz', port: 5050 }, + ]); + }); +}); + +describe('findWrapperContainerForSession', () => { + it('returns null when no container matches', async () => { + const sandbox = mockExec(() => ({ exitCode: 0, stdout: '' })); + expect(await findWrapperContainerForSession(sandbox, 'agent_xyz')).toBeNull(); + }); + + it('returns wrapper info when the matching container is alive', async () => { + const sandbox = mockExec(() => ({ + exitCode: 0, + stdout: 'cont-id\t0.0.0.0:5050->5050/tcp\tkilo.agentSession=agent_xyz\n', + })); + const result = await findWrapperContainerForSession(sandbox, 'agent_xyz'); + expect(result).not.toBeNull(); + expect(result?.port).toBe(5050); + expect(result?.process.id).toBe('cont-id'); + expect(result?.process.command).toContain('--agent-session agent_xyz'); + }); + + it('returns null when only a different session has a container', async () => { + const sandbox = mockExec(() => ({ + exitCode: 0, + stdout: 'cont-id\t0.0.0.0:5050->5050/tcp\tkilo.agentSession=agent_other\n', + })); + expect(await findWrapperContainerForSession(sandbox, 'agent_xyz')).toBeNull(); + }); +}); + +describe('stopWrapper', () => { + it('kills only the inner wrapper process when devcontainer metadata is available', async () => { + const sandbox = { + listProcesses: vi.fn(async () => []), + exec: vi + .fn() + .mockResolvedValueOnce({ + exitCode: 0, + stdout: + 'cont-id\t0.0.0.0:5050->5050/tcp\tkilo.agentSession=agent_xyz,kilo.wrapperPort=5050\n', + }) + .mockResolvedValueOnce({ exitCode: 0, stdout: '1000\n' }) + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }), + }; + + await stopWrapper(sandbox as never, 'agent_xyz', { + devcontainer: { workspacePath: '/workspace/repo' }, + }); + + const command = sandbox.exec.mock.calls[2][0] as string; + expect(command).toContain('devcontainer exec'); + expect(command).toContain('--workspace-folder'); + expect(command).toContain('/workspace/repo'); + // --config keeps the CLI applying our remoteUser=root override on exec. + expect(command).toContain("--config '/tmp/devcontainer-override-agent_xyz/devcontainer.json'"); + expect(command).toContain('pkill -f --'); + expect(command).not.toContain('docker kill'); + }); + + it('kills the container when devcontainer metadata is unavailable', async () => { + const sandbox = { + listProcesses: vi.fn(async () => []), + exec: vi + .fn() + .mockResolvedValueOnce({ + exitCode: 0, + stdout: + 'cont-id\t0.0.0.0:5050->5050/tcp\tkilo.agentSession=agent_xyz,kilo.wrapperPort=5050\n', + }) + .mockResolvedValueOnce({ exitCode: 0, stdout: '1000\n' }) + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }), + }; + + await stopWrapper(sandbox as never, 'agent_xyz'); + + expect(sandbox.exec.mock.calls[2][0]).toBe("docker kill 'cont-id'"); + }); +}); + +describe('isWrapperLiveInProcessesOrContainers', () => { + // The Process type from @cloudflare/sandbox has more fields than we exercise + // here (kill, getLogs, etc.); cast through unknown so the unit test stays + // focused on the marker-matching logic. + const baseProc = { + id: 'p1', + command: 'kilocode-wrapper --agent-session agent_xyz WRAPPER_PORT=5000', + status: 'running' as const, + } as unknown as Parameters[0][number]; + + it('returns true on a process-list match', () => { + expect(isWrapperLiveInProcessesOrContainers([baseProc], [], 'agent_xyz')).toBe(true); + }); + + it('returns true on a docker-label match', () => { + expect( + isWrapperLiveInProcessesOrContainers( + [], + [{ containerId: 'c', agentSessionId: 'agent_xyz', port: 5050 }], + 'agent_xyz' + ) + ).toBe(true); + }); + + it('returns false when neither has a hit', () => { + expect( + isWrapperLiveInProcessesOrContainers( + [], + [{ containerId: 'c', agentSessionId: 'agent_other', port: 5050 }], + 'agent_xyz' + ) + ).toBe(false); + }); +}); diff --git a/services/cloud-agent-next/src/kilo/wrapper-manager.ts b/services/cloud-agent-next/src/kilo/wrapper-manager.ts index f7e423ca26..dd5f704675 100644 --- a/services/cloud-agent-next/src/kilo/wrapper-manager.ts +++ b/services/cloud-agent-next/src/kilo/wrapper-manager.ts @@ -10,6 +10,13 @@ import type { SandboxInstance } from '../types.js'; import { logger } from '../logger.js'; +import { + getDevContainerOverridePath, + KILO_AGENT_SESSION_LABEL, + KILO_WRAPPER_PORT_LABEL, +} from './devcontainer.js'; +import { dockerSocketEnv, resolveDockerSocketPath } from './sandbox-runtime.js'; +import { shellQuote } from './utils.js'; // Re-export Process type from sandbox for consumers type Process = Awaited>[number]; @@ -19,10 +26,16 @@ const KILO_WRAPPER_SESSION_FLAG = '--agent-session'; /** * Information about a running wrapper. + * + * `kind` distinguishes between the two locations a wrapper can run: + * - `'process'` — directly on the outer sandbox; killable via `pkill -f`. + * - `'container'` — inside a dev container; killable via `docker kill ` + * where `` is `process.id` (the docker container ID). */ export type WrapperInfo = { port: number; process: Process; + kind: 'process' | 'container'; }; /** @@ -85,7 +98,7 @@ export function findWrapperForSessionInProcesses( logger .withFields({ sessionId, port, processId: proc.id, status }) .debug('Found existing wrapper for session'); - return { port, process: proc }; + return { port, process: proc, kind: 'process' }; } } } @@ -96,7 +109,12 @@ export function findWrapperForSessionInProcesses( /** * Find an existing wrapper for the given session. - * Scans listProcesses() for a command containing "--agent-session {sessionId}". + * + * Checks two places, in order: + * 1. `sandbox.listProcesses()` — wrapper running directly on the outer + * sandbox (the non-devcontainer flow). + * 2. `docker ps --filter label=kilo.agentSession=` — wrapper running + * inside a dev container, with its port published to the outer loopback. * * @param sandbox - The sandbox instance to search in * @param sessionId - The cloud-agent session ID to find @@ -107,7 +125,156 @@ export async function findWrapperForSession( sessionId: string ): Promise { const processes = await sandbox.listProcesses(); - return findWrapperForSessionInProcesses(processes, sessionId); + const fromProcesses = findWrapperForSessionInProcesses(processes, sessionId); + if (fromProcesses) return fromProcesses; + + return findWrapperContainerForSession(sandbox, sessionId); +} + +// --------------------------------------------------------------------------- +// Docker-label discovery (devcontainer flow) +// --------------------------------------------------------------------------- + +/** + * `docker ps --format` rows for wrapper containers tagged with + * `kilo.agentSession=`. The published port we want is buried in the + * `Ports` column (`0.0.0.0:5xxx->5xxx/tcp` or `127.0.0.1:5xxx->5xxx/tcp`). + */ +type LabeledWrapperRow = { + containerId: string; + agentSessionId: string; + port: number; +}; + +/** Minimal exec surface — both `SandboxInstance` and `ExecutionSession` satisfy this. */ +type DockerExecutor = { + exec(command: string): Promise<{ exitCode: number; stdout?: string; stderr?: string }>; +}; + +/** + * Extract the published wrapper port from a `docker ps` `Ports` field. + * Tolerates either `0.0.0.0:PORT->PORT/tcp` or `127.0.0.1:PORT->PORT/tcp`, + * and ignores any non-tcp / IPv6 mappings the runtime might emit. + */ +export function extractPublishedWrapperPort(portsField: string): number | null { + // Iterate every "ip:port->port/tcp" mapping; take the first valid one. + const re = /(?:0\.0\.0\.0|127\.0\.0\.1):(\d+)->\d+\/tcp/g; + let match: RegExpExecArray | null; + while ((match = re.exec(portsField)) !== null) { + const port = parseInt(match[1], 10); + if (!Number.isNaN(port) && port > 0 && port < 65536) { + return port; + } + } + return null; +} + +/** + * List all wrapper containers in the outer sandbox (one per active dev container). + * + * Uses `\\t` as a column separator so the `Ports` field — which can contain + * spaces and arrows — survives intact. Each label key/value pair is emitted as + * `Labels=k1=v1,k2=v2` so we can pull `kilo.agentSession` and the wrapper port. + */ +export async function listWrapperContainers( + executor: DockerExecutor +): Promise { + const cmd = `docker ps --filter label=${KILO_AGENT_SESSION_LABEL} --format '{{.ID}}\\t{{.Ports}}\\t{{.Labels}}'`; + let result: { exitCode: number; stdout?: string; stderr?: string } | undefined; + try { + result = await executor.exec(cmd); + } catch (error) { + logger + .withFields({ error: error instanceof Error ? error.message : String(error) }) + .debug('docker ps for wrapper containers failed'); + return []; + } + // Defensive: a missing/undefined response (or non-zero exit) means docker + // isn't reachable on this image — fall through and let process-list lookup + // (or absence of wrapper) drive the decision. + if (!result || result.exitCode !== 0) return []; + + const rows: LabeledWrapperRow[] = []; + for (const line of (result.stdout ?? '').split('\n')) { + const trimmed = line.trim(); + if (!trimmed) continue; + const [containerId, ports, labels] = trimmed.split('\t'); + if (!containerId || !labels) continue; + const agentSessionId = extractLabelValue(labels, KILO_AGENT_SESSION_LABEL); + if (!agentSessionId) continue; + const port = + extractPublishedWrapperPortFromLabel(labels) ?? extractPublishedWrapperPort(ports ?? ''); + if (port === null) continue; + rows.push({ containerId, agentSessionId, port }); + } + return rows; +} + +function extractLabelValue(labelsField: string, labelKey: string): string | null { + // labelsField looks like "k1=v1,k2=v2,kilo.agentSession=". Split on + // commas (a label value can't contain a comma), then look for the key. + for (const kv of labelsField.split(',')) { + const idx = kv.indexOf('='); + if (idx === -1) continue; + const key = kv.slice(0, idx).trim(); + if (key !== labelKey) continue; + const value = kv.slice(idx + 1).trim(); + return value || null; + } + return null; +} + +function extractPublishedWrapperPortFromLabel(labelsField: string): number | null { + const value = extractLabelValue(labelsField, KILO_WRAPPER_PORT_LABEL); + if (!value) return null; + const port = parseInt(value, 10); + return !Number.isNaN(port) && port > 0 && port < 65536 ? port : null; +} + +/** + * Find a wrapper container by `kilo.agentSession` label. Returns null if no + * matching container is running. The returned `process` field is synthesised + * from the docker row so existing callers can keep using a single `WrapperInfo` + * shape — `id` is the container ID, `command` carries the agent-session marker + * for diagnostics. + */ +export async function findWrapperContainerForSession( + executor: DockerExecutor, + sessionId: string +): Promise { + const containers = await listWrapperContainers(executor); + const match = containers.find(c => c.agentSessionId === sessionId); + if (!match) return null; + + // Synthesise a Process-shaped record so existing call sites that read + // `proc.id` / `proc.command` still work. + const synthetic: Process = { + id: match.containerId, + command: `[devcontainer] ${getWrapperSessionMarker(sessionId)} WRAPPER_PORT=${match.port}`, + status: 'running', + // The Process type may have additional optional fields (start time, etc.); + // we don't have those values for a docker container, so leave them off. + } as Process; + + logger + .withFields({ sessionId, port: match.port, containerId: match.containerId }) + .debug('Found existing wrapper container for session'); + + return { port: match.port, process: synthetic, kind: 'container' }; +} + +/** + * Convenience helper for stale-workspace cleanup: returns true when an + * agent-session marker is present *anywhere* — outer process list or + * docker-label-tagged container. + */ +export function isWrapperLiveInProcessesOrContainers( + processes: Process[], + containers: LabeledWrapperRow[], + sessionId: string +): boolean { + if (findWrapperForSessionInProcesses(processes, sessionId)) return true; + return containers.some(c => c.agentSessionId === sessionId); } /** @@ -124,23 +291,58 @@ export function getWrapperSessionMarker(sessionId: string): string { * @param sandbox - The sandbox instance to search in * @param sessionId - The cloud-agent session ID */ -export async function stopWrapper(sandbox: SandboxInstance, sessionId: string): Promise { +export async function stopWrapper( + sandbox: SandboxInstance, + sessionId: string, + options?: { devcontainer?: { workspacePath: string } } +): Promise { const existing = await findWrapperForSession(sandbox, sessionId); if (!existing) { logger.withFields({ sessionId }).debug('No wrapper found to stop'); return; } - const { process: proc, port } = existing; - logger.withFields({ sessionId, port, processId: proc.id }).info('Stopping wrapper'); - const sessionMarker = getWrapperSessionMarker(sessionId); + const { process: proc, port, kind } = existing; + logger.withFields({ sessionId, port, processId: proc.id, kind }).info('Stopping wrapper'); try { - await sandbox.exec(`pkill -f -- '${sessionMarker}'`); - logger.withFields({ sessionId, port }).info('Wrapper stopped'); + if (kind === 'container') { + const sessionMarker = getWrapperSessionMarker(sessionId); + const dockerEnv = dockerSocketEnv(await resolveDockerSocketPath(sandbox)); + if (options?.devcontainer) { + // The wrapper is inside a dev container — outer pkill can't see it. + // Prefer killing just the wrapper process so follow-up executions keep + // using the same devcontainer instead of falling back to the outer image. + // `--config` is required so the CLI keeps applying our remoteUser/ + // remoteEnv overrides; the path is reconstructed from sessionId + // since the override is written deterministically in + // `bringUpDevContainer`. + await sandbox.exec( + [ + 'devcontainer exec', + `--workspace-folder ${shellQuote(options.devcontainer.workspacePath)}`, + `--config ${shellQuote(getDevContainerOverridePath(sessionId))}`, + `--id-label ${shellQuote(`${KILO_AGENT_SESSION_LABEL}=${sessionId}`)}`, + '--', + 'sh -c', + shellQuote(`pkill -f -- ${shellQuote(sessionMarker)}`), + ].join(' '), + { env: dockerEnv } + ); + } else { + // No devcontainer metadata is available (e.g. older sessions). Kill the + // container as a last-resort cleanup rather than leaving it leaked. + await sandbox.exec(`docker kill ${shellQuote(proc.id)}`, { env: dockerEnv }); + } + } else { + const sessionMarker = getWrapperSessionMarker(sessionId); + await sandbox.exec(`pkill -f -- '${sessionMarker}'`); + } + logger.withFields({ sessionId, port, kind }).info('Wrapper stopped'); } catch (error) { logger .withFields({ sessionId, port, + kind, error: error instanceof Error ? error.message : String(error), }) .warn('Error stopping wrapper'); diff --git a/services/cloud-agent-next/src/persistence/CloudAgentSession.ts b/services/cloud-agent-next/src/persistence/CloudAgentSession.ts index 0db98ec77e..f0ba2a7798 100644 --- a/services/cloud-agent-next/src/persistence/CloudAgentSession.ts +++ b/services/cloud-agent-next/src/persistence/CloudAgentSession.ts @@ -884,6 +884,7 @@ export class CloudAgentSession extends DurableObject { sessionHome?: string; branchName?: string; sandboxId?: SandboxId; + devcontainer?: CloudAgentSessionState['devcontainer']; }): Promise { await this.requireSessionId(input.sessionId as SessionId); const existing = await this.ctx.storage.get('metadata'); @@ -1102,6 +1103,7 @@ export class CloudAgentSession extends DurableObject { workspacePath: result.workspacePath, sessionHome: result.sessionHome, branchName: result.branchName, + devcontainer: result.devcontainer, sandboxId: result.sandboxId, initialMessageId: input.initialMessageId, }); @@ -1688,7 +1690,11 @@ export class CloudAgentSession extends DurableObject { .withFields({ sessionId: this.sessionId, sandboxId }) .debug('Starting stopKiloServer RPC'); - await stopWrapper(sandbox, metadata.sessionId); + await stopWrapper(sandbox, metadata.sessionId, { + devcontainer: metadata.devcontainer + ? { workspacePath: metadata.devcontainer.workspacePath } + : undefined, + }); logger .withFields({ sessionId: this.sessionId, sandboxId, rpcElapsedMs: Date.now() - rpcStart }) @@ -2163,6 +2169,7 @@ export class CloudAgentSession extends DurableObject { branchName: params.existingMetadata.branchName ?? '', sandboxId: params.existingMetadata.sandboxId, sessionHome: params.existingMetadata.sessionHome, + devcontainer: params.existingMetadata.devcontainer, upstreamBranch: params.existingMetadata.upstreamBranch, appendSystemPrompt: params.existingMetadata.appendSystemPrompt, githubRepo: params.existingMetadata.githubRepo, @@ -2191,6 +2198,7 @@ export class CloudAgentSession extends DurableObject { branchName: params.existingMetadata.branchName ?? '', sandboxId: params.existingMetadata.sandboxId, sessionHome: params.existingMetadata.sessionHome, + devcontainer: params.existingMetadata.devcontainer, upstreamBranch: params.existingMetadata.upstreamBranch, appendSystemPrompt: params.existingMetadata.appendSystemPrompt, githubRepo: params.existingMetadata.githubRepo, diff --git a/services/cloud-agent-next/src/persistence/async-preparation.ts b/services/cloud-agent-next/src/persistence/async-preparation.ts index 6ce5400489..1d6e7353bb 100644 --- a/services/cloud-agent-next/src/persistence/async-preparation.ts +++ b/services/cloud-agent-next/src/persistence/async-preparation.ts @@ -21,18 +21,74 @@ import { writeAuthFile, } from '../session-service.js'; import { WrapperClient } from '../kilo/wrapper-client.js'; +import { + bringUpDevContainer, + detectDevContainer, + KILO_AGENT_SESSION_LABEL, + KILO_CLI_VERSION, + type DevContainerHandle, +} from '../kilo/devcontainer.js'; +import { findWrapperContainerForSession } from '../kilo/wrapper-manager.js'; +import { randomPort } from '../kilo/ports.js'; +import { dockerSocketEnv, resolveDockerSocketPath } from '../kilo/sandbox-runtime.js'; +import { shellQuote } from '../kilo/utils.js'; import type { PreparingStep } from '../shared/protocol.js'; import type { PreparationInput } from './schemas.js'; import type { Env as WorkerEnv, SandboxId, SessionId as AgentSessionId } from '../types.js'; type EmitProgress = (step: PreparingStep, message: string) => void; +/** + * Build the `kilo-restore-session.js` invocation, wrapping it in + * `devcontainer exec` when a dev container is in play. Both branches end up + * running the same bun-bundled script — only the entrypoint and cwd differ. + */ +function buildRestoreCommand(opts: { + kiloSessionId: string; + importFilePath: string; + runtimeWorkspacePath: string; + devContainer: DevContainerHandle | undefined; +}): string { + const { kiloSessionId, importFilePath, runtimeWorkspacePath, devContainer } = opts; + const innerCmd = [ + `bun`, + devContainer + ? '/opt/kilo-cloud/kilo-restore-session.js' + : '/usr/local/bin/kilo-restore-session.js', + `--file ${shellQuote(importFilePath)}`, + shellQuote(kiloSessionId), + shellQuote(runtimeWorkspacePath), + ].join(' '); + + if (!devContainer) return innerCmd; + + return [ + 'devcontainer exec', + `--workspace-folder ${shellQuote(devContainer.workspacePath)}`, + // Required: without --config the CLI re-reads the user's on-disk + // devcontainer.json and our remoteUser=root override is lost, so kilo + // import runs as the user's remoteUser (typically vscode) and fails + // EACCES writing into the bind-mounted sessionHome. + `--config ${shellQuote(devContainer.overrideConfigPath)}`, + `--id-label ${shellQuote(`${KILO_AGENT_SESSION_LABEL}=${devContainer.agentSessionId}`)}`, + '--', + 'sh -c', + shellQuote(innerCmd), + ].join(' '); +} + /** Result returned by executePreparationSteps on success. */ export type PreparationStepsResult = { sandboxId: SandboxId; workspacePath: string; sessionHome: string; branchName: string; + devcontainer?: { + workspacePath: string; + innerWorkspaceFolder: string; + wrapperPort: number; + configPath: string; + }; kiloSessionId: string; resolvedInstallationId: string | undefined; resolvedGithubAppType: 'standard' | 'lite' | undefined; @@ -188,16 +244,72 @@ export async function executePreparationSteps( emitProgress('branch', 'Setting up branch…'); await manageBranch(session, workspacePath, branchName, !!input.upstreamBranch); - // 6. Setup commands + // Pre-resolve docker socket env — devcontainer/docker CLIs need DOCKER_HOST + // pointing at the sandbox dockerd socket. Resolving once here avoids + // redundant execs in every helper that shells out to docker. + const dockerEnv = dockerSocketEnv(await resolveDockerSocketPath(session)); + + // 5b. Dev container detection + bring-up. + // + // When the repo ships a `.devcontainer/`, we run kilo + the wrapper inside + // it so the agent sees the project's expected toolchain. Project setup + // (npm install etc.) is the dev container's responsibility — we skip + // `runSetupCommands` for the same reason. A failed `devcontainer up` is + // fatal: the toolchain mismatch downstream produces confusing failures. + let devContainerHandle: DevContainerHandle | undefined; + let wrapperPort: number | undefined; + const detected = await detectDevContainer(session, workspacePath); + if (detected) { + emitProgress('devcontainer_setup', `Building dev container (${detected.configPath})…`); + // If a dev container is already running for this session (e.g. prep is + // re-running after a transient failure), reuse its published port — + // Docker can't change the publish mapping of a live container, so a + // fresh `randomPort()` would mismatch the actual port. + const existingContainer = await findWrapperContainerForSession(sandbox, input.sessionId); + wrapperPort = existingContainer?.port ?? randomPort(); + try { + devContainerHandle = await bringUpDevContainer(session, { + workspacePath, + sessionHome, + agentSessionId: input.sessionId, + wrapperPort, + kiloCliVersion: KILO_CLI_VERSION, + configPath: detected.configPath, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger + .withFields({ sessionId: input.sessionId, configPath: detected.configPath }) + .error(`devcontainer up failed: ${message}`); + emitProgress('failed', `Dev container build failed: ${message}`); + return undefined; + } + } + + // 6. Setup commands — skipped when a devcontainer is in play (the user's + // `postCreateCommand` is the right hook for project setup, and running + // setup on the outer sandbox would use the wrong toolchain). if (input.setupCommands && input.setupCommands.length > 0) { - emitProgress('setup_commands', 'Running setup commands…'); - await runSetupCommands(session, context, input.setupCommands, true); + if (devContainerHandle) { + emitProgress( + 'setup_commands', + 'Skipped — devcontainer postCreateCommand will handle project setup' + ); + } else { + emitProgress('setup_commands', 'Running setup commands…'); + await runSetupCommands(session, context, input.setupCommands, true); + } } // 7. Write auth file await writeAuthFile(sandbox, sessionHome, input.authToken); - // 8. Import pre-generated session into CLI's SQLite so the wrapper picks it up + // 8. Import pre-generated session into CLI's SQLite so the wrapper picks it up. + // + // When a dev container is in play the import must run *inside* it: the + // restore script does `Bun.spawn(['kilo', 'import', ...], { cwd })`, and the + // cwd has to exist where the script runs. We also want the session record's + // path to match what the runtime kilo (also inside) will see. if (input.kiloSessionId) { emitProgress('kilo_session', 'Importing session…'); const now = Date.now(); @@ -214,15 +326,29 @@ export async function executePreparationSteps( }, messages: [], }); - const importFilePath = `/tmp/kilo-empty-session-${input.kiloSessionId}.json`; + // When a devcontainer is in play, write the import JSON under the + // sessionHome bind mount so it's visible inside; otherwise /tmp on the + // outer sandbox is fine. + const importFilePath = devContainerHandle + ? `${sessionHome}/tmp/kilo-empty-session-${input.kiloSessionId}.json` + : `/tmp/kilo-empty-session-${input.kiloSessionId}.json`; + if (devContainerHandle) { + await session.exec(`mkdir -p ${shellQuote(`${sessionHome}/tmp`)}`); + } await sandbox.writeFile(importFilePath, minimalSessionJson); - const escapedFile = importFilePath.replaceAll("'", "'\\''"); - const escapedId = input.kiloSessionId.replaceAll("'", "'\\''"); - const escapedWorkspace = workspacePath.replaceAll("'", "'\\''"); - const restoreResult = await session.exec( - `bun /usr/local/bin/kilo-restore-session.js --file '${escapedFile}' '${escapedId}' '${escapedWorkspace}'`, - { cwd: dirname(workspacePath) } - ); + + const restoreCommand = buildRestoreCommand({ + kiloSessionId: input.kiloSessionId, + importFilePath, + // Inside a dev container the workspace lives at remoteWorkspaceFolder; + // on the outer sandbox kilo runs in the host workspace path. + runtimeWorkspacePath: devContainerHandle?.innerWorkspaceFolder ?? workspacePath, + devContainer: devContainerHandle, + }); + const restoreResult = await session.exec(restoreCommand, { + cwd: dirname(workspacePath), + env: devContainerHandle ? dockerEnv : undefined, + }); if (restoreResult.exitCode !== 0) { const stdout = restoreResult.stdout?.trim() ?? ''; logger @@ -240,6 +366,8 @@ export async function executePreparationSteps( userId: input.userId, workspacePath, sessionId: input.kiloSessionId, + devcontainer: devContainerHandle, + fixedPort: wrapperPort, }); return { @@ -247,6 +375,15 @@ export async function executePreparationSteps( workspacePath, sessionHome, branchName, + devcontainer: + devContainerHandle && wrapperPort !== undefined + ? { + workspacePath: devContainerHandle.workspacePath, + innerWorkspaceFolder: devContainerHandle.innerWorkspaceFolder, + wrapperPort, + configPath: detected!.configPath, + } + : undefined, kiloSessionId: input.kiloSessionId ?? wrapperSessionId, resolvedInstallationId, resolvedGithubAppType, diff --git a/services/cloud-agent-next/src/persistence/schemas.ts b/services/cloud-agent-next/src/persistence/schemas.ts index 6862dc56a6..aa400045d7 100644 --- a/services/cloud-agent-next/src/persistence/schemas.ts +++ b/services/cloud-agent-next/src/persistence/schemas.ts @@ -198,6 +198,14 @@ export const MetadataSchema = z.object({ ) .transform(s => s as SandboxId) .optional(), + devcontainer: z + .object({ + workspacePath: z.string(), + innerWorkspaceFolder: z.string(), + wrapperPort: z.number().int().min(1).max(65535), + configPath: z.string(), + }) + .optional(), // Initial message ID for correlation initialMessageId: z.string().startsWith('msg_').length(30).optional(), diff --git a/services/cloud-agent-next/src/persistence/types.ts b/services/cloud-agent-next/src/persistence/types.ts index 550c867148..b600f50882 100644 --- a/services/cloud-agent-next/src/persistence/types.ts +++ b/services/cloud-agent-next/src/persistence/types.ts @@ -135,6 +135,13 @@ export type CloudAgentSessionState = { branchName?: string; /** Sandbox ID where the session runs */ sandboxId?: SandboxId; + /** Devcontainer metadata needed to re-enter the same runtime on follow-up executions. */ + devcontainer?: { + workspacePath: string; + innerWorkspaceFolder: string; + wrapperPort: number; + configPath: string; + }; // Initial message ID for correlation initialMessageId?: string; diff --git a/services/cloud-agent-next/src/shared/protocol.ts b/services/cloud-agent-next/src/shared/protocol.ts index 95f95eb4bf..835e1e5f05 100644 --- a/services/cloud-agent-next/src/shared/protocol.ts +++ b/services/cloud-agent-next/src/shared/protocol.ts @@ -94,6 +94,7 @@ export type PreparingStep = | 'workspace_setup' | 'cloning' | 'branch' + | 'devcontainer_setup' | 'setup_commands' | 'kilo_server' | 'kilo_session' diff --git a/services/cloud-agent-next/src/workspace.test.ts b/services/cloud-agent-next/src/workspace.test.ts index 42de83d7ba..27816cf7bd 100644 --- a/services/cloud-agent-next/src/workspace.test.ts +++ b/services/cloud-agent-next/src/workspace.test.ts @@ -655,6 +655,14 @@ describe('disk space checking', () => { let mockSandboxExec: ReturnType; let mockListProcesses: ReturnType; + /** + * `cleanupStaleWorkspaces` issues a single `docker ps` (via + * `listWrapperContainers`) right after `ls -1 sessions/`. Tests that + * enumerate exec calls in order need to inject a docker-ps response in + * the second slot — empty stdout means "no wrapper containers running". + */ + const dockerPsEmpty = { exitCode: 0, stdout: '', stderr: '' }; + beforeEach(() => { mockSandboxExec = vi.fn(); mockListProcesses = vi.fn(); @@ -672,6 +680,7 @@ describe('disk space checking', () => { stdout: 'agent_stale-1111\nagent_current-aaaa\n', stderr: '', }) // ls sessions/ + .mockResolvedValueOnce(dockerPsEmpty) // docker ps (listWrapperContainers) .mockResolvedValueOnce({ exitCode: 0, stdout: `${oldMtime}\n`, stderr: '' }) // stat agent_stale-1111 .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }) // rm -rf workspace for stale session .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }); // rm -rf home for stale session @@ -684,24 +693,27 @@ describe('disk space checking', () => { expect(mockListProcesses).toHaveBeenCalledTimes(1); const execCalls = mockSandboxExec.mock.calls.map((c: string[]) => c[0]); - expect(execCalls[1]).toContain('stat'); - expect(execCalls[2]).toContain("rm -rf '/workspace/org/user/sessions/agent_stale-1111'"); - expect(execCalls[3]).toContain("rm -rf '/home/agent_stale-1111'"); + expect(execCalls[1]).toContain('docker ps'); + expect(execCalls[2]).toContain('stat'); + expect(execCalls[3]).toContain("rm -rf '/workspace/org/user/sessions/agent_stale-1111'"); + expect(execCalls[4]).toContain("rm -rf '/home/agent_stale-1111'"); }); it('skips the current session directory', async () => { - mockSandboxExec.mockResolvedValueOnce({ - exitCode: 0, - stdout: 'agent_current-aaaa\n', - stderr: '', - }); + mockSandboxExec + .mockResolvedValueOnce({ + exitCode: 0, + stdout: 'agent_current-aaaa\n', + stderr: '', + }) // ls sessions/ + .mockResolvedValueOnce(dockerPsEmpty); // docker ps mockListProcesses.mockResolvedValue([]); await cleanupStaleWorkspaces(fakeSandbox, '/workspace/org/user', 'agent_current-aaaa'); - // Only the ls call — no rm calls - expect(mockSandboxExec).toHaveBeenCalledTimes(1); + // ls + docker ps — no rm calls + expect(mockSandboxExec).toHaveBeenCalledTimes(2); }); it('skips sessions that have a running wrapper', async () => { @@ -712,6 +724,7 @@ describe('disk space checking', () => { stdout: 'agent_active-bbbb\n', stderr: '', }) // ls sessions/ + .mockResolvedValueOnce(dockerPsEmpty) // docker ps .mockResolvedValueOnce({ exitCode: 0, stdout: `${oldMtime}\n`, stderr: '' }); // stat agent_active-bbbb mockListProcesses.mockResolvedValue([ @@ -724,8 +737,8 @@ describe('disk space checking', () => { await cleanupStaleWorkspaces(fakeSandbox, '/workspace/org/user', 'agent_current-aaaa'); - // ls + stat — no rm calls - expect(mockSandboxExec).toHaveBeenCalledTimes(2); + // ls + docker ps + stat — no rm calls + expect(mockSandboxExec).toHaveBeenCalledTimes(3); }); it('returns early when sessions directory does not exist', async () => { @@ -758,6 +771,7 @@ describe('disk space checking', () => { stdout: 'agent_stale-aaaa\nagent_stale-bbbb\n', stderr: '', }) // ls + .mockResolvedValueOnce(dockerPsEmpty) // docker ps .mockRejectedValueOnce(new Error('exec threw during agent_stale-aaaa stat')) // stat throws for first session .mockResolvedValueOnce({ exitCode: 0, stdout: `${oldMtime}\n`, stderr: '' }) // stat agent_stale-bbbb .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }) // rm workspace agent_stale-bbbb @@ -811,6 +825,7 @@ describe('disk space checking', () => { stdout: 'unexpected-dir\n.hidden\nlost+found\nagent_valid-1234\n', stderr: '', }) // ls + .mockResolvedValueOnce(dockerPsEmpty) // docker ps .mockResolvedValueOnce({ exitCode: 0, stdout: `${oldMtime}\n`, stderr: '' }) // stat agent_valid-1234 .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }) // rm workspace agent_valid-1234 .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }); // rm home agent_valid-1234 @@ -836,14 +851,15 @@ describe('disk space checking', () => { stdout: 'agent_recent-1111\n', stderr: '', }) // ls sessions/ + .mockResolvedValueOnce(dockerPsEmpty) // docker ps .mockResolvedValueOnce({ exitCode: 0, stdout: `${recentMtime}\n`, stderr: '' }); // stat mockListProcesses.mockResolvedValue([]); await cleanupStaleWorkspaces(fakeSandbox, '/workspace/org/user', 'agent_current-aaaa'); - // ls + stat only — no rm calls - expect(mockSandboxExec).toHaveBeenCalledTimes(2); + // ls + docker ps + stat only — no rm calls + expect(mockSandboxExec).toHaveBeenCalledTimes(3); }); it('cleans old directories but skips recent ones in the same run', async () => { @@ -855,6 +871,7 @@ describe('disk space checking', () => { stdout: 'agent_old-1111\nagent_recent-2222\n', stderr: '', }) // ls + .mockResolvedValueOnce(dockerPsEmpty) // docker ps .mockResolvedValueOnce({ exitCode: 0, stdout: `${oldMtime}\n`, stderr: '' }) // stat agent_old-1111 .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }) // rm workspace agent_old-1111 .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }) // rm home agent_old-1111 @@ -884,14 +901,15 @@ describe('disk space checking', () => { stdout: 'agent_stale-1111\n', stderr: '', }) // ls + .mockResolvedValueOnce(dockerPsEmpty) // docker ps .mockResolvedValueOnce({ exitCode: 1, stdout: '', stderr: 'stat: cannot stat' }); // stat fails mockListProcesses.mockResolvedValue([]); await cleanupStaleWorkspaces(fakeSandbox, '/workspace/org/user', 'agent_current-aaaa'); - // ls + stat only — no rm calls (directory was skipped) - expect(mockSandboxExec).toHaveBeenCalledTimes(2); + // ls + docker ps + stat only — no rm calls (directory was skipped) + expect(mockSandboxExec).toHaveBeenCalledTimes(3); }); it('skips cleanup when stat returns unparseable output', async () => { @@ -901,14 +919,40 @@ describe('disk space checking', () => { stdout: 'agent_stale-1111\n', stderr: '', }) // ls + .mockResolvedValueOnce(dockerPsEmpty) // docker ps .mockResolvedValueOnce({ exitCode: 0, stdout: 'not-a-number\n', stderr: '' }); // stat returns garbage mockListProcesses.mockResolvedValue([]); await cleanupStaleWorkspaces(fakeSandbox, '/workspace/org/user', 'agent_current-aaaa'); - // ls + stat only — no rm calls (directory was skipped) - expect(mockSandboxExec).toHaveBeenCalledTimes(2); + // ls + docker ps + stat only — no rm calls (directory was skipped) + expect(mockSandboxExec).toHaveBeenCalledTimes(3); + }); + + it('skips sessions with a wrapper running inside a dev container', async () => { + const oldMtime = String(Math.floor(Date.now() / 1000) - STALE_DIR_MIN_AGE_SECONDS - 60); + mockSandboxExec + .mockResolvedValueOnce({ + exitCode: 0, + stdout: 'agent_devc-cccc\n', + stderr: '', + }) // ls sessions/ + .mockResolvedValueOnce({ + exitCode: 0, + stdout: + // \t\t + 'cont-id\t0.0.0.0:5050->5050/tcp\tkilo.agentSession=agent_devc-cccc\n', + stderr: '', + }) // docker ps — wrapper container is alive + .mockResolvedValueOnce({ exitCode: 0, stdout: `${oldMtime}\n`, stderr: '' }); // stat + + mockListProcesses.mockResolvedValue([]); + + await cleanupStaleWorkspaces(fakeSandbox, '/workspace/org/user', 'agent_current-aaaa'); + + // ls + docker ps + stat only — no rm calls (live devcontainer wrapper) + expect(mockSandboxExec).toHaveBeenCalledTimes(3); }); }); @@ -940,6 +984,7 @@ describe('disk space checking', () => { stdout: 'agent_stale-1111\nagent_current-aaaa\n', stderr: '', }) // ls sessions/ (cleanupStaleWorkspaces) + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }) // docker ps (listWrapperContainers) .mockResolvedValueOnce({ exitCode: 0, stdout: `${oldMtime}\n`, stderr: '' }) // stat agent_stale-1111 .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }) // rm workspace .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }); // rm home @@ -952,10 +997,12 @@ describe('disk space checking', () => { expect(mockSandboxExec.mock.calls[0][0]).toContain('df -B1'); // ls was called to find sessions expect(mockSandboxExec.mock.calls[1][0]).toContain('ls -1'); + // docker ps was called to find devcontainer-launched wrappers + expect(mockSandboxExec.mock.calls[2][0]).toContain('docker ps'); // stat was called for the stale session - expect(mockSandboxExec.mock.calls[2][0]).toContain('stat'); + expect(mockSandboxExec.mock.calls[3][0]).toContain('stat'); // stale session was cleaned - expect(mockSandboxExec.mock.calls[3][0]).toContain('agent_stale-1111'); + expect(mockSandboxExec.mock.calls[4][0]).toContain('agent_stale-1111'); }); it('skips cleanup when disk space is adequate', async () => { diff --git a/services/cloud-agent-next/src/workspace.ts b/services/cloud-agent-next/src/workspace.ts index c61a2897ba..c762bda9e1 100644 --- a/services/cloud-agent-next/src/workspace.ts +++ b/services/cloud-agent-next/src/workspace.ts @@ -1,7 +1,10 @@ import type { SandboxInstance, ExecutionSession, SystemSandboxUsageEvent } from './types.js'; import type { ExecResult, ExecOptions } from '@cloudflare/sandbox'; import { logger } from './logger.js'; -import { findWrapperForSessionInProcesses } from './kilo/wrapper-manager.js'; +import { + isWrapperLiveInProcessesOrContainers, + listWrapperContainers, +} from './kilo/wrapper-manager.js'; import { DISK_CHECK_TIMEOUT_MS, FAST_SANDBOX_COMMAND_TIMEOUT_MS, @@ -277,6 +280,10 @@ export async function cleanupStaleWorkspaces( return; } + // Also fetch wrapper containers (devcontainer flow). Best-effort — on the + // non-DIND outer image `docker ps` simply returns empty, which is fine. + const wrapperContainers = await listWrapperContainers(sandbox); + // Get current epoch once so we can age-check directories without re-shelling per candidate const nowSeconds = Math.floor(Date.now() / 1000); @@ -325,8 +332,7 @@ export async function cleanupStaleWorkspaces( continue; } - const wrapperInfo = findWrapperForSessionInProcesses(processes, candidateSessionId); - if (wrapperInfo !== null) { + if (isWrapperLiveInProcessesOrContainers(processes, wrapperContainers, candidateSessionId)) { logger.withFields({ candidateSessionId }).info('Skipping session: wrapper is running'); skipped++; continue; diff --git a/services/cloud-agent-next/worker-configuration.d.ts b/services/cloud-agent-next/worker-configuration.d.ts index 9cceb19433..3093a07ee5 100644 --- a/services/cloud-agent-next/worker-configuration.d.ts +++ b/services/cloud-agent-next/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: edb7ebd5d1ef6ff409168c7d77a89113) +// Generated by Wrangler by running `wrangler types` (hash: fd604ad645220614b978b84ed33d728e) // Runtime types generated with workerd@1.20260312.1 2025-09-15 nodejs_compat declare namespace Cloudflare { interface GlobalProps { diff --git a/services/cloud-agent-next/wrangler.jsonc b/services/cloud-agent-next/wrangler.jsonc index d49cd8d1c0..f41dabfd91 100644 --- a/services/cloud-agent-next/wrangler.jsonc +++ b/services/cloud-agent-next/wrangler.jsonc @@ -147,7 +147,7 @@ }, { "class_name": "SandboxSmall", - "image": "./Dockerfile", + "image": "./Dockerfile.dind", "instance_type": "standard-2", "image_vars": { "KILOCODE_CLI_VERSION": "7.1.23", @@ -286,7 +286,7 @@ }, { "class_name": "SandboxSmall", - "image": "./Dockerfile.dev", + "image": "./Dockerfile.dind", "instance_type": "standard-2", "image_vars": { "KILOCODE_CLI_VERSION": "7.1.23", diff --git a/services/cloud-agent-next/wrapper/src/restore-session.test.ts b/services/cloud-agent-next/wrapper/src/restore-session.test.ts index 4846b38458..54d9569418 100644 --- a/services/cloud-agent-next/wrapper/src/restore-session.test.ts +++ b/services/cloud-agent-next/wrapper/src/restore-session.test.ts @@ -8,8 +8,16 @@ import { restoreSession, extractDiffs } from './restore-session'; // Helpers // --------------------------------------------------------------------------- +// Real session-ingest exports always carry a top-level `info` block with at +// least `id`. The orchestrator's malformed-snapshot guardrail keys off that +// field, so test fixtures must match real shape. +function snapshotInfo(): { id: string; version: string } { + return { id: 'ses_test_fixture', version: '2' }; +} + function makeSnapshot(diffs: Array<{ file: string; after: string; status: string }>): string { return JSON.stringify({ + info: snapshotInfo(), messages: [{ info: { summary: { diffs } } }], }); } @@ -18,6 +26,7 @@ function makeMultiMessageSnapshot( ...messageDiffs: Array> ): string { return JSON.stringify({ + info: snapshotInfo(), messages: messageDiffs.map(diffs => ({ info: { summary: { diffs } } })), }); } @@ -225,7 +234,7 @@ describe('restoreSession', () => { }); it('succeeds with zero diffs when messages array is empty', async () => { - mockFetchOk(JSON.stringify({ messages: [] })); + mockFetchOk(JSON.stringify({ info: snapshotInfo(), messages: [] })); const result = await restoreSession(SESSION_ID, workspace); @@ -238,7 +247,7 @@ describe('restoreSession', () => { }); it('succeeds with zero diffs when messages have no diffs field', async () => { - mockFetchOk(JSON.stringify({ messages: [{ info: {} }] })); + mockFetchOk(JSON.stringify({ info: snapshotInfo(), messages: [{ info: {} }] })); const result = await restoreSession(SESSION_ID, workspace); diff --git a/services/cloud-agent-next/wrapper/src/restore-session.ts b/services/cloud-agent-next/wrapper/src/restore-session.ts index e2ca1c057c..f2f27a77de 100644 --- a/services/cloud-agent-next/wrapper/src/restore-session.ts +++ b/services/cloud-agent-next/wrapper/src/restore-session.ts @@ -54,27 +54,67 @@ const JQ_EXTRACT_DIFFS_FILTER = 'reduce (.messages[]?.info.summary | objects | .diffs[]? // empty) as $d ({}; .[$d.file] = $d) | [.[]]'; /** - * Extract last-write-wins diffs from a snapshot file via a jq subprocess so the - * full snapshot JSON is never loaded into the main process's heap. + * Extract last-write-wins diffs from a snapshot file. Prefers a jq subprocess + * (memory-efficient — the parsed snapshot stays in C-native heap) and falls + * back to bun-native parsing when jq isn't on PATH. The fallback matters for + * the devcontainer flow: the user's image is only required to ship `node` + + * `bun`, so `jq` may be missing. */ export async function extractDiffs(snapshotPath: string): Promise { - const proc = Bun.spawn(['jq', '-c', JQ_EXTRACT_DIFFS_FILTER, snapshotPath], { - stdout: 'pipe', - stderr: 'pipe', - }); - const exitCode = await proc.exited; - if (exitCode !== 0) { + try { + const proc = Bun.spawn(['jq', '-c', JQ_EXTRACT_DIFFS_FILTER, snapshotPath], { + stdout: 'pipe', + stderr: 'pipe', + }); + const exitCode = await proc.exited; + if (exitCode === 0) { + const stdout = await new Response(proc.stdout).text(); + try { + return JSON.parse(stdout) as SnapshotDiff[]; + } catch (err) { + log(`jq output parse failed: ${err instanceof Error ? err.message : String(err)}`); + return null; + } + } const stderr = await new Response(proc.stderr).text(); - log(`jq failed exitCode=${exitCode} stderr=${stderr.trim()}`); - return null; + log(`jq failed exitCode=${exitCode} stderr=${stderr.trim()}; falling back to bun parser`); + } catch (err) { + // `Bun.spawn` rejects with ENOENT when jq isn't installed. + log(`jq not available (${err instanceof Error ? err.message : String(err)}); using bun parser`); } - const stdout = await new Response(proc.stdout).text(); + + return extractDiffsWithBun(snapshotPath); +} + +/** + * In-process fallback for environments without `jq`. Loads the whole snapshot + * into the V8 heap and applies the same last-write-wins dedup the jq filter + * does. Higher peak memory than jq but avoids a hard dependency. + */ +async function extractDiffsWithBun(snapshotPath: string): Promise { + type SnapshotShape = { + messages?: Array<{ + info?: { + summary?: { diffs?: SnapshotDiff[] }; + }; + }>; + }; + let parsed: SnapshotShape; try { - return JSON.parse(stdout) as SnapshotDiff[]; + parsed = (await Bun.file(snapshotPath).json()) as SnapshotShape; } catch (err) { - log(`jq output parse failed: ${err instanceof Error ? err.message : String(err)}`); + log(`bun snapshot parse failed: ${err instanceof Error ? err.message : String(err)}`); return null; } + const dedup = new Map(); + for (const message of parsed.messages ?? []) { + const summary = message?.info?.summary; + if (!summary || typeof summary !== 'object') continue; + for (const diff of summary.diffs ?? []) { + if (diff && typeof diff.file === 'string') dedup.set(diff.file, diff); + } + } + return Array.from(dedup.values()); } // --------------------------------------------------------------------------- @@ -124,6 +164,28 @@ export async function restoreSession( const bytesWritten = await Bun.write(tmpPath, res); log(`snapshot downloaded bytes=${bytesWritten}`); + + // Validate before handing off to `kilo import`: an upstream error + // surface (e.g. a JSON `{"detail":"..."}` body served as 200) crashes + // kilo with a cryptic `undefined is not an object (evaluating 'info2.id')` + // and exit 1. Catch the obvious malformed cases here so the failure + // points at session-ingest instead. + let snapshotInfo: { info?: { id?: unknown } }; + try { + snapshotInfo = (await Bun.file(tmpPath).json()) as { info?: { id?: unknown } }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log(`snapshot is not valid JSON: ${message}`); + return fail(`snapshot is not valid JSON (${bytesWritten} bytes)`, null, 'download'); + } + if (typeof snapshotInfo.info?.id !== 'string') { + log('snapshot missing info.id — likely an error response'); + return fail( + `snapshot missing info.id (${bytesWritten} bytes); session-ingest may have returned an error body`, + null, + 'download' + ); + } } catch (err) { tryUnlink(tmpPath); const message = err instanceof Error ? err.message : String(err); From e8e1b3aa77cc9eba23e7325d748c7eff8b1f3be8 Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Mon, 11 May 2026 15:36:41 +0200 Subject: [PATCH 2/2] fix(cloud-agent-next): fix lint errors in devcontainer merge and async-preparation --- services/cloud-agent-next/src/kilo/devcontainer.ts | 8 ++++---- .../cloud-agent-next/src/persistence/async-preparation.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/services/cloud-agent-next/src/kilo/devcontainer.ts b/services/cloud-agent-next/src/kilo/devcontainer.ts index 6efe2bd66a..4f2d215cbe 100644 --- a/services/cloud-agent-next/src/kilo/devcontainer.ts +++ b/services/cloud-agent-next/src/kilo/devcontainer.ts @@ -393,12 +393,12 @@ export function mergeDevContainerConfig( // `remoteUser` from override wins over base — see buildOverrideConfig for why. ...(typeof override.remoteUser === 'string' ? { remoteUser: override.remoteUser } : {}), mounts: [ - ...(Array.isArray(baseConfig.mounts) ? baseConfig.mounts : []), - ...(Array.isArray(override.mounts) ? override.mounts : []), + ...(Array.isArray(baseConfig.mounts) ? (baseConfig.mounts as string[]) : []), + ...(Array.isArray(override.mounts) ? (override.mounts as string[]) : []), ], runArgs: [ - ...(Array.isArray(baseConfig.runArgs) ? baseConfig.runArgs : []), - ...(Array.isArray(override.runArgs) ? override.runArgs : []), + ...(Array.isArray(baseConfig.runArgs) ? (baseConfig.runArgs as string[]) : []), + ...(Array.isArray(override.runArgs) ? (override.runArgs as string[]) : []), ], remoteEnv: { ...(isRecord(baseConfig.remoteEnv) ? baseConfig.remoteEnv : {}), diff --git a/services/cloud-agent-next/src/persistence/async-preparation.ts b/services/cloud-agent-next/src/persistence/async-preparation.ts index 1d6e7353bb..68589b321e 100644 --- a/services/cloud-agent-next/src/persistence/async-preparation.ts +++ b/services/cloud-agent-next/src/persistence/async-preparation.ts @@ -381,7 +381,7 @@ export async function executePreparationSteps( workspacePath: devContainerHandle.workspacePath, innerWorkspaceFolder: devContainerHandle.innerWorkspaceFolder, wrapperPort, - configPath: detected!.configPath, + configPath: detected?.configPath ?? '', } : undefined, kiloSessionId: input.kiloSessionId ?? wrapperSessionId,