Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions services/cloud-agent-next/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends locales && \
ENV LC_ALL=en_US.UTF-8
ENV LANG=en_US.UTF-8

# Install dependencies globally (accessible to all users)
RUN npm install -g pnpm @kilocode/cli@${KILOCODE_CLI_VERSION}
# Install dependencies globally (accessible to all users). Keep the wrapper at
# normal priority while Kilo and its descendants inherit nice 10.
RUN npm install -g pnpm @kilocode/cli@${KILOCODE_CLI_VERSION} && \
kilo_path="$(command -v kilo)" && \
mv "$kilo_path" "${kilo_path}-real" && \
printf '#!/bin/sh\nexec nice -n 10 "%s" "$@"\n' "${kilo_path}-real" > "$kilo_path" && \
chmod +x "$kilo_path"

# === Build wrapper bundle inside container ===
# This ensures the wrapper is built with the same Bun version that will run it,
Expand Down
9 changes: 7 additions & 2 deletions services/cloud-agent-next/Dockerfile.dev
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,13 @@ RUN GLAB_VERSION="1.93.0" \
&& dpkg -i /tmp/glab.deb \
&& rm /tmp/glab.deb

# Install pnpm and kilocode
RUN npm install -g pnpm @kilocode/cli@${KILOCODE_CLI_VERSION}
# Install pnpm and Kilo. Keep the wrapper at normal priority while Kilo and
# its descendants inherit nice 10.
RUN npm install -g pnpm @kilocode/cli@${KILOCODE_CLI_VERSION} && \
kilo_path="$(command -v kilo)" && \
mv "$kilo_path" "${kilo_path}-real" && \
printf '#!/bin/sh\nexec nice -n 10 "%s" "$@"\n' "${kilo_path}-real" > "$kilo_path" && \
chmod +x "$kilo_path"

# Copy to install pre-built kilo binary (built via ./cloud-agent-build.sh)
#COPY kilo /usr/local/bin/kilo
Expand Down
10 changes: 7 additions & 3 deletions services/cloud-agent-next/Dockerfile.dind
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,11 @@ RUN GLAB_VERSION="1.93.0" \
# 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 npm install -g bun pnpm @devcontainers/cli @kilocode/cli@${KILOCODE_CLI_VERSION} \
&& kilo_path="$(command -v kilo)" \
&& mv "$kilo_path" "${kilo_path}-real" \
&& printf '#!/bin/sh\nexec nice -n 10 "%s" "$@"\n' "${kilo_path}-real" > "$kilo_path" \
&& chmod +x "$kilo_path"

RUN mkdir -p /opt/kilo-agent/bin \
/opt/kilo-agent/cli-linux-x64 \
Expand Down Expand Up @@ -90,8 +94,8 @@ else
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" "$@" ;;
x86_64:glibc) exec nice -n 10 "$root/cli-linux-x64/bin/kilo" "$@" ;;
x86_64:musl) exec nice -n 10 "$root/cli-linux-x64-musl/bin/kilo" "$@" ;;
*) echo "Unsupported devcontainer platform: $arch/$libc" >&2; exit 1 ;;
esac
EOF
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -438,71 +438,73 @@ describe('CloudflareAgentSandbox', () => {
});
});

