Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 4 additions & 0 deletions services/cloud-agent-next/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ RUN GLAB_VERSION="1.93.0" \
&& dpkg -i /tmp/glab.deb \
&& rm /tmp/glab.deb

COPY scripts/kilo-git-credential /opt/kilo-cloud/kilo-git-credential
RUN chmod +x /opt/kilo-cloud/kilo-git-credential \
&& ln -sf /opt/kilo-cloud/kilo-git-credential /usr/local/bin/kilo-git-credential

# Generate locales to suppress setlocale warnings
RUN apt-get update && apt-get install -y --no-install-recommends locales && \
sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen && \
Expand Down
4 changes: 4 additions & 0 deletions services/cloud-agent-next/Dockerfile.dev
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ RUN GLAB_VERSION="1.93.0" \
&& dpkg -i /tmp/glab.deb \
&& rm /tmp/glab.deb

COPY scripts/kilo-git-credential /opt/kilo-cloud/kilo-git-credential
RUN chmod +x /opt/kilo-cloud/kilo-git-credential \
&& ln -sf /opt/kilo-cloud/kilo-git-credential /usr/local/bin/kilo-git-credential

# Install pnpm and kilocode
RUN npm install -g pnpm @kilocode/cli@${KILOCODE_CLI_VERSION}

Expand Down
4 changes: 4 additions & 0 deletions services/cloud-agent-next/Dockerfile.dind
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ RUN GLAB_VERSION="1.93.0" \
&& chmod +x /usr/local/bin/glab \
&& rm -rf /tmp/glab.tar.gz /tmp/bin

COPY scripts/kilo-git-credential /opt/kilo-cloud/kilo-git-credential
RUN chmod +x /opt/kilo-cloud/kilo-git-credential \
&& ln -sf /opt/kilo-cloud/kilo-git-credential /usr/local/bin/kilo-git-credential

# 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.
Expand Down
49 changes: 49 additions & 0 deletions services/cloud-agent-next/scripts/kilo-git-credential
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
#!/bin/sh
set -eu

case "${1:-}" in
get) ;;
*) exit 0 ;;
esac

protocol=
host=
while IFS= read -r line || [ -n "$line" ]; do
[ -z "$line" ] && break
case "$line" in
protocol=*) protocol="${line#protocol=}" ;;
host=*) host="${line#host=}" ;;
esac
done

[ "$protocol" = https ] || exit 0

host="${host%%:*}"
Comment thread
eshurakov marked this conversation as resolved.
Outdated
username=
password=

case "$host" in
github.com)
username=x-access-token
password="${GH_TOKEN:-}"
;;
bitbucket.org)
username=x-token-auth
password="${BITBUCKET_TOKEN:-}"
;;
*)
gitlab_host="${GITLAB_HOST:-gitlab.com}"
gitlab_host="${gitlab_host#https://}"
gitlab_host="${gitlab_host#http://}"
gitlab_host="${gitlab_host%%/*}"
gitlab_host="${gitlab_host%%:*}"
if [ "$host" = "$gitlab_host" ]; then
username=oauth2
password="${GITLAB_TOKEN:-}"
fi
;;
esac

[ -n "$password" ] || exit 0

printf 'username=%s\npassword=%s\n' "$username" "$password"
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type { Env, SandboxInstance } from '../../types.js';
import type { CredentialContainment, SessionMetadata } from '../../persistence/session-metadata.js';
import { WrapperClient, WrapperError } from '../../kilo/wrapper-client.js';
import { WRAPPER_VERSION } from '../../shared/wrapper-version.js';
import { SYSTEM_GIT_CONFIG_ENV } from '../../shared/runtime-environment.js';
import type { EnsureWrapperRequest } from '../protocol.js';
import { CloudflareAgentSandbox, deriveSetupEnvironment } from './cloudflare-agent-sandbox.js';
import { buildWorkspaceBackupCandidate } from '../../workspace-backup-cache.js';
Expand Down Expand Up @@ -251,6 +252,33 @@ describe('deriveSetupEnvironment', () => {
)
).toBeNull();
});

