From efb260276269723d946559384b0d2f41bf795b1d Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Wed, 3 Jun 2026 22:40:12 +0200 Subject: [PATCH 1/2] feat(cloud-agent): enable snapshots for web sessions --- .../routers/cloud-agent-next-router.test.ts | 2 +- ...ganization-cloud-agent-next-router.test.ts | 2 +- pnpm-lock.yaml | 10 +-- pnpm-workspace.yaml | 2 + services/cloud-agent-next/Dockerfile | 2 +- services/cloud-agent-next/Dockerfile.dev | 2 +- services/cloud-agent-next/Dockerfile.dind | 2 +- .../src/kilo/devcontainer.test.ts | 35 ++++++++ .../cloud-agent-next/src/kilo/devcontainer.ts | 2 +- .../src/session-service.test.ts | 17 ++++ .../cloud-agent-next/src/session-service.ts | 2 +- .../default-slash-commands.generated.ts | 2 +- .../test/unit/wrapper/kilo-api.test.ts | 84 ++++++++++++++++-- .../test/unit/wrapper/server.test.ts | 86 +++++++++++++++++++ services/cloud-agent-next/wrangler.jsonc | 12 +-- .../cloud-agent-next/wrapper/package.json | 2 +- .../cloud-agent-next/wrapper/src/kilo-api.ts | 20 +++-- .../cloud-agent-next/wrapper/src/server.ts | 8 ++ 18 files changed, 259 insertions(+), 33 deletions(-) diff --git a/apps/web/src/routers/cloud-agent-next-router.test.ts b/apps/web/src/routers/cloud-agent-next-router.test.ts index 47b35e5274..3d04e1a539 100644 --- a/apps/web/src/routers/cloud-agent-next-router.test.ts +++ b/apps/web/src/routers/cloud-agent-next-router.test.ts @@ -209,7 +209,7 @@ describe('cloudAgentNextRouter.prepareSession', () => { }); expect(mockPrepareSession).toHaveBeenCalledWith( - expect.objectContaining({ attachments: images }) + expect.objectContaining({ attachments: images, createdOnPlatform: 'cloud-agent-web' }) ); expect(mockPrepareSession).not.toHaveBeenCalledWith(expect.objectContaining({ images })); }); diff --git a/apps/web/src/routers/organizations/organization-cloud-agent-next-router.test.ts b/apps/web/src/routers/organizations/organization-cloud-agent-next-router.test.ts index d663a9a5e8..6918c242f0 100644 --- a/apps/web/src/routers/organizations/organization-cloud-agent-next-router.test.ts +++ b/apps/web/src/routers/organizations/organization-cloud-agent-next-router.test.ts @@ -233,7 +233,7 @@ describe('organizationCloudAgentNextRouter.prepareSession', () => { }); expect(mockPrepareSession).toHaveBeenCalledWith( - expect.objectContaining({ attachments: images }) + expect.objectContaining({ attachments: images, createdOnPlatform: 'cloud-agent-web' }) ); expect(mockPrepareSession).not.toHaveBeenCalledWith(expect.objectContaining({ images })); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b59c540b3a..c81c82bdfa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1621,8 +1621,8 @@ importers: services/cloud-agent-next/wrapper: dependencies: '@kilocode/sdk': - specifier: 7.3.12 - version: 7.3.12 + specifier: 7.3.21 + version: 7.3.21 devDependencies: '@types/bun': specifier: 1.3.14 @@ -4971,8 +4971,8 @@ packages: '@kilocode/sdk@7.2.52': resolution: {integrity: sha512-j8w6ewvo7dyu/qxjJAg0bcjHGUGGvIZ4F2f5tJnpMwLzPTAu26DJoO/08aoxf1BhfuZLzNS9tA2q+ZPdzPT8Jg==} - '@kilocode/sdk@7.3.12': - resolution: {integrity: sha512-wnodVXM7ThX1nvPMao2fMBUXykoVxzMTP0fKe0FIr+R3xWlK9kXGnqnj8614+e9xgIGHhNAPJ8XQtMpoMJBXNw==} + '@kilocode/sdk@7.3.21': + resolution: {integrity: sha512-sdoV7l+rIzsAF/bSpKn6crmwQ3HGYo+59HbbdpVmGAfTj758N40CDGqPrgyzwOL7GHAcHgl6LlQKPvk+f69Qmw==} '@lexical/clipboard@0.35.0': resolution: {integrity: sha512-ko7xSIIiayvDiqjNDX6fgH9RlcM6r9vrrvJYTcfGVBor5httx16lhIi0QJZ4+RNPvGtTjyFv4bwRmsixRRwImg==} @@ -19893,7 +19893,7 @@ snapshots: dependencies: cross-spawn: 7.0.6 - '@kilocode/sdk@7.3.12': + '@kilocode/sdk@7.3.21': dependencies: cross-spawn: 7.0.6 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ab647390a7..8c7dfd71a2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -59,6 +59,8 @@ minimumReleaseAgeExclude: - '@typescript/native-preview-linux-x64' - '@typescript/native-preview-win32-arm64' - '@typescript/native-preview-win32-x64' + # Kilo SDK releases are owned internally and adopted with pinned runtime validation. + - '@kilocode/sdk' # KiloClaw pins and live-smoke-validates OpenClaw image upgrades before rollout. - openclaw overrides: diff --git a/services/cloud-agent-next/Dockerfile b/services/cloud-agent-next/Dockerfile index 30c2e49bd6..de88c0c63c 100644 --- a/services/cloud-agent-next/Dockerfile +++ b/services/cloud-agent-next/Dockerfile @@ -3,7 +3,7 @@ FROM docker.io/cloudflare/sandbox:0.10.3 # Build arguments for metadata (all optional with defaults) ARG BUILD_DATE="" ARG VCS_REF="" -ARG KILOCODE_CLI_VERSION="7.3.12" +ARG KILOCODE_CLI_VERSION="7.3.21" # Install latest stable git + git-lfs from the git-core PPA, GitHub CLI, and supporting tools. # The default Ubuntu git (2.34.1 on 22.04) is outdated; the git-core PPA ships the latest diff --git a/services/cloud-agent-next/Dockerfile.dev b/services/cloud-agent-next/Dockerfile.dev index 199e8bdf93..667a1fad0a 100644 --- a/services/cloud-agent-next/Dockerfile.dev +++ b/services/cloud-agent-next/Dockerfile.dev @@ -3,7 +3,7 @@ FROM docker.io/cloudflare/sandbox:0.10.3 # Build arguments for metadata (all optional with defaults) ARG BUILD_DATE="" ARG VCS_REF="" -ARG KILOCODE_CLI_VERSION="7.3.12" +ARG KILOCODE_CLI_VERSION="7.3.21" # Build the kilo binary: # cd ~/projects/kilocode-backend/cloud-agent diff --git a/services/cloud-agent-next/Dockerfile.dind b/services/cloud-agent-next/Dockerfile.dind index 7d7f519e05..2ac53953cc 100644 --- a/services/cloud-agent-next/Dockerfile.dind +++ b/services/cloud-agent-next/Dockerfile.dind @@ -9,7 +9,7 @@ USER root # Build arguments for metadata (all optional with defaults) ARG BUILD_DATE="" ARG VCS_REF="" -ARG KILOCODE_CLI_VERSION="7.3.12" +ARG KILOCODE_CLI_VERSION="7.3.21" # Cloudflare Containers run without root privileges, so Docker must run in # rootless mode. The Sandbox SDK server is copied into this image so the diff --git a/services/cloud-agent-next/src/kilo/devcontainer.test.ts b/services/cloud-agent-next/src/kilo/devcontainer.test.ts index f20bab63af..ce08fa00b4 100644 --- a/services/cloud-agent-next/src/kilo/devcontainer.test.ts +++ b/services/cloud-agent-next/src/kilo/devcontainer.test.ts @@ -19,6 +19,7 @@ import { detectDevContainer, getDevContainerOverridePath, KILO_AGENT_SESSION_LABEL, + KILO_CLI_VERSION, KILO_WRAPPER_PORT_LABEL, mergeDevContainerConfig, parseDevContainerConfig, @@ -26,6 +27,7 @@ import { writeMergedOverrideConfig, } from './devcontainer.js'; import type { ExecutionSession } from '../types.js'; +import { DEFAULT_SLASH_COMMANDS_SOURCE } from '../shared/default-slash-commands.generated.js'; const mockSessionExec = (impl: (cmd: string) => { exitCode: number; stdout?: string }) => ({ @@ -55,6 +57,39 @@ describe('sandbox image versions', () => { expect(devDockerfile).toContain(`FROM docker.io/cloudflare/sandbox:${sandboxVersion}`); expect(dindDockerfile).toContain(`ARG SANDBOX_VERSION="${sandboxVersion}"`); }); + + it('keeps the Kilo SDK and CLI pins aligned across sandbox runtimes', () => { + const wrapperPackageJson = JSON.parse( + readFileSync( + fileURLToPath(new URL('../../wrapper/package.json', import.meta.url).href), + 'utf8' + ) + ) as { dependencies: Record }; + const dockerfile = readFileSync( + fileURLToPath(new URL('../../Dockerfile', import.meta.url).href), + 'utf8' + ); + const devDockerfile = readFileSync( + fileURLToPath(new URL('../../Dockerfile.dev', import.meta.url).href), + 'utf8' + ); + const dindDockerfile = readFileSync( + fileURLToPath(new URL('../../Dockerfile.dind', import.meta.url).href), + 'utf8' + ); + const wranglerConfig = readFileSync( + fileURLToPath(new URL('../../wrangler.jsonc', import.meta.url).href), + 'utf8' + ); + const imageVar = `"KILOCODE_CLI_VERSION": "${KILO_CLI_VERSION}"`; + + expect(wrapperPackageJson.dependencies['@kilocode/sdk']).toBe(KILO_CLI_VERSION); + expect(dockerfile).toContain(`ARG KILOCODE_CLI_VERSION="${KILO_CLI_VERSION}"`); + expect(devDockerfile).toContain(`ARG KILOCODE_CLI_VERSION="${KILO_CLI_VERSION}"`); + expect(dindDockerfile).toContain(`ARG KILOCODE_CLI_VERSION="${KILO_CLI_VERSION}"`); + expect(wranglerConfig.split(imageVar)).toHaveLength(7); + expect(DEFAULT_SLASH_COMMANDS_SOURCE).toBe(`kilo@${KILO_CLI_VERSION}`); + }); }); describe('detectDevContainer', () => { diff --git a/services/cloud-agent-next/src/kilo/devcontainer.ts b/services/cloud-agent-next/src/kilo/devcontainer.ts index e1a1fe9d5b..52b4cef7bd 100644 --- a/services/cloud-agent-next/src/kilo/devcontainer.ts +++ b/services/cloud-agent-next/src/kilo/devcontainer.ts @@ -121,7 +121,7 @@ export const KILO_WRAPPER_PORT_LABEL = 'kilo.wrapperPort'; * `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.3.12'; +export const KILO_CLI_VERSION = '7.3.21'; const DEVCONTAINER_RUNTIME_BUN_VERSION = '1.3.14'; const DEVCONTAINER_RUNTIME_BOOTSTRAP_TIMEOUT_MS = 10 * 60 * 1000; diff --git a/services/cloud-agent-next/src/session-service.test.ts b/services/cloud-agent-next/src/session-service.test.ts index a8cf78f6c8..09026d459e 100644 --- a/services/cloud-agent-next/src/session-service.test.ts +++ b/services/cloud-agent-next/src/session-service.test.ts @@ -1196,6 +1196,23 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { }); }); + it.each([ + ['cloud-agent-web', true], + [undefined, false], + ['app-builder', false], + ['code-review', false], + ['slack', false], + ])('sets Kilo snapshots for %s-origin sessions to %s', async (createdOnPlatform, snapshot) => { + const result = await buildPromptWrapperRequests(createMetadata({ createdOnPlatform })); + const kiloConfig = JSON.parse(result.readyRequest.materialized.env.KILO_CONFIG_CONTENT) as { + snapshot?: boolean; + }; + const opencodeConfig = JSON.parse(result.readyRequest.materialized.env.OPENCODE_CONFIG_CONTENT); + + expect(kiloConfig.snapshot).toBe(snapshot); + expect(opencodeConfig).toEqual(kiloConfig); + }); + it('passes canonical document attachments through signed wrapper prompt construction', async () => { const service = new SessionService(); const env = createEnv(); diff --git a/services/cloud-agent-next/src/session-service.ts b/services/cloud-agent-next/src/session-service.ts index 94bd3d3b22..2b0678d4b9 100644 --- a/services/cloud-agent-next/src/session-service.ts +++ b/services/cloud-agent-next/src/session-service.ts @@ -1120,7 +1120,7 @@ export class SessionService { }, }, autoupdate: false, - snapshot: false, + snapshot: createdOnPlatform === 'cloud-agent-web', }; if (mcpServers && Object.keys(mcpServers).length > 0) { const materialized = materializeMcpServers(mcpServers, env.AGENT_ENV_VARS_PRIVATE_KEY); diff --git a/services/cloud-agent-next/src/shared/default-slash-commands.generated.ts b/services/cloud-agent-next/src/shared/default-slash-commands.generated.ts index 6446bdfc80..d2fabd1f89 100644 --- a/services/cloud-agent-next/src/shared/default-slash-commands.generated.ts +++ b/services/cloud-agent-next/src/shared/default-slash-commands.generated.ts @@ -17,7 +17,7 @@ export type SlashCommandInfo = { * * Regenerate with `pnpm --filter cloud-agent-next update-default-slash-commands`. */ -export const DEFAULT_SLASH_COMMANDS_SOURCE = 'kilo@7.3.12'; +export const DEFAULT_SLASH_COMMANDS_SOURCE = 'kilo@7.3.21'; /** * Default slash command catalog used when no live wrapper-reported catalog is diff --git a/services/cloud-agent-next/test/unit/wrapper/kilo-api.test.ts b/services/cloud-agent-next/test/unit/wrapper/kilo-api.test.ts index f1654c1e8a..a77e05fc31 100644 --- a/services/cloud-agent-next/test/unit/wrapper/kilo-api.test.ts +++ b/services/cloud-agent-next/test/unit/wrapper/kilo-api.test.ts @@ -21,12 +21,16 @@ describe('createWrapperKiloClient prompt handoff', () => { }); it('throws when the command SDK response contains an error result', async () => { - const sdkClient = { - session: { - command: vi.fn().mockResolvedValue({ error: { message: 'command rejected' } }), - }, - } as unknown as SDKClient; - const client = createWrapperKiloClient(sdkClient, 'http://127.0.0.1:0', workspacePath); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ message: 'command rejected' }), { + status: 409, + headers: { 'content-type': 'application/json' }, + }) + ) + ); + const client = createWrapperKiloClient(createSdkClient(), 'http://127.0.0.1:0', workspacePath); await expect( client.sendCommand({ sessionId: 'kilo_sess', command: 'compact', messageId: 'msg_command' }) @@ -80,6 +84,74 @@ describe('createWrapperKiloClient prompt handoff', () => { }) ).rejects.toThrow('Async prompt for session kilo_sess_rejected failed: server rejected prompt'); }); + + it('passes snapshot wait policy through async prompt requests', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 })); + vi.stubGlobal('fetch', fetchMock); + const client = createWrapperKiloClient(createSdkClient(), 'http://127.0.0.1:0', workspacePath); + + await client.sendPromptAsync({ + sessionId: 'kilo_sess_wait', + messageId: 'msg_wait', + prompt: 'queue this prompt', + snapshotInitialization: 'wait', + }); + + const request = fetchMock.mock.calls[0]?.[0]; + expect(request).toBeInstanceOf(Request); + await expect((request as Request).clone().json()).resolves.toMatchObject({ + snapshotInitialization: 'wait', + }); + }); + + it('passes snapshot wait policy through command requests', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({}), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ); + vi.stubGlobal('fetch', fetchMock); + const client = createWrapperKiloClient(createSdkClient(), 'http://127.0.0.1:0', workspacePath); + + await client.sendCommand({ + sessionId: 'kilo_sess_wait', + command: 'review', + args: 'selected changes', + messageId: 'msg_wait', + snapshotInitialization: 'wait', + }); + + const request = fetchMock.mock.calls[0]?.[0]; + expect(request).toBeInstanceOf(Request); + expect(new URL((request as Request).url).pathname).toBe('/session/kilo_sess_wait/command'); + await expect((request as Request).clone().json()).resolves.toEqual({ + command: 'review', + arguments: 'selected changes', + messageID: 'msg_wait', + snapshotInitialization: 'wait', + }); + }); + + it('omits snapshot wait policy from default command requests', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({}), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ); + vi.stubGlobal('fetch', fetchMock); + const client = createWrapperKiloClient(createSdkClient(), 'http://127.0.0.1:0', workspacePath); + + await client.sendCommand({ sessionId: 'kilo_sess_default', command: 'review' }); + + const request = fetchMock.mock.calls[0]?.[0]; + expect(request).toBeInstanceOf(Request); + await expect((request as Request).clone().json()).resolves.toEqual({ + command: 'review', + arguments: '', + }); + }); }); describe('createWrapperKiloClient network endpoints', () => { diff --git a/services/cloud-agent-next/test/unit/wrapper/server.test.ts b/services/cloud-agent-next/test/unit/wrapper/server.test.ts index a192380c65..a81a1f0991 100644 --- a/services/cloud-agent-next/test/unit/wrapper/server.test.ts +++ b/services/cloud-agent-next/test/unit/wrapper/server.test.ts @@ -556,6 +556,48 @@ describe('createPromptHandler', () => { expect(deps.openConnection).toHaveBeenCalled(); }); + it('waits silently for slow snapshot initialization in Cloud Agent web prompts', async () => { + const state = new WrapperState(); + const deps = createMockDeps(state); + const handler = createPromptHandler( + { ...defaultServerConfig, platform: 'cloud-agent-web' }, + deps + ); + + const response = await handler( + jsonRequest({ + message: { id: 'msg_web_snapshot', prompt: 'Hello' }, + session: completeBinding, + }) + ); + + expect(response.status).toBe(200); + expect(deps.kiloClient.sendPromptAsync).toHaveBeenCalledWith( + expect.objectContaining({ snapshotInitialization: 'wait' }) + ); + }); + + it.each([undefined, 'slack', 'code-review', 'app-builder'])( + 'does not wait for snapshot initialization in %s-origin prompts', + async platform => { + const state = new WrapperState(); + const deps = createMockDeps(state); + const handler = createPromptHandler({ ...defaultServerConfig, platform }, deps); + + const response = await handler( + jsonRequest({ + message: { id: 'msg_non_web_snapshot', prompt: 'Hello' }, + session: completeBinding, + }) + ); + + expect(response.status).toBe(200); + expect(deps.kiloClient.sendPromptAsync).toHaveBeenCalledWith( + expect.not.objectContaining({ snapshotInitialization: expect.anything() }) + ); + } + ); + it('materializes a PDF before opening ingest and sends its local file part to Kilo', async () => { const state = new WrapperState(); const deps = createMockDeps(state); @@ -933,6 +975,50 @@ describe('createPromptHandler', () => { // --------------------------------------------------------------------------- describe('createCommandHandler', () => { + it('waits silently for slow snapshot initialization in Cloud Agent web commands', async () => { + const state = new WrapperState(); + const deps = createMockDeps(state); + const handler = createCommandHandler( + { ...defaultServerConfig, platform: 'cloud-agent-web' }, + deps + ); + + const response = await handler( + jsonRequest({ command: 'review', messageId: 'msg_web_command', session: completeBinding }) + ); + + expect(response.status).toBe(200); + expect(deps.kiloClient.sendCommand).toHaveBeenCalledWith({ + sessionId: 'kilo_sess_1', + command: 'review', + args: undefined, + messageId: 'msg_web_command', + snapshotInitialization: 'wait', + }); + }); + + it.each([undefined, 'slack', 'code-review', 'app-builder'])( + 'does not wait for snapshot initialization in %s-origin commands', + async platform => { + const state = new WrapperState(); + const deps = createMockDeps(state); + const handler = createCommandHandler({ ...defaultServerConfig, platform }, deps); + + const response = await handler( + jsonRequest({ + command: 'review', + messageId: 'msg_non_web_command', + session: completeBinding, + }) + ); + + expect(response.status).toBe(200); + expect(deps.kiloClient.sendCommand).toHaveBeenCalledWith( + expect.not.objectContaining({ snapshotInitialization: expect.anything() }) + ); + } + ); + it('routes compact through session summarize with the selected model', async () => { const state = new WrapperState(); const sendToIngest = vi.fn(); diff --git a/services/cloud-agent-next/wrangler.jsonc b/services/cloud-agent-next/wrangler.jsonc index 623d73fed9..a5012cce44 100644 --- a/services/cloud-agent-next/wrangler.jsonc +++ b/services/cloud-agent-next/wrangler.jsonc @@ -146,7 +146,7 @@ "image": "./Dockerfile", "instance_type": "standard-4", "image_vars": { - "KILOCODE_CLI_VERSION": "7.3.12", + "KILOCODE_CLI_VERSION": "7.3.21", }, "max_instances": 250, "rollout_active_grace_period": 1800, @@ -156,7 +156,7 @@ "image": "./Dockerfile", "instance_type": "standard-2", "image_vars": { - "KILOCODE_CLI_VERSION": "7.3.12", + "KILOCODE_CLI_VERSION": "7.3.21", }, "max_instances": 400, "rollout_active_grace_period": 1800, @@ -166,7 +166,7 @@ "image": "./Dockerfile.dind", "instance_type": "standard-3", "image_vars": { - "KILOCODE_CLI_VERSION": "7.3.12", + "KILOCODE_CLI_VERSION": "7.3.21", }, "max_instances": 20, "rollout_active_grace_period": 1800, @@ -327,7 +327,7 @@ "image": "./Dockerfile.dev", "instance_type": "standard-4", "image_vars": { - "KILOCODE_CLI_VERSION": "7.3.12", + "KILOCODE_CLI_VERSION": "7.3.21", }, "max_instances": 10, "rollout_active_grace_period": 60, @@ -337,7 +337,7 @@ "image": "./Dockerfile.dev", "instance_type": "standard-2", "image_vars": { - "KILOCODE_CLI_VERSION": "7.3.12", + "KILOCODE_CLI_VERSION": "7.3.21", }, "max_instances": 2, "rollout_active_grace_period": 60, @@ -347,7 +347,7 @@ "image": "./Dockerfile.dind", "instance_type": "standard-3", "image_vars": { - "KILOCODE_CLI_VERSION": "7.3.12", + "KILOCODE_CLI_VERSION": "7.3.21", }, "max_instances": 2, "rollout_active_grace_period": 60, diff --git a/services/cloud-agent-next/wrapper/package.json b/services/cloud-agent-next/wrapper/package.json index 878e8276e7..c8a1e68276 100644 --- a/services/cloud-agent-next/wrapper/package.json +++ b/services/cloud-agent-next/wrapper/package.json @@ -8,7 +8,7 @@ "typecheck": "tsgo --noEmit" }, "dependencies": { - "@kilocode/sdk": "7.3.12" + "@kilocode/sdk": "7.3.21" }, "devDependencies": { "@types/bun": "1.3.14", diff --git a/services/cloud-agent-next/wrapper/src/kilo-api.ts b/services/cloud-agent-next/wrapper/src/kilo-api.ts index 5cfc0f4661..0c002b1ff7 100644 --- a/services/cloud-agent-next/wrapper/src/kilo-api.ts +++ b/services/cloud-agent-next/wrapper/src/kilo-api.ts @@ -113,6 +113,7 @@ export type WrapperKiloClient = { model?: { providerID?: string; modelID: string }; system?: string; tools?: Record; + snapshotInitialization?: 'wait'; }) => Promise; abortSession: (opts: { sessionId: string }) => Promise; summarizeSession: (opts: { @@ -125,6 +126,7 @@ export type WrapperKiloClient = { command: string; args?: string; messageId?: string; + snapshotInitialization?: 'wait'; }) => Promise; /** Fetch the full slash command catalog from kilo, trimmed to wire shape. */ listCommands: () => Promise; @@ -248,6 +250,9 @@ export function createWrapperKiloClient( ...(opts.system ? { system: opts.system } : {}), ...(opts.tools ? { tools: opts.tools } : {}), ...(opts.agent ? { agent: opts.agent } : {}), + ...(opts.snapshotInitialization + ? { snapshotInitialization: opts.snapshotInitialization } + : {}), }); if (result.error !== undefined) { throw new Error( @@ -277,13 +282,14 @@ export function createWrapperKiloClient( }, sendCommand: async opts => { - const result = await sdkClient.session.command({ - path: { id: opts.sessionId }, - body: { - command: opts.command, - arguments: opts.args ?? '', - ...(opts.messageId !== undefined ? { messageID: opts.messageId } : {}), - }, + const result = await v2Client.session.command({ + sessionID: opts.sessionId, + command: opts.command, + arguments: opts.args ?? '', + ...(opts.messageId !== undefined ? { messageID: opts.messageId } : {}), + ...(opts.snapshotInitialization + ? { snapshotInitialization: opts.snapshotInitialization } + : {}), }); if (result.error !== undefined) { throw new Error( diff --git a/services/cloud-agent-next/wrapper/src/server.ts b/services/cloud-agent-next/wrapper/src/server.ts index b65172575a..4b83f1d81b 100644 --- a/services/cloud-agent-next/wrapper/src/server.ts +++ b/services/cloud-agent-next/wrapper/src/server.ts @@ -168,6 +168,10 @@ function wrapperFinalizingResponse(state: WrapperState): Response { ); } +function snapshotInitializationForPlatform(platform?: string): 'wait' | undefined { + return platform === 'cloud-agent-web' ? 'wait' : undefined; +} + async function applyCommitAttribution( workspacePath: string, commitCoAuthor: WrapperCommitCoAuthor | undefined, @@ -478,6 +482,7 @@ export function createPromptHandler(config: ServerConfig, deps: ServerDependenci }); try { + const snapshotInitialization = snapshotInitializationForPlatform(session.platform); await kiloClient.sendPromptAsync({ sessionId: session.kiloSessionId, messageId, @@ -488,6 +493,7 @@ export function createPromptHandler(config: ServerConfig, deps: ServerDependenci model: prompt.agent?.model, system: prompt.agent?.system, tools: prompt.agent?.tools, + ...(snapshotInitialization ? { snapshotInitialization } : {}), }); logToFile(`job/prompt: sent messageId=${messageId}`); acknowledgeDelivery('async-prompt'); @@ -593,11 +599,13 @@ export function createCommandHandler(config: ServerConfig, deps: ServerDependenc }); } } else { + const snapshotInitialization = snapshotInitializationForPlatform(session.platform); result = await kiloClient.sendCommand({ sessionId: session.kiloSessionId, command: body.command, args: body.args, messageId, + ...(snapshotInitialization ? { snapshotInitialization } : {}), }); } state.updateActivity(); From 68858da540f15ac023f95544bbc98a4e82ac2d8a Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Mon, 8 Jun 2026 22:15:05 +0200 Subject: [PATCH 2/2] fix(dev): stabilize local cloud-agent startup --- dev/local/dashboard.tsx | 25 +-- dev/local/runner.ts | 38 ++-- dev/local/tmux.test.ts | 17 ++ dev/local/tmux.ts | 34 +++- .../scripts/dev-with-docker-proxy.sh | 2 +- .../scripts/docker-privileged-proxy.mjs | 31 +++- .../docker-privileged-proxy-script.test.ts | 172 ++++++++++++++++++ 7 files changed, 270 insertions(+), 49 deletions(-) create mode 100644 dev/local/tmux.test.ts create mode 100644 services/cloud-agent-next/src/docker-privileged-proxy-script.test.ts diff --git a/dev/local/dashboard.tsx b/dev/local/dashboard.tsx index 88383b5363..408add745e 100644 --- a/dev/local/dashboard.tsx +++ b/dev/local/dashboard.tsx @@ -130,13 +130,7 @@ function doShowGroup( ): void { if (runningServiceNames.length === 0) return; const current = viewedRef.current; - const currentIsGroup = isGroupView(current); - const result = showGroupInTmux( - sessionName, - runningServiceNames, - currentViewedEncoded(current), - currentIsGroup - ); + const result = showGroupInTmux(sessionName, runningServiceNames, currentViewedEncoded(current)); if (result !== currentViewedEncoded(current)) { viewedRef.current = { kind: 'group', groupId, serviceNames: runningServiceNames }; } @@ -373,7 +367,9 @@ function Dashboard({ return new Map(entries); }); }; - refresh(); + void refresh().catch(error => { + console.error('Failed to refresh service statuses:', error); + }); const timer = setInterval(refresh, REFRESH_MS); return () => clearInterval(timer); }, [runningServices]); @@ -691,7 +687,7 @@ function Dashboard({ const handleStdin = (data: Buffer) => { const str = data.toString('utf-8'); - const re = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g; + const re = new RegExp(`${String.fromCharCode(27)}\\[<(\\d+);(\\d+);(\\d+)([Mm])`, 'g'); let m; while ((m = re.exec(str)) !== null) { const button = parseInt(m[1], 10); @@ -894,6 +890,11 @@ const { waitUntilExit } = render( /> ); -waitUntilExit().then(() => { - process.exit(0); -}); +waitUntilExit() + .then(() => { + process.exit(0); + }) + .catch(error => { + console.error('Dashboard exited with an error:', error); + process.exit(1); + }); diff --git a/dev/local/runner.ts b/dev/local/runner.ts index f0244c916a..5bb66b5472 100644 --- a/dev/local/runner.ts +++ b/dev/local/runner.ts @@ -1,4 +1,4 @@ -import { execFileSync, execSync } from 'node:child_process'; +import { execFileSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as net from 'node:net'; import * as path from 'node:path'; @@ -134,28 +134,25 @@ function getPnpmCommand(): string { export function startServiceInTmux(sessionName: string, serviceName: string): void { const svc = getService(serviceName); - const winIndex = createWindow(sessionName, serviceName); - if (svc.type === 'infra') { - // Profile-gated services need --profile on every compose subcommand, - // including `logs`. Without it, Compose v2 filters the service out of - // the graph and the tmux pane is silent or errors. Shell-quote the - // profile and service names — they are safe identifiers today but the - // quoting keeps the command robust if a future maintainer adds a name - // containing whitespace or metacharacters. - const profile = getInfraProfile(serviceName); - const profileArg = profile ? `--profile ${shellQuote(profile)} ` : ''; - sendKeys( - sessionName, - serviceName, - `docker compose ${profileArg}-f dev/docker-compose.yml logs -f ${shellQuote(serviceName)}` - ); - } else { - sendKeys(sessionName, serviceName, buildStartCommand(serviceName)); - } + const startupCommand = + svc.type === 'infra' ? buildInfraLogCommand(serviceName) : buildStartCommand(serviceName); + const winIndex = createWindow(sessionName, serviceName, startupCommand); const logPath = path.join(findRepoRoot(), 'dev', 'logs', `${serviceName}.log`); pipePane(sessionName, winIndex, 0, buildLogPipeCommand(logPath)); } +function buildInfraLogCommand(serviceName: string): string { + // Profile-gated services need --profile on every compose subcommand, + // including `logs`. Without it, Compose v2 filters the service out of + // the graph and the tmux pane is silent or errors. Shell-quote the + // profile and service names — they are safe identifiers today but the + // quoting keeps the command robust if a future maintainer adds a name + // containing whitespace or metacharacters. + const profile = getInfraProfile(serviceName); + const profileArg = profile ? `--profile ${shellQuote(profile)} ` : ''; + return `docker compose ${profileArg}-f dev/docker-compose.yml logs -f ${shellQuote(serviceName)}`; +} + function buildLogPipeCommand(logPath: string): string { const filterPath = path.join(findRepoRoot(), 'dev', 'local', 'log-filter.ts'); return `tsx ${shellQuote(filterPath)} >> ${shellQuote(logPath)}`; @@ -286,8 +283,7 @@ export function showServiceInTmux( export function showGroupInTmux( sessionName: string, serviceNames: string[], - currentPaneNames: string, - currentViewedIsGroup: boolean + currentPaneNames: string ): string { if (serviceNames.length === 0) return currentPaneNames; try { diff --git a/dev/local/tmux.test.ts b/dev/local/tmux.test.ts new file mode 100644 index 0000000000..571f46b182 --- /dev/null +++ b/dev/local/tmux.test.ts @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import test from 'node:test'; + +import { buildInteractiveShellCommand } from './tmux'; + +test('buildInteractiveShellCommand wraps quoted startup commands in parseable shell syntax', () => { + const startupCommand = + "PATH='/tmp/with spaces:/bin' PNPM_HOME='/tmp/pnpm home' node '/tmp/runner with spaces.js' --flag"; + + const wrapped = buildInteractiveShellCommand(startupCommand, '/bin/sh'); + + assert.match(wrapped, /^'\/bin\/sh' -lc /); + assert.match(wrapped, /exec/); + assert.match(wrapped, /PATH/); + execFileSync('/bin/sh', ['-n', '-c', wrapped]); +}); diff --git a/dev/local/tmux.ts b/dev/local/tmux.ts index 51f7ba9a40..9e1f4a8f32 100644 --- a/dev/local/tmux.ts +++ b/dev/local/tmux.ts @@ -124,14 +124,37 @@ function attachSession(sessionName: string): void { // Window management // --------------------------------------------------------------------------- -function createWindow(sessionName: string, windowName: string): number { - const output = execSync( - `tmux new-window -d -t ${sessionName} -n ${windowName} -P -F "#{window_index}"`, - { encoding: 'utf-8' } - ).trim(); +function createWindow(sessionName: string, windowName: string, startupCommand?: string): number { + const args = [ + 'new-window', + '-d', + '-t', + sessionName, + '-n', + windowName, + '-c', + getWorktreeRoot(), + '-P', + '-F', + '#{window_index}', + ]; + if (startupCommand) { + args.push(buildInteractiveShellCommand(startupCommand)); + } + + const output = execFileSync('tmux', args, { encoding: 'utf-8' }).trim(); return parseInt(output, 10); } +function buildInteractiveShellCommand( + startupCommand: string, + shell = process.env.SHELL || '/bin/sh' +): string { + return `${escapeForShell(shell)} -lc ${escapeForShell( + `${startupCommand}; exec ${escapeForShell(shell)} -l` + )}`; +} + function paneTarget(sessionName: string, windowTarget: string | number, pane?: number): string { return pane !== undefined ? `${sessionName}:${windowTarget}.${pane}` @@ -442,6 +465,7 @@ export { killSession, attachSession, createWindow, + buildInteractiveShellCommand, sendKeys, sendInterrupt, selectWindow, diff --git a/services/cloud-agent-next/scripts/dev-with-docker-proxy.sh b/services/cloud-agent-next/scripts/dev-with-docker-proxy.sh index b8f37d89b8..a2ad090aa7 100755 --- a/services/cloud-agent-next/scripts/dev-with-docker-proxy.sh +++ b/services/cloud-agent-next/scripts/dev-with-docker-proxy.sh @@ -1,6 +1,6 @@ #!/bin/sh # Run `wrangler dev` with a local Docker socket proxy that injects -# HostConfig.Privileged=true for SandboxSmall (Docker-in-Docker). +# HostConfig.Privileged=true for SandboxDIND containers only. # # See scripts/docker-privileged-proxy.mjs for context. # Args after `--` are forwarded to wrangler dev. diff --git a/services/cloud-agent-next/scripts/docker-privileged-proxy.mjs b/services/cloud-agent-next/scripts/docker-privileged-proxy.mjs index 34e9e82f5c..35ee6893a4 100644 --- a/services/cloud-agent-next/scripts/docker-privileged-proxy.mjs +++ b/services/cloud-agent-next/scripts/docker-privileged-proxy.mjs @@ -1,17 +1,17 @@ // Docker socket proxy that injects HostConfig.Privileged=true into -// `POST /containers/create` requests. +// `SandboxDIND` container 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. +// Cloudflare Containers run our `SandboxDIND` image 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 SandboxDIND 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`. +// forwards Docker API calls to the host's real Docker socket and rewrites only +// `SandboxDIND` 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 @@ -83,10 +83,10 @@ const server = net.createServer(client => { const header = buffered.slice(0, headerEnd).toString('utf8'); const bodyStart = headerEnd + 4; - const match = header.match(/^POST\s+\S*\/containers\/create(?:\?|\s)/); + const createRequest = header.match(/^POST\s+(\S*\/containers\/create(?:\?\S*)?)\s/); const contentLength = header.match(/\r\nContent-Length:\s*(\d+)/i); - if (!match || !contentLength) { + if (!createRequest || !contentLength) { patched = true; upstream.write(buffered); return; @@ -107,6 +107,17 @@ const server = net.createServer(client => { return; } + const containerName = new URL(createRequest[1], 'http://docker').searchParams.get('name'); + const isSandboxDind = + containerName?.includes('-SandboxDIND-') && + typeof payload.Image === 'string' && + payload.Image.startsWith('cloudflare-dev/sandboxdind:'); + if (!isSandboxDind) { + patched = true; + upstream.write(buffered); + return; + } + 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}`); diff --git a/services/cloud-agent-next/src/docker-privileged-proxy-script.test.ts b/services/cloud-agent-next/src/docker-privileged-proxy-script.test.ts new file mode 100644 index 0000000000..25e8be2320 --- /dev/null +++ b/services/cloud-agent-next/src/docker-privileged-proxy-script.test.ts @@ -0,0 +1,172 @@ +import { spawn, type ChildProcess } from 'node:child_process'; +import fs from 'node:fs'; +import net, { type Server } from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, describe, expect, it } from 'vitest'; + +const testDir = path.dirname(fileURLToPath(import.meta.url)); +const serviceDir = path.resolve(testDir, '..'); +const scriptPath = path.join(serviceDir, 'scripts/docker-privileged-proxy.mjs'); +const childProcesses: ChildProcess[] = []; +const servers: Server[] = []; +const tempDirs: string[] = []; + +afterEach(async () => { + for (const child of childProcesses.splice(0)) { + child.kill('SIGTERM'); + } + await Promise.all( + servers.splice(0).map( + server => + new Promise(resolve => { + server.close(() => resolve()); + }) + ) + ); + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +function listen(server: Server, socketPath: string): Promise { + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(socketPath, () => { + server.off('error', reject); + resolve(); + }); + }); +} + +async function waitForSocket(socketPath: string): Promise { + for (let attempt = 0; attempt < 100; attempt += 1) { + if (fs.existsSync(socketPath)) return; + await new Promise(resolve => setTimeout(resolve, 10)); + } + throw new Error(`Socket was not created: ${socketPath}`); +} + +function readRequestBody(request: Buffer): unknown { + const headerEnd = request.indexOf('\r\n\r\n'); + if (headerEnd === -1) throw new Error('Request headers were incomplete'); + return JSON.parse(request.subarray(headerEnd + 4).toString('utf8')); +} + +async function startProxy(): Promise<{ proxySocket: string; request: Promise }> { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cloud-agent-privileged-proxy-test-')); + tempDirs.push(tempDir); + const upstreamSocket = path.join(tempDir, 'upstream.sock'); + const proxySocket = path.join(tempDir, 'proxy.sock'); + + let resolveRequest: (request: Buffer) => void = () => {}; + const request = new Promise(resolve => { + resolveRequest = resolve; + }); + const upstream = net.createServer(socket => { + let received = Buffer.alloc(0); + socket.on('data', chunk => { + received = Buffer.concat([received, chunk]); + const headerEnd = received.indexOf('\r\n\r\n'); + if (headerEnd === -1) return; + const header = received.subarray(0, headerEnd).toString('utf8'); + const contentLength = header.match(/\r\nContent-Length:\s*(\d+)/i); + if (!contentLength) return; + const requestLength = headerEnd + 4 + Number(contentLength[1]); + if (received.length < requestLength) return; + + resolveRequest(received.subarray(0, requestLength)); + socket.end('HTTP/1.1 201 Created\r\nContent-Length: 2\r\n\r\n{}'); + }); + }); + servers.push(upstream); + await listen(upstream, upstreamSocket); + + const child = spawn(process.execPath, [scriptPath], { + cwd: serviceDir, + env: { ...process.env, DOCKER_PROXY_SOCKET: proxySocket, DOCKER_SOCKET: upstreamSocket }, + stdio: 'ignore', + }); + childProcesses.push(child); + await waitForSocket(proxySocket); + + return { proxySocket, request }; +} + +function sendCreateRequest(proxySocket: string, name: string, image: string): Promise { + return new Promise((resolve, reject) => { + const body = JSON.stringify({ Image: image, HostConfig: { PidMode: 'host' } }); + const socket = net.createConnection(proxySocket, () => { + socket.write( + `POST /v1.48/containers/create?name=${encodeURIComponent(name)} HTTP/1.1\r\n` + + `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}` + ); + }); + socket.once('error', reject); + socket.once('end', resolve); + socket.resume(); + }); +} + +describe('docker-privileged-proxy.mjs', () => { + it('leaves ordinary sandbox creates unprivileged', async () => { + const proxy = await startProxy(); + + await sendCreateRequest( + proxy.proxySocket, + 'workerd-cloud-agent-next-dev-SandboxSmall-session', + 'cloudflare-dev/sandboxsmall:test' + ); + + expect(readRequestBody(await proxy.request)).toEqual({ + Image: 'cloudflare-dev/sandboxsmall:test', + HostConfig: { PidMode: 'host' }, + }); + }); + + it('makes SandboxDIND creates privileged', async () => { + const proxy = await startProxy(); + + await sendCreateRequest( + proxy.proxySocket, + 'workerd-cloud-agent-next-dev-SandboxDIND-session', + 'cloudflare-dev/sandboxdind:test' + ); + + expect(readRequestBody(await proxy.request)).toEqual({ + Image: 'cloudflare-dev/sandboxdind:test', + HostConfig: { PidMode: 'host', Privileged: true }, + }); + }); + + it('leaves SandboxDIND proxy sidecars unprivileged', async () => { + const proxy = await startProxy(); + + await sendCreateRequest( + proxy.proxySocket, + 'workerd-cloud-agent-next-dev-SandboxDIND-session-proxy', + 'cloudflare/proxy-everything:test' + ); + + expect(readRequestBody(await proxy.request)).toEqual({ + Image: 'cloudflare/proxy-everything:test', + HostConfig: { PidMode: 'host' }, + }); + }); + + it('requires the SandboxDIND container name before granting privilege', async () => { + const proxy = await startProxy(); + + await sendCreateRequest( + proxy.proxySocket, + 'workerd-cloud-agent-next-dev-SandboxSmall-session', + 'cloudflare-dev/sandboxdind:test' + ); + + expect(readRequestBody(await proxy.request)).toEqual({ + Image: 'cloudflare-dev/sandboxdind:test', + HostConfig: { PidMode: 'host' }, + }); + }); +});