it('does not require Docker discovery for standard sandboxes', async () => {
const exec = vi.fn().mockRejectedValue(new Error('docker unavailable'));
it('inspects owned runtimes without requiring Docker for standard sandboxes', async () => {
const exec = vi.fn().mockResolvedValue({ exitCode: 0, stdout: '' });
const sandbox = new CloudflareAgentSandbox({} as Env, metadata(), {
resolveSandbox: () =>
({ listProcesses: vi.fn().mockResolvedValue([]), exec }) as unknown as SandboxInstance,
});

await expect(sandbox.discoverSessionWrappers()).resolves.toEqual({ status: 'absent' });
expect(exec).not.toHaveBeenCalled();
});

it('requires container discovery for a DIND sandbox even before resolved devcontainer metadata exists', async () => {
const unresolvedDindMetadata = {
...metadata(),
workspace: { sandboxId: 'dind-unresolved' },
} satisfies SessionMetadata;
const sandbox = new CloudflareAgentSandbox({} as Env, unresolvedDindMetadata, {
resolveSandbox: () =>
({
listProcesses: vi.fn().mockResolvedValue([]),
exec: vi.fn().mockRejectedValue(new Error('docker inspection unavailable')),
}) as unknown as SandboxInstance,
});

await expect(sandbox.discoverSessionWrappers()).resolves.toMatchObject({
status: 'inspection-failed',
error: expect.stringContaining('docker inspection unavailable'),
});
expect(exec).toHaveBeenCalledOnce();
expect(exec).toHaveBeenCalledWith(expect.stringContaining('/proc/[0-9]*/environ'), undefined);
});

it('stops remaining session wrappers before confirming an instance target is absent', async () => {
const stopObservedWrappers = vi.fn().mockResolvedValue(undefined);
it('force stops a targeted wrapper that remains after graceful termination and confirms absence', async () => {
const listProcesses = vi
.fn()
.mockResolvedValueOnce([
{
id: 'wrapper-legacy',
command: 'WRAPPER_PORT=5001 kilocode-wrapper --agent-session agent_cloudflare',
id: 'wrapper-target',
command:
'WRAPPER_PORT=5000 kilocode-wrapper --agent-session agent_cloudflare --wrapper-instance-id instance_1 --wrapper-instance-generation 2',
status: 'running',
},
])
.mockResolvedValueOnce([
{
id: 'wrapper-target',
command:
'WRAPPER_PORT=5000 kilocode-wrapper --agent-session agent_cloudflare --wrapper-instance-id instance_1 --wrapper-instance-generation 2',
status: 'running',
},
])
.mockResolvedValueOnce([]);
let runtimeInspectionCount = 0;
const exec = vi.fn().mockImplementation((command: string) => {
if (command.includes('/proc/[0-9]*/environ')) {
runtimeInspectionCount += 1;
return Promise.resolve({
exitCode: 0,
stdout:
runtimeInspectionCount === 1
? '812\tagent_cloudflare:instance_1:2\t1\tinstance_1\t2\t/tmp/kilocode-wrapper-agent_cloudflare-1.log\t.kilo serve\n'
: '',
});
}
return Promise.resolve({ exitCode: 0, stdout: '' });
});
const sandbox = new CloudflareAgentSandbox({} as Env, metadata(), {
resolveSandbox: () => ({ listProcesses }) as unknown as SandboxInstance,
stopObservedWrappers,
stopObservationDelaysMs: [0],
resolveSandbox: () => ({ listProcesses, exec }) as unknown as SandboxInstance,
sleep: vi.fn().mockResolvedValue(undefined),
stopObservationDelaysMs: [0],
});

await expect(
sandbox.stopWrappers({
target: {
kind: 'instance',
instance: { instanceId: 'instance_gone', instanceGeneration: 1 },
},
attemptId: 'attempt_residual',
reason: 'session-delete',
target: { kind: 'instance', instance: { instanceId: 'instance_1', instanceGeneration: 2 } },
attemptId: 'attempt_1',
reason: 'readiness-failed',
})
).resolves.toEqual({ status: 'absent' });
expect(stopObservedWrappers).toHaveBeenCalledWith(expect.anything(), 'agent_cloudflare', [
{ representation: 'process', id: 'wrapper-legacy', port: 5001 },
]);
).resolves.toEqual({ status: 'absent', stoppedInstanceIds: ['instance_1'] });
expect(exec).toHaveBeenCalledWith(expect.stringContaining('pkill -f --'));
expect(exec).toHaveBeenCalledWith(expect.stringContaining('pkill -9 -f --'));
expect(exec).toHaveBeenCalledWith(expect.stringContaining('--agent-session agent_cloudflare'));
});

it('force stops a targeted wrapper that remains after graceful termination and confirms absence', async () => {
it('does not confirm absence while an owned runtime survives wrapper cleanup', async () => {
const ownedRuntimeOutput =
'812\tagent_cloudflare:instance_1:2\t1\tinstance_1\t2\t/tmp/kilocode-wrapper-agent_cloudflare-1.log\t.kilo serve --hostname 127.0.0.1\n';
const listProcesses = vi
.fn()
.mockResolvedValueOnce([
Expand All @@ -513,16 +515,13 @@ describe('CloudflareAgentSandbox', () => {
status: 'running',
},
])
.mockResolvedValueOnce([
{
id: 'wrapper-target',
command:
'WRAPPER_PORT=5000 kilocode-wrapper --agent-session agent_cloudflare --wrapper-instance-id instance_1 --wrapper-instance-generation 2',
status: 'running',
},
])
.mockResolvedValueOnce([]);
const exec = vi.fn().mockResolvedValue({ exitCode: 0, stdout: '' });
.mockResolvedValue([]);
const exec = vi.fn().mockImplementation((command: string) => {
if (command.startsWith('sh -c')) {
return Promise.resolve({ exitCode: 0, stdout: ownedRuntimeOutput });
}
return Promise.resolve({ exitCode: 0, stdout: '' });
});
const sandbox = new CloudflareAgentSandbox({} as Env, metadata(), {
resolveSandbox: () => ({ listProcesses, exec }) as unknown as SandboxInstance,
sleep: vi.fn().mockResolvedValue(undefined),
Expand All @@ -532,13 +531,13 @@ describe('CloudflareAgentSandbox', () => {
await expect(
sandbox.stopWrappers({
target: { kind: 'instance', instance: { instanceId: 'instance_1', instanceGeneration: 2 } },
attemptId: 'attempt_1',
attemptId: 'attempt_runtime_survives',
reason: 'readiness-failed',
})
).resolves.toEqual({ status: 'absent', stoppedInstanceIds: ['instance_1'] });
expect(exec).toHaveBeenCalledWith(expect.stringContaining('pkill -f --'));
expect(exec).toHaveBeenCalledWith(expect.stringContaining('pkill -9 -f --'));
expect(exec).toHaveBeenCalledWith(expect.stringContaining('--agent-session agent_cloudflare'));
).resolves.toMatchObject({
status: 'still-present',
observed: [expect.objectContaining({ id: '812', processKind: 'runtime' })],
});
});

it('returns still-present when targeted forceful cleanup remains observable', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -294,9 +294,11 @@ export class CloudflareAgentSandbox implements AgentSandbox {
const final = await this.observeTarget(request.target);
if (final.status === 'inspection-failed') return final;
if (final.status === 'present') return { status: 'still-present', observed: final.observed };
const stoppedInstanceIds = initial.observed.flatMap(observed =>
observed.instanceId ? [observed.instanceId] : []
);
const stoppedInstanceIds = [
...new Set(
initial.observed.flatMap(observed => (observed.instanceId ? [observed.instanceId] : []))
),
];
return stoppedInstanceIds.length > 0 ? { status: 'absent', stoppedInstanceIds } : final;
}

Expand Down
2 changes: 2 additions & 0 deletions services/cloud-agent-next/src/agent-sandbox/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export type WrapperInstanceLease = {
export type ObservedWrapper = {
representation: 'process' | 'container';
id: string;
containerId?: string;
processKind?: 'wrapper' | 'runtime';
port?: number;
instanceId?: string;
instanceGeneration?: number;
Expand Down
19 changes: 18 additions & 1 deletion services/cloud-agent-next/src/kilo/devcontainer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,18 @@ describe('sandbox image versions', () => {
expect(wranglerConfig.split(imageVar)).toHaveLength(7);
expect(DEFAULT_SLASH_COMMANDS_SOURCE).toBe(`kilo@${KILO_CLI_VERSION}`);
});

it('runs image-installed Kilo through a nice 10 shim without lowering wrapper priority', () => {
for (const dockerfileName of ['Dockerfile', 'Dockerfile.dev', 'Dockerfile.dind']) {
const dockerfile = readFileSync(
fileURLToPath(new URL(`../../${dockerfileName}`, import.meta.url).href),
'utf8'
);
expect(dockerfile).toContain('exec nice -n 10');
expect(dockerfile).not.toContain('nice -n 10 bun');
expect(dockerfile).not.toContain('nice -n 10 kilocode-wrapper');
}
});
});

describe('detectDevContainer', () => {
Expand Down Expand Up @@ -243,7 +255,12 @@ describe('bringUpDevContainer', () => {
expect(commands.some(cmd => cmd.includes('@kilocode/cli@7.2.52'))).toBe(true);
expect(commands.some(cmd => cmd.includes('set -euo pipefail'))).toBe(true);
expect(commands.some(cmd => cmd.includes('/usr/local/bin/bun'))).toBe(true);
expect(commands.some(cmd => cmd.includes('/usr/local/bin/kilo'))).toBe(true);
expect(bootstrapCall?.[0]).toContain('chmod +x "$kilo_path"');
expect(bootstrapCall?.[0]).toContain('kilo_path="$(command -v kilo)"');
expect(bootstrapCall?.[0]).toContain('rm -f "${kilo_path}-real"');
expect(bootstrapCall?.[0]).toContain('mv "$kilo_path" "${kilo_path}-real"');
expect(bootstrapCall?.[0]).toContain('exec nice -n 10 "%s-real" "$@"');
expect(bootstrapCall?.[0]).not.toContain('ln -sf "$(command -v kilo)"');
expect(bootstrapCall?.[1]).toEqual({
env: { DOCKER_HOST: 'unix:///var/run/docker.sock' },
timeout: 10 * 60 * 1000,
Expand Down
6 changes: 5 additions & 1 deletion services/cloud-agent-next/src/kilo/devcontainer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,11 @@ async function bootstrapDevContainerRuntimeTools(
`curl -fsSL https://bun.sh/install | bash -s "bun-v${DEVCONTAINER_RUNTIME_BUN_VERSION}"`,
'ln -sf "$HOME/.bun/bin/bun" /usr/local/bin/bun',
`npm install -g ${shellQuote(`@kilocode/cli@${opts.kiloCliVersion}`)}`,
'ln -sf "$(command -v kilo)" /usr/local/bin/kilo',
'kilo_path="$(command -v kilo)"',
'rm -f "${kilo_path}-real"',
'mv "$kilo_path" "${kilo_path}-real"',
`printf ${shellQuote('#!/bin/sh\nexec nice -n 10 "%s-real" "$@"\n')} "$kilo_path" > "$kilo_path"`,
'chmod +x "$kilo_path"',
].join(' && ');
const command = buildDevContainerRuntimeExecCommand(opts, installCommand, 'bash -lc');
const result = await session.exec(command, {
Expand Down
14 changes: 13 additions & 1 deletion services/cloud-agent-next/src/kilo/wrapper-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1053,6 +1053,13 @@ describe('WrapperClient', () => {
const command = (session.startProcess as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
expect(command).toContain("WRAPPER_INSTANCE_ID='instance_test'");
expect(command).toContain('WRAPPER_INSTANCE_GENERATION=6');
expect(command).toContain("KILO_RUNTIME_OWNER='test-session:instance_test:6'");
const options = (session.startProcess as ReturnType<typeof vi.fn>).mock.calls[0][1] as {
env?: Record<string, string>;
};
expect(options.env).toEqual(
expect.objectContaining({ KILO_RUNTIME_OWNER: 'test-session:instance_test:6' })
);
expect(command).not.toContain('--wrapper-instance-id');
expect(command).not.toContain('--wrapper-instance-generation');
});
Expand Down Expand Up @@ -1501,7 +1508,7 @@ describe('WrapperClient', () => {
expect(session.startProcess).not.toHaveBeenCalled();
});

it('reuses an env-tagged legacy wrapper whose health does not report its lease', async () => {
it('reuses an env-tagged legacy wrapper with a matching owned runtime', async () => {
const session = createMockSession(createSuccessResponse(healthResponseData));
const sandbox = createMockSandbox({ port: 5555, healthy: true });
(sandbox.listProcesses as ReturnType<typeof vi.fn>).mockResolvedValue([
Expand All @@ -1512,6 +1519,11 @@ describe('WrapperClient', () => {
status: 'running',
},
]);
(sandbox.exec as ReturnType<typeof vi.fn>).mockResolvedValue({
exitCode: 0,
stdout:
'812\ttest-session:instance_current:2\t1\tinstance_current\t2\t/tmp/kilocode-wrapper-test-session-1.log\t.kilo serve --hostname 127.0.0.1\n',
});

await expect(
WrapperClient.ensureWrapper(sandbox, session, {
Expand Down
39 changes: 27 additions & 12 deletions services/cloud-agent-next/src/kilo/wrapper-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
findWrapperForSession,
findWrapperForSessionInProcesses,
getWrapperSessionMarker,
serializeRuntimeOwner,
} from './wrapper-manager.js';
import { randomPort } from './ports.js';
import {
Expand Down Expand Up @@ -246,17 +247,19 @@ async function observationMatchesLease(
const observation = await discoverSessionWrappers(sandbox, agentSessionId, {
inspectContainers: options.inspectContainers,
});
if (observation.status !== 'present' || observation.observed.length !== 1) return false;
const wrapper = observation.observed[0];
if (!wrapper) return false;
if (observation.status !== 'present') return false;
const wrappers = observation.observed.filter(observed => observed.processKind !== 'runtime');
const wrapper = wrappers[0];
if (wrappers.length !== 1 || !wrapper) return false;
if (options.expectedContainerId !== undefined) {
if (wrapper.representation !== 'container' || wrapper.id !== options.expectedContainerId) {
return false;
}
}
return (
wrapper.instanceId === leasedInstance.instanceId &&
wrapper.instanceGeneration === leasedInstance.instanceGeneration
return observation.observed.every(
observed =>
observed.instanceId === leasedInstance.instanceId &&
observed.instanceGeneration === leasedInstance.instanceGeneration
);
}

Expand Down Expand Up @@ -577,16 +580,27 @@ export class WrapperClient {
// 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 runtimeLease = leasedInstance
? {
instance: leasedInstance,
owner: serializeRuntimeOwner(
agentSessionId,
leasedInstance.instanceId,
leasedInstance.instanceGeneration
),
}
: undefined;
const wrapperEnv: Record<string, string | undefined> = {
WRAPPER_PORT: String(this.port),
WORKSPACE_PATH: innerWorkspacePath,
WRAPPER_LOG_PATH: wrapperLogPath,
KILO_SESSION_RETRY_LIMIT: '5',
KILO_CLOUD_AGENT: '1',
...(leasedInstance
...(runtimeLease
? {
WRAPPER_INSTANCE_ID: leasedInstance.instanceId,
WRAPPER_INSTANCE_GENERATION: String(leasedInstance.instanceGeneration),
WRAPPER_INSTANCE_ID: runtimeLease.instance.instanceId,
WRAPPER_INSTANCE_GENERATION: String(runtimeLease.instance.instanceGeneration),
KILO_RUNTIME_OWNER: runtimeLease.owner,
}
: {}),
};
Expand All @@ -597,10 +611,11 @@ export class WrapperClient {
`KILO_SESSION_RETRY_LIMIT=5`,
`KILO_CLOUD_AGENT=1`,
// Environment markers let pre-lease wrapper bundles launch during a rolling deploy.
...(leasedInstance
...(runtimeLease
? [
`WRAPPER_INSTANCE_ID=${shellQuote(leasedInstance.instanceId)}`,
`WRAPPER_INSTANCE_GENERATION=${leasedInstance.instanceGeneration}`,
`WRAPPER_INSTANCE_ID=${shellQuote(runtimeLease.instance.instanceId)}`,
`WRAPPER_INSTANCE_GENERATION=${runtimeLease.instance.instanceGeneration}`,
`KILO_RUNTIME_OWNER=${shellQuote(runtimeLease.owner)}`,
]
: []),
...dockerEnvParts,
Expand Down
Loading