it('ignores git config keys the runtime strips before the sandbox starts', () => {
expect(
deriveSetupEnvironment(
{
envVars: {
CACHE_VARIANT: 'profile-value',
GIT_CONFIG_GLOBAL: '/tmp/evil.gitconfig',
GIT_CONFIG_KEY_2: 'credential.helper',
},
},
{ CACHE_VARIANT: 'resolved-profile-value', ...SYSTEM_GIT_CONFIG_ENV }
)
).toEqual({
variables: { CACHE_VARIANT: 'resolved-profile-value' },
secretIdentities: {},
});
});

it('keeps the pinned git config in the cache key when a profile declares it', () => {
expect(
deriveSetupEnvironment({ envVars: { GIT_CONFIG_COUNT: '99' } }, { ...SYSTEM_GIT_CONFIG_ENV })
).toEqual({
variables: { GIT_CONFIG_COUNT: '2' },
secretIdentities: {},
});
});
});

describe('CloudflareAgentSandbox', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
import { SANDBOX_WORKSPACE_PROBE_TIMEOUT_MESSAGE } from '../../sandbox-recovery.js';
import { withTimeout } from '@kilocode/worker-utils';
import { WRAPPER_VERSION } from '../../shared/wrapper-version.js';
import { isStrippedGitConfigEnvVar } from '../../shared/runtime-environment.js';
import { ExecutionError } from '../../execution/errors.js';
import { readProfileBundle, type SessionProfileBundle } from '../../session-profile.js';
import {
Expand Down Expand Up @@ -157,6 +158,11 @@ export function deriveSetupEnvironment(
const variables: Record<string, string> = {};
for (const key of Object.keys(profile.envVars ?? {})) {
if (Object.hasOwn(encryptedSecrets, key)) continue;
// The runtime environment owns git's configuration and drops these before
// the sandbox starts, so they are absent from `materializedEnvironment` by
// design. Treating that as an unresolved variable would silently disable
// workspace snapshots for every session on the profile.
if (isStrippedGitConfigEnvVar(key)) continue;
if (!Object.hasOwn(materializedEnvironment, key)) return null;
const value = materializedEnvironment[key];
if (value === undefined) return null;
Expand Down
191 changes: 191 additions & 0 deletions services/cloud-agent-next/src/kilo-git-credential.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, describe, expect, it } from 'vitest';

const scriptPath = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../scripts/kilo-git-credential'
);
const tempDirs: string[] = [];

afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});

type HelperEnv = {
GH_TOKEN?: string;
GITLAB_TOKEN?: string;
GITLAB_HOST?: string;
BITBUCKET_TOKEN?: string;
};

function credentialInput(protocol: string, host: string): string {
return `protocol=${protocol}\nhost=${host}\n\n`;
}

function runHelper(
action: string | undefined,
input: string,
env: HelperEnv = {}
): { status: number | null; stdout: string; home: string } {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'kilo-git-credential-'));
tempDirs.push(home);
const result = spawnSync('sh', action === undefined ? [scriptPath] : [scriptPath, action], {
encoding: 'utf8',
input,
env: {
...process.env,
HOME: home,
GH_TOKEN: undefined,
GITLAB_TOKEN: undefined,
GITLAB_HOST: undefined,
BITBUCKET_TOKEN: undefined,
...env,
},
});
return { status: result.status, stdout: result.stdout, home };
}

function parseCredential(stdout: string): {
username: string | undefined;
password: string | undefined;
} {
let username: string | undefined;
let password: string | undefined;
for (const line of stdout.split('\n')) {
if (line.startsWith('username=')) {
username = line.slice('username='.length);
} else if (line.startsWith('password=')) {
password = line.slice('password='.length);
}
}
return { username, password };
}

function expectPassword(actual: string | undefined, expected: string): void {
if (actual !== expected) {
throw new Error('password did not match the provided token');
}
}

describe('kilo-git-credential', () => {
it('returns GitHub credentials including a capability token', () => {
const token = 'kgh2.cap';
const result = runHelper('get', credentialInput('https', 'github.com'), { GH_TOKEN: token });
expect(result.status).toBe(0);
const parsed = parseCredential(result.stdout);
expect(parsed.username).toBe('x-access-token');
expectPassword(parsed.password, token);
});

it('returns GitLab credentials for gitlab.com', () => {
const token = 'kgl2.cap';
const result = runHelper('get', credentialInput('https', 'gitlab.com'), {
GITLAB_TOKEN: token,
});
expect(result.status).toBe(0);
const parsed = parseCredential(result.stdout);
expect(parsed.username).toBe('oauth2');
expectPassword(parsed.password, token);
});

it('returns GitLab credentials for a custom GITLAB_HOST and ignores gitlab.com', () => {
const token = 'kgl2.custom';
const env = { GITLAB_TOKEN: token, GITLAB_HOST: 'gitlab.example.com' };
const custom = runHelper('get', credentialInput('https', 'gitlab.example.com'), env);
expect(custom.status).toBe(0);
const parsed = parseCredential(custom.stdout);
expect(parsed.username).toBe('oauth2');
expectPassword(parsed.password, token);

const defaultHost = runHelper('get', credentialInput('https', 'gitlab.com'), env);
expect(defaultHost.status).toBe(0);
expect(defaultHost.stdout).toBe('');
});

it('matches GITLAB_HOST and requested host when either includes a port', () => {
const token = 'kgl2.port';
const env = { GITLAB_TOKEN: token, GITLAB_HOST: 'gitlab.example.com:8443' };
const requestedWithPort = runHelper(
'get',
credentialInput('https', 'gitlab.example.com:8443'),
env
);
expect(requestedWithPort.status).toBe(0);
const parsedRequested = parseCredential(requestedWithPort.stdout);
expect(parsedRequested.username).toBe('oauth2');
expectPassword(parsedRequested.password, token);

const requestedWithoutPort = runHelper(
'get',
credentialInput('https', 'gitlab.example.com'),
env
);
expect(requestedWithoutPort.status).toBe(0);
const parsedBare = parseCredential(requestedWithoutPort.stdout);
expect(parsedBare.username).toBe('oauth2');
expectPassword(parsedBare.password, token);
});

it('accepts GITLAB_HOST with a scheme and path', () => {
const token = 'kgl2.scheme';
const result = runHelper('get', credentialInput('https', 'gitlab.example.com'), {
GITLAB_TOKEN: token,
GITLAB_HOST: 'https://gitlab.example.com/gitlab',
});
expect(result.status).toBe(0);
const parsed = parseCredential(result.stdout);
expect(parsed.username).toBe('oauth2');
expectPassword(parsed.password, token);
});

it('returns Bitbucket credentials', () => {
const token = 'kbb1.cap';
const result = runHelper('get', credentialInput('https', 'bitbucket.org'), {
BITBUCKET_TOKEN: token,
});
expect(result.status).toBe(0);
const parsed = parseCredential(result.stdout);
expect(parsed.username).toBe('x-token-auth');
expectPassword(parsed.password, token);
});

it('prints nothing for an unmatched host', () => {
const result = runHelper('get', credentialInput('https', 'example.com'), {
GH_TOKEN: 'kgh2.unused',
GITLAB_TOKEN: 'kgl2.unused',
BITBUCKET_TOKEN: 'kbb1.unused',
});
expect(result.status).toBe(0);
expect(result.stdout).toBe('');
});

it('prints nothing when the matching https token is missing', () => {
const result = runHelper('get', credentialInput('https', 'github.com'));
expect(result.status).toBe(0);
expect(result.stdout).toBe('');
});

it('prints nothing for http', () => {
const result = runHelper('get', credentialInput('http', 'github.com'), {
GH_TOKEN: 'kgh2.unused',
});
expect(result.status).toBe(0);
expect(result.stdout).toBe('');
});

it.each(['store', 'erase', 'unknown'] as const)('%s exits 0 without writing files', action => {
const result = runHelper(action, credentialInput('https', 'github.com'), {
GH_TOKEN: 'kgh2.unused',
});
expect(result.status).toBe(0);
expect(result.stdout).toBe('');
expect(fs.existsSync(path.join(result.home, '.git-credentials'))).toBe(false);
expect(fs.readdirSync(result.home)).toEqual([]);
});
});
Loading