diff --git a/dev/local/env-sync/plan.test.ts b/dev/local/env-sync/plan.test.ts index ead86bbe26..460ae82f1a 100644 --- a/dev/local/env-sync/plan.test.ts +++ b/dev/local/env-sync/plan.test.ts @@ -57,6 +57,39 @@ function computeCloudAgentNextPlan(root: string) { return plan; } +const gitTokenServiceDir = 'services/git-token-service'; +const annotatedCapabilitySecretBinding = `{ + "binding": "SCM_SESSION_CAPABILITY_ENCRYPTION_KEY", + "store_id": "store-id", + // @dev-generate base64 32 + "secret_name": "SCM_SESSION_CAPABILITY_ENCRYPTION_KEY_DEV" +}`; + +function createGitTokenServiceRepo(options: { + envLocal?: string; + devSecretBinding?: string; + rootSecretBinding?: string; +}): TestRepo { + const rootSecrets = options.rootSecretBinding + ? `"secrets_store_secrets": [${options.rootSecretBinding}],` + : ''; + return createRepo({ + '.env.local': options.envLocal ?? '', + [`${gitTokenServiceDir}/package.json`]: JSON.stringify({ + scripts: { dev: 'wrangler dev --env dev' }, + }), + [`${gitTokenServiceDir}/.dev.vars.example`]: '', + [`${gitTokenServiceDir}/wrangler.jsonc`]: `{ + ${rootSecrets} + "env": { + "dev": { + "secrets_store_secrets": [${options.devSecretBinding ?? annotatedCapabilitySecretBinding}] + } + } + }`, + }); +} + function withFakePnpm(output: string, fn: () => void): void { const binDir = fs.mkdtempSync(path.join(os.tmpdir(), 'env-sync-bin-')); const oldPath = process.env.PATH; @@ -261,6 +294,132 @@ test('writes example defaults to .dev.vars when they override wrangler vars', () } }); +test('generates an annotated missing secret directly into the local Secrets Store', () => { + const repo = createGitTokenServiceRepo({}); + try { + withFakePnpm('', () => { + const plan = computePlan(repo.root, new Set(['cloudflare-git-token-service'])); + assert.equal(plan.missingEnvLocal, false); + assert.deepEqual(plan.devVarsChanges, []); + assert.deepEqual(plan.envLocalAutoCreates, []); + assert.deepEqual(plan.secretStoreWarnings, []); + assert.equal(plan.secretStoreAutoCreates.length, 1); + const [create] = plan.secretStoreAutoCreates; + assert.ok(create); + assert.equal(create.binding.secret_name, 'SCM_SESSION_CAPABILITY_ENCRYPTION_KEY_DEV'); + assert.equal(create.sourceKey, '@dev-generate base64 32'); + assert.equal(Buffer.from(create.value, 'base64').length, 32); + }); + } finally { + repo.cleanup(); + } +}); + +test('generates an annotated secret instead of copying a plaintext env source', () => { + const repo = createGitTokenServiceRepo({ + envLocal: 'SCM_SESSION_CAPABILITY_ENCRYPTION_KEY=plaintext-source\n', + }); + try { + withFakePnpm('', () => { + const plan = computePlan(repo.root, new Set(['cloudflare-git-token-service'])); + const [create] = plan.secretStoreAutoCreates; + assert.ok(create); + assert.equal(create.sourceKey, '@dev-generate base64 32'); + assert.notEqual(create.value, 'plaintext-source'); + assert.equal(Buffer.from(create.value, 'base64').length, 32); + }); + } finally { + repo.cleanup(); + } +}); + +test('applies generation only to the annotated Secrets Store binding', () => { + const repo = createGitTokenServiceRepo({ + rootSecretBinding: `{ + "binding": "SHARED_SECRET", + "store_id": "shared-store", + // @dev-generate base64 32 + "secret_name": "SHARED_SECRET" + }`, + devSecretBinding: `{ + "binding": "SHARED_SECRET", + "store_id": "shared-store", + "secret_name": "SHARED_SECRET" + }`, + }); + try { + withFakePnpm('', () => { + const plan = computePlan(repo.root, new Set(['cloudflare-git-token-service'])); + assert.deepEqual(plan.secretStoreAutoCreates, []); + assert.deepEqual(plan.secretStoreWarnings, [ + { + workerDir: 'services/git-token-service', + bindings: [ + { + binding: 'SHARED_SECRET', + store_id: 'shared-store', + secret_name: 'SHARED_SECRET', + }, + ], + }, + ]); + }); + } finally { + repo.cleanup(); + } +}); + +test('rejects malformed Secrets Store generation annotations', () => { + const repo = createGitTokenServiceRepo({ + devSecretBinding: `{ + "binding": "SCM_SESSION_CAPABILITY_ENCRYPTION_KEY", + "store_id": "store-id", + // @dev-generate base64 nope + "secret_name": "SCM_SESSION_CAPABILITY_ENCRYPTION_KEY_DEV" + }`, + }); + try { + assert.throws( + () => computePlan(repo.root, new Set(['cloudflare-git-token-service'])), + /Invalid @dev-generate directive/ + ); + } finally { + repo.cleanup(); + } +}); + +test('rejects reserved generated-secret metadata in source Wrangler config', () => { + const repo = createGitTokenServiceRepo({ + devSecretBinding: `{ + "binding": "SCM_SESSION_CAPABILITY_ENCRYPTION_KEY", + "store_id": "store-id", + "__kilo\\u005fdev_generated_base64_bytes_0": 4096, + "secret_name": "SCM_SESSION_CAPABILITY_ENCRYPTION_KEY_DEV" + }`, + }); + try { + assert.throws( + () => computePlan(repo.root, new Set(['cloudflare-git-token-service'])), + /reserved for generated-secret metadata/ + ); + } finally { + repo.cleanup(); + } +}); + +test('preserves an existing annotated local Secrets Store secret', () => { + const repo = createGitTokenServiceRepo({}); + try { + withFakePnpm('SCM_SESSION_CAPABILITY_ENCRYPTION_KEY_DEV\n', () => { + const plan = computePlan(repo.root, new Set(['cloudflare-git-token-service'])); + assert.deepEqual(plan.secretStoreAutoCreates, []); + assert.deepEqual(plan.secretStoreWarnings, []); + }); + } finally { + repo.cleanup(); + } +}); + test('auto-creates event-service NEXTAUTH Secrets Store binding from .env.local', () => { const repo = createRepo({ '.env.local': 'NEXTAUTH_SECRET=local-nextauth-secret\n', diff --git a/dev/local/env-sync/plan.ts b/dev/local/env-sync/plan.ts index 050b32b6c9..e27869b7d6 100644 --- a/dev/local/env-sync/plan.ts +++ b/dev/local/env-sync/plan.ts @@ -1,4 +1,5 @@ import { spawnSync } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; @@ -219,12 +220,124 @@ function isProvidedByWranglerVars( return entry.defaultValue === wranglerValue; } +const DEV_GENERATED_BASE64_BYTES_FIELD_PREFIX = '__kilo_dev_generated_base64_bytes_'; +const MAX_DEV_GENERATED_SECRET_BYTES = 1024; + +function injectDevSecretGenerationMetadata(content: string): string { + let directiveIndex = 0; + const transformed = content.replace( + /^(\s*)\/\/\s*@dev-generate([^\r\n]*)$/gm, + (_line, indent, args: string) => { + const directive = args.trim().match(/^base64\s+(\d+)$/); + if (!directive) { + throw new Error( + 'Invalid @dev-generate directive; expected `// @dev-generate base64 `' + ); + } + + const bytes = Number(directive[1]); + if (!Number.isSafeInteger(bytes) || bytes < 1 || bytes > MAX_DEV_GENERATED_SECRET_BYTES) { + throw new Error( + `@dev-generate bytes must be between 1 and ${MAX_DEV_GENERATED_SECRET_BYTES}` + ); + } + + return `${indent}"${DEV_GENERATED_BASE64_BYTES_FIELD_PREFIX}${directiveIndex++}": ${bytes},`; + } + ); + + if (/\/\/\s*@dev-generate/.test(transformed)) { + throw new Error('@dev-generate must be a standalone comment inside a Secrets Store binding'); + } + return transformed; +} + +function rejectReservedDevSecretGenerationMetadata(value: unknown): void { + if (Array.isArray(value)) { + for (const item of value) rejectReservedDevSecretGenerationMetadata(item); + return; + } + if (!isJsonObject(value)) return; + + if (Object.keys(value).some(key => key.startsWith(DEV_GENERATED_BASE64_BYTES_FIELD_PREFIX))) { + throw new Error('@dev-generate field prefix is reserved for generated-secret metadata'); + } + for (const nestedValue of Object.values(value)) + rejectReservedDevSecretGenerationMetadata(nestedValue); +} + +function getDevGeneratedBase64Bytes(value: JsonObject): number | undefined { + const metadata = Object.entries(value).filter(([key]) => + key.startsWith(DEV_GENERATED_BASE64_BYTES_FIELD_PREFIX) + ); + if (metadata.length === 0) return undefined; + if (metadata.length > 1) { + throw new Error('A Secrets Store binding can have only one @dev-generate directive'); + } + + const bytes = metadata[0]?.[1]; + if ( + typeof bytes !== 'number' || + !Number.isSafeInteger(bytes) || + bytes < 1 || + bytes > MAX_DEV_GENERATED_SECRET_BYTES + ) { + throw new Error('Invalid @dev-generate metadata'); + } + return bytes; +} + +function validateDevSecretGenerationMetadata(value: unknown): void { + if (Array.isArray(value)) { + for (const item of value) validateDevSecretGenerationMetadata(item); + return; + } + if (!isJsonObject(value)) return; + + if (getDevGeneratedBase64Bytes(value) !== undefined) { + if ( + typeof value.binding !== 'string' || + typeof value.store_id !== 'string' || + typeof value.secret_name !== 'string' + ) { + throw new Error('@dev-generate must annotate a Secrets Store binding object'); + } + } + for (const nestedValue of Object.values(value)) validateDevSecretGenerationMetadata(nestedValue); +} + function extractSecretsStoreBindings(repoRoot: string, workerDir: string): SecretStoreBinding[] { - const config = readWranglerConfig(repoRoot, workerDir); - if (!config) return []; + const wranglerPath = path.join(repoRoot, workerDir, 'wrangler.jsonc'); + if (!fs.existsSync(wranglerPath)) return []; + + const content = fs.readFileSync(wranglerPath, 'utf-8'); + try { + const originalConfig = parseJsonc(content); + if (!isJsonObject(originalConfig)) return []; + rejectReservedDevSecretGenerationMetadata(originalConfig); + } catch (error) { + if (error instanceof Error && error.message.includes('@dev-generate')) throw error; + return []; + } + + let annotatedConfig: JsonObject; + try { + const parsed = parseJsonc(injectDevSecretGenerationMetadata(content)); + if (!isJsonObject(parsed)) return []; + validateDevSecretGenerationMetadata(parsed); + annotatedConfig = parsed; + } catch (error) { + if (error instanceof Error && error.message.includes('@dev-generate')) throw error; + if (content.includes('@dev-generate')) { + throw new Error('@dev-generate must annotate a Secrets Store binding object', { + cause: error, + }); + } + return []; + } const envName = detectWranglerEnv(repoRoot, workerDir); - const secretsSection = getWranglerSection(config, envName, 'secrets_store_secrets'); + const secretsSection = getWranglerSection(annotatedConfig, envName, 'secrets_store_secrets'); if (!Array.isArray(secretsSection)) return []; const bindings: SecretStoreBinding[] = []; @@ -240,7 +353,13 @@ function extractSecretsStoreBindings(repoRoot: string, workerDir: string): Secre ) { continue; } - bindings.push({ binding, store_id: storeId, secret_name: secretName }); + const generatedBytes = getDevGeneratedBase64Bytes(secret); + bindings.push({ + binding, + store_id: storeId, + secret_name: secretName, + ...(typeof generatedBytes === 'number' ? { devGeneratedBase64Bytes: generatedBytes } : {}), + }); } return bindings; } @@ -570,8 +689,17 @@ function computePlan(repoRoot: string, serviceFilter?: Set): EnvSyncPlan continue; // Secret exists, nothing to do } - const source = resolveSecretStoreSource(b.secret_name, envLocal, localSecretSources); + if (b.devGeneratedBase64Bytes) { + secretStoreAutoCreates.push({ + workerDir: svc.dir, + binding: b, + sourceKey: `@dev-generate base64 ${b.devGeneratedBase64Bytes}`, + value: randomBytes(b.devGeneratedBase64Bytes).toString('base64'), + }); + continue; + } + const source = resolveSecretStoreSource(b.secret_name, envLocal, localSecretSources); if (source) { // Can auto-create from .env.local or another local worker's dev vars. secretStoreAutoCreates.push({ diff --git a/dev/local/env-sync/types.ts b/dev/local/env-sync/types.ts index ff5a38caa5..8366dc19fe 100644 --- a/dev/local/env-sync/types.ts +++ b/dev/local/env-sync/types.ts @@ -33,6 +33,7 @@ type SecretStoreBinding = { binding: string; store_id: string; secret_name: string; + devGeneratedBase64Bytes?: number; }; type SecretStoreWarning = { diff --git a/services/cloud-agent-next/Dockerfile.dev b/services/cloud-agent-next/Dockerfile.dev index 199e8bdf93..9d9f2c195b 100644 --- a/services/cloud-agent-next/Dockerfile.dev +++ b/services/cloud-agent-next/Dockerfile.dev @@ -11,7 +11,7 @@ ARG KILOCODE_CLI_VERSION="7.3.12" # # This builds kilo-cli from source and copies the linux-x64 binary here. -# Install latest stable git + git-lfs from the git-core PPA, plus supporting CLI tools, +# Install latest stable git + git-lfs from the git-core PPA, GitHub CLI, plus supporting CLI tools, # and generate locales to suppress setlocale warnings. # The default Ubuntu git (2.34.1 on 22.04) is outdated; the git-core PPA ships the latest # stable release. git-lfs is installed from the same PPA so it stays in lockstep with git, @@ -21,12 +21,20 @@ RUN set -eux; \ apt-get install -y --no-install-recommends \ ca-certificates \ gnupg \ - software-properties-common; \ + software-properties-common \ + wget; \ add-apt-repository -y ppa:git-core/ppa; \ + mkdir -p -m 755 /etc/apt/keyrings; \ + wget -nv -O /etc/apt/keyrings/githubcli-archive-keyring.gpg https://cli.github.com/packages/githubcli-archive-keyring.gpg; \ + chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg; \ + mkdir -p -m 755 /etc/apt/sources.list.d; \ + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ + | tee /etc/apt/sources.list.d/github-cli.list > /dev/null; \ apt-get update; \ apt-get install -y --no-install-recommends \ git \ git-lfs \ + gh \ jq \ locales \ openssh-client \ diff --git a/services/cloud-agent-next/src/index.ts b/services/cloud-agent-next/src/index.ts index ccc57f09f3..ff6344a33d 100644 --- a/services/cloud-agent-next/src/index.ts +++ b/services/cloud-agent-next/src/index.ts @@ -1,4 +1,4 @@ export { default } from './server.js'; -export { Sandbox, Sandbox as SandboxSmall, Sandbox as SandboxDIND } from '@cloudflare/sandbox'; +export { Sandbox, SandboxSmall, SandboxDIND, ContainerProxy } from './sandbox-outbound.js'; export { CloudAgentSession } from './persistence/CloudAgentSession.js'; export { UserKiloFacade } from './kilo-facade/user-kilo-facade.js'; diff --git a/services/cloud-agent-next/src/kilo/devcontainer.test.ts b/services/cloud-agent-next/src/kilo/devcontainer.test.ts index f20bab63af..e9b666db1f 100644 --- a/services/cloud-agent-next/src/kilo/devcontainer.test.ts +++ b/services/cloud-agent-next/src/kilo/devcontainer.test.ts @@ -16,8 +16,10 @@ import { bringUpDevContainer, buildRestoreCommand, buildOverrideConfig, + buildDevContainerTrustedCaBundleSetupCommand, detectDevContainer, getDevContainerOverridePath, + getDevContainerTrustedCaBundlePath, KILO_AGENT_SESSION_LABEL, KILO_WRAPPER_PORT_LABEL, mergeDevContainerConfig, @@ -53,6 +55,7 @@ describe('sandbox image versions', () => { expect(dockerfile).toContain(`FROM docker.io/cloudflare/sandbox:${sandboxVersion}`); expect(devDockerfile).toContain(`FROM docker.io/cloudflare/sandbox:${sandboxVersion}`); + expect(devDockerfile).toMatch(/apt-get install[^;]+\bgh\b/s); expect(dindDockerfile).toContain(`ARG SANDBOX_VERSION="${sandboxVersion}"`); }); }); @@ -197,6 +200,9 @@ describe('bringUpDevContainer', () => { const commands = execCalls.map(([cmd]) => cmd); const bootstrapCall = execCalls.find(([cmd]) => cmd.includes('nvm install --lts')); expect(preflightCount).toBe(2); + expect(commands).toContain( + "source=${GIT_SSL_CAINFO:-/etc/ssl/certs/ca-certificates.crt} && test -f \"$source\" && mkdir -p '/home/agent_xyz/.kilocode/platform' && cp \"$source\" '/home/agent_xyz/.kilocode/platform/outer-trusted-ca-bundle.crt' && chmod 444 '/home/agent_xyz/.kilocode/platform/outer-trusted-ca-bundle.crt'" + ); expect(commands.some(cmd => cmd.includes('bun --version'))).toBe(true); expect(commands.some(cmd => cmd.includes('nvm install --lts'))).toBe(true); expect(commands.some(cmd => cmd.includes('nvm use --lts'))).toBe(false); @@ -328,6 +334,7 @@ describe('buildOverrideConfig', () => { 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', + 'source=/home/agent_xyz/.kilocode/platform/outer-trusted-ca-bundle.crt,target=/home/agent_xyz/.kilocode/platform/outer-trusted-ca-bundle.crt,type=bind,readonly', ]); }); @@ -344,11 +351,21 @@ describe('buildOverrideConfig', () => { ]); }); - it('sets HOME without exposing the outer Docker socket', () => { + it('sets platform-owned nested trust env for lifecycle hooks and remote execution', () => { const cfg = buildOverrideConfig(baseOpts); + const trustedCaBundle = '/home/agent_xyz/.kilocode/platform/outer-trusted-ca-bundle.crt'; + const platformTrustEnv = { + GIT_SSL_CAINFO: trustedCaBundle, + SSL_CERT_FILE: trustedCaBundle, + CURL_CA_BUNDLE: trustedCaBundle, + REQUESTS_CA_BUNDLE: trustedCaBundle, + NODE_EXTRA_CA_CERTS: trustedCaBundle, + }; + expect(cfg.containerEnv).toEqual(platformTrustEnv); expect(cfg.remoteEnv).toEqual({ HOME: '/home/agent_xyz', KILO_CLOUD_AGENT: '1', + ...platformTrustEnv, }); }); @@ -364,6 +381,9 @@ describe('writeMergedOverrideConfig', () => { 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( + 'source=/home/agent_xyz/.kilocode/platform/outer-trusted-ca-bundle.crt,target=/home/agent_xyz/.kilocode/platform/outer-trusted-ca-bundle.crt,type=bind,readonly' + ); expect(cmd).toContain(`${KILO_AGENT_SESSION_LABEL}=agent_xyz`); expect(cmd).toContain(`${KILO_WRAPPER_PORT_LABEL}=5050`); return { exitCode: 0 }; @@ -414,6 +434,7 @@ describe('mergeDevContainerConfig', () => { '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', + 'source=/home/agent_xyz/.kilocode/platform/outer-trusted-ca-bundle.crt,target=/home/agent_xyz/.kilocode/platform/outer-trusted-ca-bundle.crt,type=bind,readonly', ]); expect(merged.runArgs).toEqual([ '--env', @@ -426,10 +447,22 @@ describe('mergeDevContainerConfig', () => { '--label', `${KILO_WRAPPER_PORT_LABEL}=5050`, ]); + expect(merged.containerEnv).toEqual({ + GIT_SSL_CAINFO: '/home/agent_xyz/.kilocode/platform/outer-trusted-ca-bundle.crt', + SSL_CERT_FILE: '/home/agent_xyz/.kilocode/platform/outer-trusted-ca-bundle.crt', + CURL_CA_BUNDLE: '/home/agent_xyz/.kilocode/platform/outer-trusted-ca-bundle.crt', + REQUESTS_CA_BUNDLE: '/home/agent_xyz/.kilocode/platform/outer-trusted-ca-bundle.crt', + NODE_EXTRA_CA_CERTS: '/home/agent_xyz/.kilocode/platform/outer-trusted-ca-bundle.crt', + }); expect(merged.remoteEnv).toEqual({ USER_ENV: '1', HOME: '/home/agent_xyz', KILO_CLOUD_AGENT: '1', + GIT_SSL_CAINFO: '/home/agent_xyz/.kilocode/platform/outer-trusted-ca-bundle.crt', + SSL_CERT_FILE: '/home/agent_xyz/.kilocode/platform/outer-trusted-ca-bundle.crt', + CURL_CA_BUNDLE: '/home/agent_xyz/.kilocode/platform/outer-trusted-ca-bundle.crt', + REQUESTS_CA_BUNDLE: '/home/agent_xyz/.kilocode/platform/outer-trusted-ca-bundle.crt', + NODE_EXTRA_CA_CERTS: '/home/agent_xyz/.kilocode/platform/outer-trusted-ca-bundle.crt', }); }); @@ -442,6 +475,26 @@ describe('mergeDevContainerConfig', () => { expect(merged.remoteUser).toBe('root'); }); + it('preserves user env while ensuring platform trust paths win', () => { + const merged = mergeDevContainerConfig( + { + image: 'debian:bookworm', + containerEnv: { USER_CONTAINER_ENV: '1', GIT_SSL_CAINFO: '/user/container-ca.crt' }, + remoteEnv: { USER_REMOTE_ENV: '1', GIT_SSL_CAINFO: '/user/remote-ca.crt' }, + }, + { sessionHome: '/home/agent_xyz', wrapperPort: 5050, agentSessionId: 'agent_xyz' } + ); + + expect(merged.containerEnv).toMatchObject({ + USER_CONTAINER_ENV: '1', + GIT_SSL_CAINFO: '/home/agent_xyz/.kilocode/platform/outer-trusted-ca-bundle.crt', + }); + expect(merged.remoteEnv).toMatchObject({ + USER_REMOTE_ENV: '1', + GIT_SSL_CAINFO: '/home/agent_xyz/.kilocode/platform/outer-trusted-ca-bundle.crt', + }); + }); + it('removes host-side initializeCommand while preserving in-container lifecycle hooks', () => { const merged = mergeDevContainerConfig( { @@ -473,6 +526,7 @@ describe('mergeDevContainerConfig', () => { 'source=/workspace/cache,target=/cache,type=bind', 'source=/opt/kilo-cloud,target=/opt/kilo-cloud,type=bind,readonly', 'source=/home/agent_xyz,target=/home/agent_xyz,type=bind', + 'source=/home/agent_xyz/.kilocode/platform/outer-trusted-ca-bundle.crt,target=/home/agent_xyz/.kilocode/platform/outer-trusted-ca-bundle.crt,type=bind,readonly', ]); }); @@ -651,6 +705,26 @@ describe('buildRestoreCommand', () => { }); }); +describe('nested devcontainer trusted CA bundle', () => { + it('uses a stable path inside the session-home bind mount', () => { + expect(getDevContainerTrustedCaBundlePath('/home/agent_xyz')).toBe( + '/home/agent_xyz/.kilocode/platform/outer-trusted-ca-bundle.crt' + ); + }); + + it('copies the effective outer bundle with safe shell quoting and read-only permissions', () => { + const command = buildDevContainerTrustedCaBundleSetupCommand("/home/agent_'xyz"); + expect(command).toContain('source=${GIT_SSL_CAINFO:-/etc/ssl/certs/ca-certificates.crt}'); + expect(command).toContain("mkdir -p '/home/agent_'\\''xyz/.kilocode/platform'"); + expect(command).toContain( + "cp \"$source\" '/home/agent_'\\''xyz/.kilocode/platform/outer-trusted-ca-bundle.crt'" + ); + expect(command).toContain( + "chmod 444 '/home/agent_'\\''xyz/.kilocode/platform/outer-trusted-ca-bundle.crt'" + ); + }); +}); + describe('getDevContainerOverridePath', () => { it('falls back to the legacy deterministic temp path without config metadata', () => { expect(getDevContainerOverridePath('agent_xyz')).toBe( diff --git a/services/cloud-agent-next/src/kilo/devcontainer.ts b/services/cloud-agent-next/src/kilo/devcontainer.ts index e1a1fe9d5b..e9d4a4bc40 100644 --- a/services/cloud-agent-next/src/kilo/devcontainer.ts +++ b/services/cloud-agent-next/src/kilo/devcontainer.ts @@ -114,6 +114,33 @@ export function getDevContainerOverridePath( /** Label that records the wrapper HTTP port published by the dev container. */ export const KILO_WRAPPER_PORT_LABEL = 'kilo.wrapperPort'; +export function getDevContainerTrustedCaBundlePath(sessionHome: string): string { + return `${sessionHome}/${DEVCONTAINER_TRUSTED_CA_BUNDLE_RELATIVE_PATH}`; +} + +export function buildDevContainerTrustedCaBundleSetupCommand(sessionHome: string): string { + const trustedCaBundle = getDevContainerTrustedCaBundlePath(sessionHome); + const trustedCaBundleDir = pathPosix.dirname(trustedCaBundle); + return [ + `source=\${GIT_SSL_CAINFO:-${OUTER_TRUSTED_CA_BUNDLE_FALLBACK}}`, + 'test -f "$source"', + `mkdir -p ${shellQuote(trustedCaBundleDir)}`, + `cp "$source" ${shellQuote(trustedCaBundle)}`, + `chmod 444 ${shellQuote(trustedCaBundle)}`, + ].join(' && '); +} + +function buildDevContainerTrustEnv(sessionHome: string): Record { + const trustedCaBundle = getDevContainerTrustedCaBundlePath(sessionHome); + return { + GIT_SSL_CAINFO: trustedCaBundle, + SSL_CERT_FILE: trustedCaBundle, + CURL_CA_BUNDLE: trustedCaBundle, + REQUESTS_CA_BUNDLE: trustedCaBundle, + NODE_EXTRA_CA_CERTS: trustedCaBundle, + }; +} + /** * Pinned kilo CLI version installed *inside* the dev container. * @@ -125,6 +152,9 @@ export const KILO_CLI_VERSION = '7.3.12'; const DEVCONTAINER_RUNTIME_BUN_VERSION = '1.3.14'; const DEVCONTAINER_RUNTIME_BOOTSTRAP_TIMEOUT_MS = 10 * 60 * 1000; +const OUTER_TRUSTED_CA_BUNDLE_FALLBACK = '/etc/ssl/certs/ca-certificates.crt'; +const DEVCONTAINER_TRUSTED_CA_BUNDLE_RELATIVE_PATH = + '.kilocode/platform/outer-trusted-ca-bundle.crt'; /** `devcontainer up` prints multiple JSON lines on stdout — we look for this final line. */ const UP_OUTCOME_SUCCESS = 'success'; @@ -334,9 +364,20 @@ export async function bringUpDevContainer( // `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"`, + `mkdir -p ${shellQuote(`${sessionHome}/.cache`)} ${shellQuote(`${sessionHome}/.local/share/kilo`)} ${shellQuote(`${sessionHome}/tmp`)}`, + { timeout: 10_000 } + ); + const trustedCaSetupResult = await session.exec( + buildDevContainerTrustedCaBundleSetupCommand(sessionHome), { timeout: 10_000 } ); + if (trustedCaSetupResult.exitCode !== 0) { + throw new DevContainerUpError( + `Failed to prepare dev container trusted CA bundle (exit ${trustedCaSetupResult.exitCode})`, + trustedCaSetupResult.stdout ?? '', + trustedCaSetupResult.stderr ?? '' + ); + } onProgress?.('Preparing dev container configuration…'); @@ -473,7 +514,7 @@ export async function bringUpDevContainer( /** * Build the override JSON merged on top of the user's `devcontainer.json`. - * Adds Kilo's `mounts`/`runArgs`/`remoteEnv` without changing + * Adds Kilo's `mounts`/`runArgs`/`containerEnv`/`remoteEnv` without changing * `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 @@ -490,6 +531,8 @@ export function buildOverrideConfig(opts: { agentSessionId: string; }): Record { const { sessionHome, wrapperPort, agentSessionId } = opts; + const trustedCaBundle = getDevContainerTrustedCaBundlePath(sessionHome); + const trustEnv = buildDevContainerTrustEnv(sessionHome); return { remoteUser: 'root', @@ -498,6 +541,8 @@ export function buildOverrideConfig(opts: { `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`, + // Narrow read-only CA propagation for nested HTTPS tooling. + `source=${trustedCaBundle},target=${trustedCaBundle},type=bind,readonly`, ], runArgs: [ '--network=host', @@ -510,9 +555,11 @@ export function buildOverrideConfig(opts: { '--label', `${KILO_WRAPPER_PORT_LABEL}=${wrapperPort}`, ], + containerEnv: trustEnv, remoteEnv: { HOME: sessionHome, KILO_CLOUD_AGENT: '1', + ...trustEnv, }, }; } @@ -594,6 +641,10 @@ export function mergeDevContainerConfig( ...(typeof override.remoteUser === 'string' ? { remoteUser: override.remoteUser } : {}), mounts: [...baseMounts, ...overrideMounts], runArgs: [...sanitizeDevContainerRunArgs(sanitizedBaseConfig.runArgs), ...overrideRunArgs], + containerEnv: { + ...(isRecord(sanitizedBaseConfig.containerEnv) ? sanitizedBaseConfig.containerEnv : {}), + ...(isRecord(override.containerEnv) ? override.containerEnv : {}), + }, remoteEnv: { ...(isRecord(sanitizedBaseConfig.remoteEnv) ? sanitizedBaseConfig.remoteEnv : {}), ...(isRecord(override.remoteEnv) ? override.remoteEnv : {}), diff --git a/services/cloud-agent-next/src/persistence/types.ts b/services/cloud-agent-next/src/persistence/types.ts index f89e7196ec..a61246d69c 100644 --- a/services/cloud-agent-next/src/persistence/types.ts +++ b/services/cloud-agent-next/src/persistence/types.ts @@ -111,8 +111,12 @@ export type OperationResult = { }; export type PersistenceEnv = { - /** Durable Object namespace for Sandbox instances */ + /** Durable Object namespace for shared Sandbox instances */ Sandbox: DurableObjectNamespace; + /** Durable Object namespace for per-session Sandbox instances */ + SandboxSmall: DurableObjectNamespace; + /** Durable Object namespace for Docker-in-Docker Sandbox instances */ + SandboxDIND: DurableObjectNamespace; /** Durable Object namespace for CloudAgentSession metadata (SQLite-backed) with RPC support */ CLOUD_AGENT_SESSION: DurableObjectNamespace; /** Service binding for the session ingest worker */ diff --git a/services/cloud-agent-next/src/sandbox-id.test.ts b/services/cloud-agent-next/src/sandbox-id.test.ts index 865d78b24a..1d5a58ce80 100644 --- a/services/cloud-agent-next/src/sandbox-id.test.ts +++ b/services/cloud-agent-next/src/sandbox-id.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import type { Sandbox } from '@cloudflare/sandbox'; -import { generateSandboxId, getSandboxNamespace } from './sandbox-id.js'; +import { generateSandboxId, getOutboundContainerId, getSandboxNamespace } from './sandbox-id.js'; import type { Env } from './types.js'; describe('generateSandboxId', () => { @@ -291,3 +291,22 @@ describe('getSandboxNamespace', () => { expect(ns).toBe(mockSandbox); }); }); + +describe('getOutboundContainerId', () => { + it.each([ + ['org-a1b2c3', 'shared-do-id'], + ['ses-a1b2c3', 'small-do-id'], + ['dind-a1b2c3', 'dind-do-id'], + ])('derives %s from the selected sandbox namespace', (sandboxId, expected) => { + const createNamespace = (containerId: string) => ({ + idFromName: (name: string) => ({ toString: () => `${containerId}:${name}` }), + }); + const env = { + Sandbox: createNamespace('shared-do-id'), + SandboxSmall: createNamespace('small-do-id'), + SandboxDIND: createNamespace('dind-do-id'), + } as unknown as Env; + + expect(getOutboundContainerId(env, sandboxId)).toBe(`${expected}:${sandboxId}`); + }); +}); diff --git a/services/cloud-agent-next/src/sandbox-id.ts b/services/cloud-agent-next/src/sandbox-id.ts index ee88455fbd..770e93be5c 100644 --- a/services/cloud-agent-next/src/sandbox-id.ts +++ b/services/cloud-agent-next/src/sandbox-id.ts @@ -1,6 +1,8 @@ import type { SandboxId, Env } from './types.js'; import type { Sandbox } from '@cloudflare/sandbox'; +type SandboxNamespaceEnv = Pick; + /** * Parses a comma-separated org ID list into a set. * Returns an empty set when the value is falsy or blank. @@ -21,11 +23,18 @@ function parseOrgIdList(raw: string | undefined): Set { * - Per-session sandboxes (ses-* prefix) use SandboxSmall * - All others use Sandbox */ -export function getSandboxNamespace(env: Env, sandboxId: string): DurableObjectNamespace { +export function getSandboxNamespace( + env: SandboxNamespaceEnv, + sandboxId: string +): DurableObjectNamespace { if (sandboxId.startsWith('dind-')) return env.SandboxDIND; return sandboxId.startsWith('ses-') ? env.SandboxSmall : env.Sandbox; } +export function getOutboundContainerId(env: SandboxNamespaceEnv, sandboxId: string): string { + return getSandboxNamespace(env, sandboxId).idFromName(sandboxId).toString(); +} + async function hashToSandboxId(input: string, prefix: string): Promise { const encoder = new TextEncoder(); const hashBuffer = await crypto.subtle.digest('SHA-256', encoder.encode(input)); diff --git a/services/cloud-agent-next/src/sandbox-outbound.test.ts b/services/cloud-agent-next/src/sandbox-outbound.test.ts new file mode 100644 index 0000000000..6ea6f12dd7 --- /dev/null +++ b/services/cloud-agent-next/src/sandbox-outbound.test.ts @@ -0,0 +1,947 @@ +import { Buffer } from 'node:buffer'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const sdk = vi.hoisted(() => { + class StockSandbox {} + class ContainerProxy {} + return { StockSandbox, ContainerProxy }; +}); +const logging = vi.hoisted(() => { + const logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + withFields: vi.fn(), + }; + logger.withFields.mockReturnValue(logger); + return { logger }; +}); + +vi.mock('@cloudflare/sandbox', () => ({ + Sandbox: sdk.StockSandbox, + ContainerProxy: sdk.ContainerProxy, +})); +vi.mock('./logger.js', () => ({ logger: logging.logger })); + +import { + ContainerProxy, + Sandbox, + SandboxDIND, + SandboxSmall, + handleManagedScmOutbound, +} from './sandbox-outbound.js'; + +const CAPABILITY = 'kgh2.opaque'; +const LEGACY_CAPABILITY = 'kgh1.opaque'; +const GITLAB_CAPABILITY = 'kgl2.opaque'; +const LEGACY_GITLAB_CAPABILITY = 'kgl1.opaque'; +const OUTBOUND_CONTEXT = { containerId: 'container-test', className: 'Sandbox' }; +const REDEEMED_GIT_AUTHORIZATION = `Basic ${Buffer.from('x-access-token:upstream-token').toString('base64')}`; +const REDEEMED_GITLAB_AUTHORIZATION = `Basic ${Buffer.from('oauth2:upstream-token').toString('base64')}`; + +function basicCredential(password: string, scheme = 'Basic', username = 'x-access-token'): string { + return `${scheme} ${Buffer.from(`${username}:${password}`).toString('base64')}`; +} + +function createEnv( + redeemGitHubSessionCapability: ReturnType = vi.fn(), + redeemGitLabSessionCapability: ReturnType = vi.fn() +) { + return { + GIT_TOKEN_SERVICE: { redeemGitHubSessionCapability, redeemGitLabSessionCapability }, + } as never; +} + +function handleOutbound(request: Request, env: Cloudflare.Env): Promise { + return handleManagedScmOutbound(request, env, OUTBOUND_CONTEXT); +} + +function serializedLogCalls(): string { + return JSON.stringify({ + fields: logging.logger.withFields.mock.calls, + debug: logging.logger.debug.mock.calls, + info: logging.logger.info.mock.calls, + warn: logging.logger.warn.mock.calls, + }); +} + +describe('managed GitHub sandbox outbound configuration', () => { + it('enables catch-all outbound HTTPS interception on production sandboxes', () => { + expect(new Sandbox({} as never, {} as never)).toMatchObject({ + enableInternet: true, + interceptHttps: true, + }); + expect(new SandboxSmall({} as never, {} as never)).toMatchObject({ + enableInternet: true, + interceptHttps: true, + }); + expect(new SandboxDIND({} as never, {} as never)).toMatchObject({ + enableInternet: true, + interceptHttps: true, + }); + expect(ContainerProxy).toBe(sdk.ContainerProxy); + expect(Sandbox.outbound).toBe(handleManagedScmOutbound); + expect(SandboxSmall.outbound).toBe(handleManagedScmOutbound); + expect(SandboxDIND.outbound).toBe(handleManagedScmOutbound); + expect(Sandbox.outboundByHost).toBeUndefined(); + expect(SandboxSmall.outboundByHost).toBeUndefined(); + expect(SandboxDIND.outboundByHost).toBeUndefined(); + }); + + it('wires the catch-all handler to Git and API redemption behavior', async () => { + const redeemGitHubSessionCapability = vi.fn().mockResolvedValue({ + success: false, + reason: 'invalid_capability', + }); + const env = createEnv(redeemGitHubSessionCapability); + const handler = Sandbox.outbound; + if (!handler) throw new Error('Expected configured outbound handler'); + + await handler( + new Request('https://github.com/acme/repo.git/info/refs?service=git-upload-pack', { + headers: { Authorization: basicCredential(CAPABILITY) }, + }), + env, + { containerId: 'container-test', className: 'Sandbox' } + ); + await handler( + new Request('https://api.github.com/user', { + headers: { Authorization: `token ${CAPABILITY}` }, + }), + env, + { containerId: 'container-test', className: 'Sandbox' } + ); + + expect(redeemGitHubSessionCapability).toHaveBeenCalledTimes(2); + }); +}); + +describe('handleManagedScmOutbound', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); + }); + + it('logs safe GitHub CLI request and redemption failure diagnostics', async () => { + const redeemGitHubSessionCapability = vi.fn().mockResolvedValue({ + success: false, + reason: 'invalid_upstream_request', + }); + + const response = await handleOutbound( + new Request('https://api.github.com/graphql?secret=query-secret', { + method: 'POST', + headers: { + Authorization: `Bearer ${CAPABILITY}`, + 'User-Agent': 'GitHub CLI 2.82.1', + }, + body: '{}', + }), + createEnv(redeemGitHubSessionCapability) + ); + + expect(response.status).toBe(502); + expect(logging.logger.withFields).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationClass: 'github-managed', + client: 'github-cli', + method: 'POST', + route: 'graphql', + target: 'github-api', + }) + ); + expect(logging.logger.withFields).toHaveBeenCalledWith( + expect.objectContaining({ + capabilityVersion: 'kgh2', + outboundContainerId: OUTBOUND_CONTEXT.containerId, + reason: 'invalid_upstream_request', + }) + ); + const logs = serializedLogCalls(); + expect(logs).not.toContain(CAPABILITY); + expect(logs).not.toContain('query-secret'); + }); + + it('logs when GitHub CLI does not send a managed capability', async () => { + const forward = vi.fn().mockResolvedValue(new Response('forwarded')); + vi.stubGlobal('fetch', forward); + + await handleOutbound( + new Request('https://api.github.com/user?secret=query-secret', { + headers: { + Authorization: 'Bearer explicit-profile-token', + 'User-Agent': 'GitHub CLI 2.82.1', + }, + }), + createEnv() + ); + + expect(logging.logger.withFields).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationClass: 'unmanaged', + client: 'github-cli', + method: 'GET', + route: 'user', + target: 'github-api', + }) + ); + const logs = serializedLogCalls(); + expect(logs).not.toContain('explicit-profile-token'); + expect(logs).not.toContain('query-secret'); + }); + + it('logs mixed managed and unmanaged GitHub CLI authorization without credential values', async () => { + const redeemGitHubSessionCapability = vi.fn().mockResolvedValue({ + success: false, + reason: 'invalid_upstream_request', + }); + + await handleOutbound( + new Request('https://api.github.com/graphql', { + method: 'POST', + headers: { + Authorization: `Bearer ${CAPABILITY}`, + 'PRIVATE-TOKEN': 'explicit-private-secret', + 'User-Agent': 'GitHub CLI 2.82.1', + }, + body: '{}', + }), + createEnv(redeemGitHubSessionCapability) + ); + + expect(logging.logger.withFields).toHaveBeenCalledWith( + expect.objectContaining({ authorizationClass: 'mixed' }) + ); + expect(serializedLogCalls()).not.toContain('explicit-private-secret'); + expect(serializedLogCalls()).not.toContain(CAPABILITY); + }); + + it('logs conflicting managed GitHub CLI capabilities as mixed', async () => { + const response = await handleOutbound( + new Request('https://api.github.com/graphql', { + method: 'POST', + headers: { + Authorization: `Bearer ${CAPABILITY}`, + 'PRIVATE-TOKEN': GITLAB_CAPABILITY, + 'User-Agent': 'GitHub CLI 2.82.1', + }, + body: '{}', + }), + createEnv() + ); + + expect(response.status).toBe(502); + expect(logging.logger.withFields).toHaveBeenCalledWith( + expect.objectContaining({ authorizationClass: 'mixed' }) + ); + expect(serializedLogCalls()).not.toContain(CAPABILITY); + expect(serializedLogCalls()).not.toContain(GITLAB_CAPABILITY); + }); + + it('redacts untrusted GitHub CLI request paths from diagnostics', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('forwarded'))); + + await handleOutbound( + new Request('https://example.com/private-secret?secret=query-secret', { + method: 'METHODSECRET', + headers: { + Authorization: 'Bearer explicit-profile-token', + 'User-Agent': 'GitHub CLI 2.82.1', + }, + }), + createEnv() + ); + + expect(logging.logger.withFields).toHaveBeenCalledWith( + expect.objectContaining({ method: 'other', route: 'other', target: 'other' }) + ); + const logs = serializedLogCalls(); + expect(logs).not.toContain('private-secret'); + expect(logs).not.toContain('query-secret'); + expect(logs).not.toContain('METHODSECRET'); + expect(logs).not.toContain('explicit-profile-token'); + }); + + it('logs the upstream status for redeemed GitHub CLI requests', async () => { + const redeemGitHubSessionCapability = vi.fn().mockResolvedValue({ + success: true, + authorization: REDEEMED_GIT_AUTHORIZATION, + }); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('forbidden', { status: 403 }))); + + const response = await handleOutbound( + new Request('https://api.github.com/repos/acme/repo/pulls/1', { + headers: { + Authorization: `Bearer ${CAPABILITY}`, + 'User-Agent': 'GitHub CLI 2.82.1', + }, + }), + createEnv(redeemGitHubSessionCapability) + ); + + expect(response.status).toBe(403); + expect(logging.logger.withFields).toHaveBeenCalledWith( + expect.objectContaining({ upstreamStatus: 403 }) + ); + expect(serializedLogCalls()).not.toContain('upstream-token'); + }); + + it('keeps diagnostics failures from changing a successful forwarded response', async () => { + const redeemGitHubSessionCapability = vi.fn().mockResolvedValue({ + success: true, + authorization: REDEEMED_GIT_AUTHORIZATION, + }); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 204 }))); + logging.logger.withFields.mockImplementationOnce(() => { + throw new Error('diagnostics unavailable'); + }); + + const response = await handleOutbound( + new Request('https://api.github.com/repos/acme/repo/pulls/1', { + headers: { + Authorization: `Bearer ${CAPABILITY}`, + 'User-Agent': 'GitHub CLI 2.82.1', + }, + }), + createEnv(redeemGitHubSessionCapability) + ); + + expect(response.status).toBe(204); + }); + + it('distinguishes upstream forwarding failures without logging their messages', async () => { + const redeemGitHubSessionCapability = vi.fn().mockResolvedValue({ + success: true, + authorization: REDEEMED_GIT_AUTHORIZATION, + }); + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('upstream-secret-message'))); + + const response = await handleOutbound( + new Request('https://api.github.com/repos/acme/repo/pulls/1', { + headers: { + Authorization: `Bearer ${CAPABILITY}`, + 'User-Agent': 'GitHub CLI 2.82.1', + }, + }), + createEnv(redeemGitHubSessionCapability) + ); + + expect(response.status).toBe(502); + expect(logging.logger.withFields).toHaveBeenCalledWith( + expect.objectContaining({ errorClass: 'error', failureStage: 'upstream-forward' }) + ); + expect(serializedLogCalls()).not.toContain('upstream-secret-message'); + expect(serializedLogCalls()).not.toContain('upstream-token'); + }); + + it('redeems a managed Git credential, rewrites authorization and uses manual redirects', async () => { + const redeemGitHubSessionCapability = vi.fn().mockResolvedValue({ + success: true, + authorization: REDEEMED_GIT_AUTHORIZATION, + }); + const forward = vi.fn().mockResolvedValue(new Response('forwarded')); + vi.stubGlobal('fetch', forward); + const request = new Request('https://github.com/acme/repo.git/git-receive-pack', { + method: 'POST', + headers: { + Authorization: basicCredential(CAPABILITY), + 'PRIVATE-TOKEN': 'explicit-unrelated-token', + }, + body: 'git-body', + }); + + await handleOutbound(request, createEnv(redeemGitHubSessionCapability)); + + expect(redeemGitHubSessionCapability).toHaveBeenCalledWith({ + capability: CAPABILITY, + outboundContainerId: OUTBOUND_CONTEXT.containerId, + requestMethod: 'POST', + requestUrl: 'https://github.com/acme/repo.git/git-receive-pack', + }); + const forwarded = forward.mock.calls[0]?.[0] as Request; + expect(forwarded.headers.get('Authorization')).toBe(REDEEMED_GIT_AUTHORIZATION); + expect(forwarded.headers.get('PRIVATE-TOKEN')).toBe('explicit-unrelated-token'); + expect(forwarded.redirect).toBe('manual'); + expect(await forwarded.text()).toBe('git-body'); + }); + + it('fails closed for a managed capability using alternate Basic scheme casing', async () => { + const redeemGitHubSessionCapability = vi.fn().mockResolvedValue({ + success: false, + reason: 'expired_capability', + }); + const forward = vi.fn(); + vi.stubGlobal('fetch', forward); + + const response = await handleOutbound( + new Request('https://github.com/acme/repo.git/info/refs?service=git-upload-pack', { + headers: { Authorization: basicCredential(CAPABILITY, 'bAsIc') }, + }), + createEnv(redeemGitHubSessionCapability) + ); + + expect(redeemGitHubSessionCapability).toHaveBeenCalledWith({ + capability: CAPABILITY, + outboundContainerId: OUTBOUND_CONTEXT.containerId, + requestMethod: 'GET', + requestUrl: 'https://github.com/acme/repo.git/info/refs?service=git-upload-pack', + }); + expect(response.status).toBe(502); + expect(forward).not.toHaveBeenCalled(); + }); + + it('passes non-capability or malformed Basic credentials through unchanged', async () => { + const redeemGitHubSessionCapability = vi.fn(); + const forward = vi.fn().mockResolvedValue(new Response('forwarded')); + vi.stubGlobal('fetch', forward); + const authorization = basicCredential('explicit-profile-token'); + + await handleOutbound( + new Request('https://github.com/acme/repo.git/info/refs?service=git-upload-pack', { + headers: { Authorization: authorization }, + }), + createEnv(redeemGitHubSessionCapability) + ); + + expect(redeemGitHubSessionCapability).not.toHaveBeenCalled(); + const forwarded = forward.mock.calls[0]?.[0] as Request; + expect(forwarded.headers.get('Authorization')).toBe(authorization); + expect(forwarded.redirect).toBe('follow'); + + await handleOutbound( + new Request('https://github.com/acme/repo.git/info/refs?service=git-upload-pack', { + headers: { Authorization: 'Basic %not-base64%' }, + }), + createEnv(redeemGitHubSessionCapability) + ); + expect(redeemGitHubSessionCapability).not.toHaveBeenCalled(); + }); + + it('redeems a GitHub LFS Basic capability request', async () => { + const redeemGitHubSessionCapability = vi.fn().mockResolvedValue({ + success: true, + authorization: REDEEMED_GIT_AUTHORIZATION, + }); + const forward = vi.fn().mockResolvedValue(new Response('forwarded')); + vi.stubGlobal('fetch', forward); + + await handleOutbound( + new Request('https://github.com/acme/repo.git/info/lfs/objects/batch', { + method: 'POST', + headers: { Authorization: basicCredential(CAPABILITY) }, + body: '{}', + }), + createEnv(redeemGitHubSessionCapability) + ); + + expect(redeemGitHubSessionCapability).toHaveBeenCalledWith({ + capability: CAPABILITY, + outboundContainerId: OUTBOUND_CONTEXT.containerId, + requestMethod: 'POST', + requestUrl: 'https://github.com/acme/repo.git/info/lfs/objects/batch', + }); + const forwarded = forward.mock.calls[0]?.[0] as Request; + expect(forwarded.headers.get('Authorization')).toBe(REDEEMED_GIT_AUTHORIZATION); + expect(forwarded.redirect).toBe('manual'); + expect(await forwarded.text()).toBe('{}'); + }); + + it('passes an ordinary unrelated outbound request through unchanged', async () => { + const redeemGitHubSessionCapability = vi.fn(); + const forward = vi.fn().mockResolvedValue(new Response('forwarded')); + vi.stubGlobal('fetch', forward); + const request = new Request('https://example.com/resource', { + headers: { Authorization: 'Bearer explicit-profile-token' }, + }); + + await handleOutbound(request, createEnv(redeemGitHubSessionCapability)); + + expect(redeemGitHubSessionCapability).not.toHaveBeenCalled(); + expect(forward).toHaveBeenCalledWith(request); + }); + + it.each([ + ['GitHub API bearer', { Authorization: 'Bearer kgh3opaque' }], + ['GitLab Git Basic', { Authorization: basicCredential('kgl42opaque', 'Basic', 'oauth2') }], + ['GitLab API private token', { 'PRIVATE-TOKEN': 'kgl999opaque' }], + ])('passes non-versioned capability-like %s credential through unchanged', async (_, headers) => { + const redeemGitHubSessionCapability = vi.fn(); + const redeemGitLabSessionCapability = vi.fn(); + const forward = vi.fn().mockResolvedValue(new Response('forwarded')); + vi.stubGlobal('fetch', forward); + const request = new Request('https://example.com/resource', { headers }); + + await handleOutbound( + request, + createEnv(redeemGitHubSessionCapability, redeemGitLabSessionCapability) + ); + + expect(redeemGitHubSessionCapability).not.toHaveBeenCalled(); + expect(redeemGitLabSessionCapability).not.toHaveBeenCalled(); + expect(forward).toHaveBeenCalledWith(request); + }); + + it('continues redeeming legacy capabilities during staged rollout', async () => { + const redeemGitHubSessionCapability = vi.fn().mockResolvedValue({ + success: false, + reason: 'invalid_capability', + }); + const redeemGitLabSessionCapability = vi.fn().mockResolvedValue({ + success: false, + reason: 'invalid_capability', + }); + const env = createEnv(redeemGitHubSessionCapability, redeemGitLabSessionCapability); + + await handleOutbound( + new Request('https://github.com/acme/repo.git/info/refs?service=git-upload-pack', { + headers: { Authorization: basicCredential(LEGACY_CAPABILITY) }, + }), + env + ); + await handleOutbound( + new Request('https://gitlab.com/api/v4/projects', { + headers: { Authorization: `Bearer ${LEGACY_GITLAB_CAPABILITY}` }, + }), + env + ); + + expect(redeemGitHubSessionCapability).toHaveBeenCalledWith({ + capability: LEGACY_CAPABILITY, + outboundContainerId: OUTBOUND_CONTEXT.containerId, + requestMethod: 'GET', + requestUrl: 'https://github.com/acme/repo.git/info/refs?service=git-upload-pack', + }); + expect(redeemGitLabSessionCapability).toHaveBeenCalledWith({ + capability: LEGACY_GITLAB_CAPABILITY, + outboundContainerId: OUTBOUND_CONTEXT.containerId, + requestMethod: 'GET', + requestUrl: 'https://gitlab.com/api/v4/projects', + }); + }); + + it.each([ + basicCredential(CAPABILITY, 'bAsIc', 'oauth2'), + basicCredential(GITLAB_CAPABILITY, 'BaSiC', 'x-access-token'), + ])( + 'fails closed without forwarding a cross-provider Basic capability carrier: %s', + async authorization => { + const redeemGitHubSessionCapability = vi.fn(); + const redeemGitLabSessionCapability = vi.fn(); + const forward = vi.fn(); + vi.stubGlobal('fetch', forward); + + const response = await handleOutbound( + new Request('https://example.com/resource', { headers: { Authorization: authorization } }), + createEnv(redeemGitHubSessionCapability, redeemGitLabSessionCapability) + ); + + expect(response.status).toBe(502); + expect(redeemGitHubSessionCapability).not.toHaveBeenCalled(); + expect(redeemGitLabSessionCapability).not.toHaveBeenCalled(); + expect(forward).not.toHaveBeenCalled(); + } + ); + + it('fails closed without forwarding a GitHub capability in PRIVATE-TOKEN', async () => { + const redeemGitHubSessionCapability = vi.fn(); + const redeemGitLabSessionCapability = vi.fn(); + const forward = vi.fn(); + vi.stubGlobal('fetch', forward); + + const response = await handleOutbound( + new Request('https://example.com/resource', { + headers: { 'PRIVATE-TOKEN': ` \t${CAPABILITY}\t ` }, + }), + createEnv(redeemGitHubSessionCapability, redeemGitLabSessionCapability) + ); + + expect(response.status).toBe(502); + expect(redeemGitHubSessionCapability).not.toHaveBeenCalled(); + expect(redeemGitLabSessionCapability).not.toHaveBeenCalled(); + expect(forward).not.toHaveBeenCalled(); + }); + + it.each([ + ['GitHub Git Basic', { Authorization: basicCredential('kgh3.opaque') }], + ['GitHub API bearer', { Authorization: 'Bearer kgh42.opaque' }], + ['GitLab Git Basic', { Authorization: basicCredential('kgl3.opaque', 'Basic', 'oauth2') }], + ['GitLab API bearer', { Authorization: 'Bearer kgl42.opaque' }], + ['GitLab API private token', { 'PRIVATE-TOKEN': 'kgl999.opaque' }], + ])( + 'fails closed without forwarding an unsupported future %s capability version', + async (_, headers) => { + const redeemGitHubSessionCapability = vi.fn(); + const redeemGitLabSessionCapability = vi.fn(); + const forward = vi.fn().mockResolvedValue(new Response('forwarded')); + vi.stubGlobal('fetch', forward); + + const response = await handleOutbound( + new Request('https://example.com/resource', { headers }), + createEnv(redeemGitHubSessionCapability, redeemGitLabSessionCapability) + ); + + expect(response.status).toBe(502); + expect(redeemGitHubSessionCapability).not.toHaveBeenCalled(); + expect(redeemGitLabSessionCapability).not.toHaveBeenCalled(); + expect(forward).not.toHaveBeenCalled(); + } + ); + + it('fails closed without forwarding a GitHub capability sent to an unrelated host', async () => { + const redeemGitHubSessionCapability = vi.fn().mockResolvedValue({ + success: false, + reason: 'upstream_host_not_allowed', + }); + const forward = vi.fn(); + vi.stubGlobal('fetch', forward); + + const response = await handleOutbound( + new Request('https://example.com/resource', { + headers: { Authorization: `Bearer ${CAPABILITY}` }, + }), + createEnv(redeemGitHubSessionCapability) + ); + + expect(redeemGitHubSessionCapability).toHaveBeenCalledWith({ + capability: CAPABILITY, + outboundContainerId: OUTBOUND_CONTEXT.containerId, + requestMethod: 'GET', + requestUrl: 'https://example.com/resource', + }); + expect(response.status).toBe(502); + expect(forward).not.toHaveBeenCalled(); + }); + + it.each([ + `Basic ${Buffer.from(`x-access-token:${CAPABILITY}`).toString('base64')}`, + `token ${CAPABILITY}`, + `Bearer ${CAPABILITY}`, + `Basic\t${Buffer.from(`x-access-token:${CAPABILITY}`).toString('base64')}`, + `token \t ${CAPABILITY}`, + `Bearer\t \t${CAPABILITY}`, + ])( + 'fails closed without forwarding a whitespace-separated capability credential: %s', + async authorization => { + const redeemGitHubSessionCapability = vi.fn().mockResolvedValue({ + success: false, + reason: 'upstream_host_not_allowed', + }); + const forward = vi.fn(); + vi.stubGlobal('fetch', forward); + + const response = await handleOutbound( + new Request('https://example.com/resource', { headers: { Authorization: authorization } }), + createEnv(redeemGitHubSessionCapability) + ); + + expect(redeemGitHubSessionCapability).toHaveBeenCalledWith({ + capability: CAPABILITY, + outboundContainerId: OUTBOUND_CONTEXT.containerId, + requestMethod: 'GET', + requestUrl: 'https://example.com/resource', + }); + expect(response.status).toBe(502); + expect(forward).not.toHaveBeenCalled(); + } + ); + + it('fails closed without forwarding when redemption fails or throws', async () => { + const forward = vi.fn(); + vi.stubGlobal('fetch', forward); + const request = () => + new Request('https://github.com/acme/repo.git/info/refs?service=git-upload-pack', { + headers: { Authorization: basicCredential(CAPABILITY) }, + }); + const rejected = await handleOutbound( + request(), + createEnv(vi.fn().mockResolvedValue({ success: false, reason: 'expired_capability' })) + ); + const thrown = await handleOutbound( + request(), + createEnv(vi.fn().mockRejectedValue(new Error('RPC unavailable'))) + ); + + expect(rejected.status).toBe(502); + expect(thrown.status).toBe(502); + expect(forward).not.toHaveBeenCalled(); + }); +}); + +describe('handleManagedScmOutbound GitLab authorization', () => { + beforeEach(() => { + vi.unstubAllGlobals(); + }); + + it('redeems GitLab Git and LFS Basic capabilities with exact method and URL', async () => { + const redeemGitLabSessionCapability = vi.fn().mockResolvedValue({ + success: true, + headers: { authorization: REDEEMED_GITLAB_AUTHORIZATION }, + }); + const forward = vi.fn().mockResolvedValue(new Response('forwarded')); + vi.stubGlobal('fetch', forward); + const urls = [ + 'https://gitlab.example.com/acme/platform/repo.git/info/refs?service=git-upload-pack', + 'https://gitlab.example.com/acme/platform/repo.git/info/lfs/objects/batch', + ]; + + for (const [index, url] of urls.entries()) { + await handleOutbound( + new Request(url, { + method: index === 0 ? 'GET' : 'POST', + headers: { Authorization: basicCredential(GITLAB_CAPABILITY, 'bAsIc', 'oauth2') }, + ...(index === 0 ? {} : { body: '{}' }), + }), + createEnv(vi.fn(), redeemGitLabSessionCapability) + ); + } + + expect(redeemGitLabSessionCapability).toHaveBeenNthCalledWith(1, { + capability: GITLAB_CAPABILITY, + outboundContainerId: OUTBOUND_CONTEXT.containerId, + requestMethod: 'GET', + requestUrl: urls[0], + }); + expect(redeemGitLabSessionCapability).toHaveBeenNthCalledWith(2, { + capability: GITLAB_CAPABILITY, + outboundContainerId: OUTBOUND_CONTEXT.containerId, + requestMethod: 'POST', + requestUrl: urls[1], + }); + const forwarded = forward.mock.calls[1]?.[0] as Request; + expect(forwarded.headers.get('Authorization')).toBe(REDEEMED_GITLAB_AUTHORIZATION); + expect(forwarded.redirect).toBe('manual'); + }); + + it.each([ + ['Authorization', `bEaReR\t ${GITLAB_CAPABILITY}`], + ['PRIVATE-TOKEN', ` \t${GITLAB_CAPABILITY}\t `], + ])('redeems mixed-case whitespace-separated GitLab API %s capabilities', async (name, value) => { + const redeemGitLabSessionCapability = vi.fn().mockResolvedValue({ + success: true, + headers: { authorization: 'Bearer upstream-token' }, + }); + const forward = vi.fn().mockResolvedValue(new Response('forwarded')); + vi.stubGlobal('fetch', forward); + + await handleOutbound( + new Request('https://gitlab.com/api/v4/projects/1/merge_requests', { + method: 'POST', + headers: { [name]: value }, + body: '{}', + }), + createEnv(vi.fn(), redeemGitLabSessionCapability) + ); + + expect(redeemGitLabSessionCapability).toHaveBeenCalledWith({ + capability: GITLAB_CAPABILITY, + outboundContainerId: OUTBOUND_CONTEXT.containerId, + requestMethod: 'POST', + requestUrl: 'https://gitlab.com/api/v4/projects/1/merge_requests', + }); + const forwarded = forward.mock.calls[0]?.[0] as Request; + expect(forwarded.headers.get('Authorization')).toBe('Bearer upstream-token'); + expect(forwarded.headers.get('PRIVATE-TOKEN')).toBeNull(); + expect(forwarded.redirect).toBe('manual'); + }); + + it('redeems a GitLab PRIVATE-TOKEN capability to only the raw upstream project token', async () => { + const redeemGitLabSessionCapability = vi.fn().mockResolvedValue({ + success: true, + headers: { 'PRIVATE-TOKEN': 'project-access-token' }, + }); + const forward = vi.fn().mockResolvedValue(new Response('forwarded')); + vi.stubGlobal('fetch', forward); + + await handleOutbound( + new Request('https://gitlab.com/api/v4/projects/42/merge_requests', { + method: 'POST', + headers: { 'PRIVATE-TOKEN': GITLAB_CAPABILITY }, + body: '{}', + }), + createEnv(vi.fn(), redeemGitLabSessionCapability) + ); + + expect(redeemGitLabSessionCapability).toHaveBeenCalledWith({ + capability: GITLAB_CAPABILITY, + outboundContainerId: OUTBOUND_CONTEXT.containerId, + requestMethod: 'POST', + requestUrl: 'https://gitlab.com/api/v4/projects/42/merge_requests', + }); + const forwarded = forward.mock.calls[0]?.[0] as Request; + expect(forwarded.headers.get('Authorization')).toBeNull(); + expect(forwarded.headers.get('PRIVATE-TOKEN')).toBe('project-access-token'); + expect(forwarded.headers.get('PRIVATE-TOKEN')).not.toBe(GITLAB_CAPABILITY); + expect(forwarded.redirect).toBe('manual'); + }); + + it('fails closed for conflicting managed GitLab API headers', async () => { + const redeemGitLabSessionCapability = vi.fn(); + const forward = vi.fn(); + vi.stubGlobal('fetch', forward); + + const response = await handleOutbound( + new Request('https://gitlab.com/api/v4/user', { + headers: { + Authorization: `Bearer ${GITLAB_CAPABILITY}`, + 'PRIVATE-TOKEN': 'kgl1.different', + }, + }), + createEnv(vi.fn(), redeemGitLabSessionCapability) + ); + + expect(response.status).toBe(502); + expect(redeemGitLabSessionCapability).not.toHaveBeenCalled(); + expect(forward).not.toHaveBeenCalled(); + }); + + it.each([ + `token ${GITLAB_CAPABILITY}`, + `ToKeN ${GITLAB_CAPABILITY}`, + `TOKEN\t \t${GITLAB_CAPABILITY}`, + basicCredential(GITLAB_CAPABILITY, 'Basic', 'x-access-token'), + ])( + 'fails closed without forwarding a GitLab capability in unsupported authorization carrier: %s', + async authorization => { + const redeemGitLabSessionCapability = vi.fn(); + const forward = vi.fn(); + vi.stubGlobal('fetch', forward); + + const response = await handleOutbound( + new Request('https://example.com/resource', { headers: { Authorization: authorization } }), + createEnv(vi.fn(), redeemGitLabSessionCapability) + ); + + expect(response.status).toBe(502); + expect(redeemGitLabSessionCapability).not.toHaveBeenCalled(); + expect(forward).not.toHaveBeenCalled(); + } + ); + + it('fails closed without forwarding a GitLab capability sent to an arbitrary host', async () => { + const redeemGitLabSessionCapability = vi.fn().mockResolvedValue({ + success: false, + reason: 'upstream_origin_not_allowed', + }); + const forward = vi.fn(); + vi.stubGlobal('fetch', forward); + + const response = await handleOutbound( + new Request('https://example.com/resource', { + headers: { Authorization: `Bearer ${GITLAB_CAPABILITY}` }, + }), + createEnv(vi.fn(), redeemGitLabSessionCapability) + ); + + expect(response.status).toBe(502); + expect(forward).not.toHaveBeenCalled(); + }); + + it('fails closed without forwarding when GitLab redemption rejects or throws', async () => { + const forward = vi.fn(); + vi.stubGlobal('fetch', forward); + const request = () => + new Request('https://gitlab.com/api/v4/user', { + headers: { Authorization: `Bearer ${GITLAB_CAPABILITY}` }, + }); + + const rejected = await handleOutbound( + request(), + createEnv( + vi.fn(), + vi.fn().mockResolvedValue({ success: false, reason: 'invalid_capability' }) + ) + ); + const thrown = await handleOutbound( + request(), + createEnv(vi.fn(), vi.fn().mockRejectedValue(new Error('RPC unavailable'))) + ); + + expect(rejected.status).toBe(502); + expect(thrown.status).toBe(502); + expect(forward).not.toHaveBeenCalled(); + }); + + it.each([ + { headers: [['PRIVATE-TOKEN', 'explicit-profile-token']] }, + { headers: [['Authorization', 'Bearer explicit-profile-token']] }, + ])('passes explicit raw GitLab credentials through unchanged', async ({ headers }) => { + const redeemGitLabSessionCapability = vi.fn(); + const forward = vi.fn().mockResolvedValue(new Response('forwarded')); + vi.stubGlobal('fetch', forward); + const request = new Request('https://gitlab.com/api/v4/user', { headers }); + + await handleOutbound(request, createEnv(vi.fn(), redeemGitLabSessionCapability)); + + expect(redeemGitLabSessionCapability).not.toHaveBeenCalled(); + expect(forward).toHaveBeenCalledWith(request); + }); +}); + +describe('handleManagedScmOutbound API authorization', () => { + beforeEach(() => { + vi.unstubAllGlobals(); + }); + + it.each(['token', 'TOKEN', 'Bearer', 'bEaReR'])( + 'redeems managed `%s` GH_TOKEN requests', + async scheme => { + const redeemGitHubSessionCapability = vi.fn().mockResolvedValue({ + success: true, + authorization: 'Bearer upstream-token', + }); + const forward = vi.fn().mockResolvedValue(new Response('forwarded')); + vi.stubGlobal('fetch', forward); + + await handleOutbound( + new Request('https://api.github.com/repos/acme/repo/issues/1/comments', { + method: 'POST', + headers: { Authorization: `${scheme} ${CAPABILITY}` }, + body: '{}', + }), + createEnv(redeemGitHubSessionCapability) + ); + + expect(redeemGitHubSessionCapability).toHaveBeenCalledWith({ + capability: CAPABILITY, + outboundContainerId: OUTBOUND_CONTEXT.containerId, + requestMethod: 'POST', + requestUrl: 'https://api.github.com/repos/acme/repo/issues/1/comments', + }); + const forwarded = forward.mock.calls[0]?.[0] as Request; + expect(forwarded.headers.get('Authorization')).toBe('Bearer upstream-token'); + expect(forwarded.redirect).toBe('manual'); + } + ); + + it('passes explicit profile authorization through without redemption', async () => { + const redeemGitHubSessionCapability = vi.fn(); + const forward = vi.fn().mockResolvedValue(new Response('forwarded')); + vi.stubGlobal('fetch', forward); + + await handleOutbound( + new Request('https://api.github.com/user', { + headers: { Authorization: 'token explicit-profile-token' }, + }), + createEnv(redeemGitHubSessionCapability) + ); + + expect(redeemGitHubSessionCapability).not.toHaveBeenCalled(); + const forwarded = forward.mock.calls[0]?.[0] as Request; + expect(forwarded.headers.get('Authorization')).toBe('token explicit-profile-token'); + }); + + it('fails closed without forwarding when managed API redemption is rejected', async () => { + const forward = vi.fn(); + vi.stubGlobal('fetch', forward); + + const response = await handleOutbound( + new Request('https://api.github.com/user', { + headers: { Authorization: `Bearer ${CAPABILITY}` }, + }), + createEnv(vi.fn().mockResolvedValue({ success: false, reason: 'invalid_capability' })) + ); + + expect(response.status).toBe(502); + expect(forward).not.toHaveBeenCalled(); + }); +}); diff --git a/services/cloud-agent-next/src/sandbox-outbound.ts b/services/cloud-agent-next/src/sandbox-outbound.ts new file mode 100644 index 0000000000..3862d19a6d --- /dev/null +++ b/services/cloud-agent-next/src/sandbox-outbound.ts @@ -0,0 +1,417 @@ +import { Buffer } from 'node:buffer'; +import { ContainerProxy, Sandbox as StockSandbox } from '@cloudflare/sandbox'; +import { logger } from './logger.js'; +import type { GitTokenService } from './types.js'; + +const GITHUB_CAPABILITY_PREFIXES = ['kgh1.', 'kgh2.']; +const GITLAB_CAPABILITY_PREFIXES = ['kgl1.', 'kgl2.']; + +type GitHubTokenRedemptionBinding = Pick; +type GitLabTokenRedemptionBinding = Pick; +type ManagedScmOutboundContext = { containerId: string }; +type RedeemableAuthorization = { provider: 'github' | 'gitlab'; capability: string }; +type AuthorizationExtraction = + | { type: 'none' } + | { type: 'capability'; value: RedeemableAuthorization } + | { type: 'unsupported_capability' }; + +const NO_AUTHORIZATION_CAPABILITY = { type: 'none' } satisfies AuthorizationExtraction; + +type ScmClient = 'github-cli' | 'gitlab-cli' | 'git-lfs' | 'git' | 'other'; +type ScmMethod = 'GET' | 'HEAD' | 'POST' | 'PATCH' | 'PUT' | 'DELETE' | 'OPTIONS' | 'other'; +type ScmTarget = 'github-api' | 'github-git' | 'gitlab' | 'other'; +type AuthorizationClass = + | 'github-managed' + | 'gitlab-managed' + | 'unsupported-managed' + | 'mixed' + | 'unmanaged' + | 'none'; +type DiagnosticLevel = 'debug' | 'info' | 'warn'; + +function logDiagnostic( + level: DiagnosticLevel, + fields: Record, + message: string +): void { + try { + const scopedLogger = logger.withFields(fields); + scopedLogger[level](message); + } catch { + // Diagnostics must never change outbound request behavior. + } +} + +function classifyScmClient(userAgent: string | null): ScmClient { + const normalized = userAgent?.toLowerCase() ?? ''; + if (normalized.includes('github cli') || normalized.startsWith('gh/')) return 'github-cli'; + if (normalized.includes('glab')) return 'gitlab-cli'; + if (normalized.includes('git-lfs')) return 'git-lfs'; + if (normalized.includes('git/')) return 'git'; + return 'other'; +} + +function classifyScmMethod(method: string): ScmMethod { + const normalized = method.toUpperCase(); + if ( + normalized === 'GET' || + normalized === 'HEAD' || + normalized === 'POST' || + normalized === 'PATCH' || + normalized === 'PUT' || + normalized === 'DELETE' || + normalized === 'OPTIONS' + ) { + return normalized; + } + return 'other'; +} + +function classifyScmTarget(url: URL): ScmTarget { + if (url.hostname === 'api.github.com') return 'github-api'; + if (url.hostname === 'github.com') return 'github-git'; + if (url.hostname === 'gitlab.com') return 'gitlab'; + return 'other'; +} + +function classifyScmRoute(url: URL, target: ScmTarget): string { + if (target === 'github-api') { + if (url.pathname === '/graphql') return 'graphql'; + if (url.pathname === '/user') return 'user'; + if (url.pathname.startsWith('/repos/')) return 'repository-api'; + return 'github-api-other'; + } + if (target === 'github-git') { + if (url.pathname.includes('/info/lfs/')) return 'git-lfs'; + if (url.pathname.endsWith('/info/refs')) return 'git-info-refs'; + if (url.pathname.endsWith('/git-upload-pack')) return 'git-upload-pack'; + if (url.pathname.endsWith('/git-receive-pack')) return 'git-receive-pack'; + return 'github-git-other'; + } + return target === 'gitlab' ? 'gitlab' : 'other'; +} + +function getSafeRequestLogFields(request: Request) { + const url = new URL(request.url); + const target = classifyScmTarget(url); + return { + client: classifyScmClient(request.headers.get('User-Agent')), + method: classifyScmMethod(request.method), + target, + route: classifyScmRoute(url, target), + }; +} + +function getCapabilityVersion(capability: string): string { + const separator = capability.indexOf('.'); + return separator === -1 ? 'unknown' : capability.slice(0, separator); +} + +function classifyDiagnosticError(error: unknown): 'error' | 'unknown' { + return error instanceof Error ? 'error' : 'unknown'; +} + +function supportsGitHubSessionCapabilityRedemption( + service: unknown +): service is GitHubTokenRedemptionBinding { + return ( + typeof service === 'object' && + service !== null && + 'redeemGitHubSessionCapability' in service && + typeof service.redeemGitHubSessionCapability === 'function' + ); +} + +function supportsGitLabSessionCapabilityRedemption( + service: unknown +): service is GitLabTokenRedemptionBinding { + return ( + typeof service === 'object' && + service !== null && + 'redeemGitLabSessionCapability' in service && + typeof service.redeemGitLabSessionCapability === 'function' + ); +} + +function classifyCapability(capability: string): AuthorizationExtraction { + if (GITHUB_CAPABILITY_PREFIXES.some(prefix => capability.startsWith(prefix))) { + return { type: 'capability', value: { provider: 'github', capability } }; + } + if (GITLAB_CAPABILITY_PREFIXES.some(prefix => capability.startsWith(prefix))) { + return { type: 'capability', value: { provider: 'gitlab', capability } }; + } + return /^(?:kgh|kgl)\d+\./.test(capability) + ? { type: 'unsupported_capability' } + : NO_AUTHORIZATION_CAPABILITY; +} + +function extractGitCapability(authorization: string | null): AuthorizationExtraction { + if (!authorization) return NO_AUTHORIZATION_CAPABILITY; + const match = /^Basic[ \t]+(.+)$/i.exec(authorization); + if (!match) return NO_AUTHORIZATION_CAPABILITY; + const encodedCredential = match[1]; + if (!encodedCredential) return NO_AUTHORIZATION_CAPABILITY; + if (!/^[A-Za-z0-9+/]+={0,2}$/.test(encodedCredential)) return NO_AUTHORIZATION_CAPABILITY; + const decodedCredential = Buffer.from(encodedCredential, 'base64'); + if (decodedCredential.toString('base64') !== encodedCredential) + return NO_AUTHORIZATION_CAPABILITY; + const credential = decodedCredential.toString('utf8'); + const separator = credential.indexOf(':'); + if (separator === -1) return NO_AUTHORIZATION_CAPABILITY; + const username = credential.slice(0, separator); + const extraction = classifyCapability(credential.slice(separator + 1)); + if (extraction.type !== 'capability') return extraction; + if (username === 'x-access-token' && extraction.value.provider === 'github') { + return extraction; + } + if (username === 'oauth2' && extraction.value.provider === 'gitlab') { + return extraction; + } + return { type: 'unsupported_capability' }; +} + +function extractApiCapability(authorization: string | null): AuthorizationExtraction { + if (!authorization) return NO_AUTHORIZATION_CAPABILITY; + const match = /^(token|Bearer)[ \t]+(.+)$/i.exec(authorization); + if (!match?.[2]) return NO_AUTHORIZATION_CAPABILITY; + const extraction = classifyCapability(match[2]); + if (extraction.type !== 'capability') return extraction; + if (extraction.value.provider === 'gitlab' && match[1]?.toLowerCase() !== 'bearer') { + return { type: 'unsupported_capability' }; + } + return extraction; +} + +function extractGitLabPrivateTokenCapability(privateToken: string | null): AuthorizationExtraction { + if (!privateToken) return NO_AUTHORIZATION_CAPABILITY; + const extraction = classifyCapability(privateToken.trim()); + if (extraction.type !== 'capability') return extraction; + return extraction.value.provider === 'gitlab' ? extraction : { type: 'unsupported_capability' }; +} + +function getAuthorizationClass( + extractions: AuthorizationExtraction[], + hasUnmanagedAuthorization: boolean +): AuthorizationClass { + if (extractions.some(extraction => extraction.type === 'unsupported_capability')) { + return 'unsupported-managed'; + } + const capabilities = extractions.flatMap(extraction => + extraction.type === 'capability' ? [extraction.value] : [] + ); + const capability = capabilities[0]; + if (!capability) return hasUnmanagedAuthorization ? 'unmanaged' : 'none'; + if ( + hasUnmanagedAuthorization || + capabilities.some( + candidate => + candidate.provider !== capability.provider || candidate.capability !== capability.capability + ) + ) { + return 'mixed'; + } + return capability.provider === 'github' ? 'github-managed' : 'gitlab-managed'; +} + +async function forwardRedeemedRequest( + request: Request, + headersToApply: Record, + removeGitLabPrivateToken = false +): Promise { + const headers = new Headers(request.headers); + headers.delete('Authorization'); + if (removeGitLabPrivateToken) headers.delete('PRIVATE-TOKEN'); + for (const [name, value] of Object.entries(headersToApply)) { + if (value !== undefined) headers.set(name, value); + } + return fetch( + new Request(request, { + headers, + redirect: 'manual', + }) + ); +} + +async function handleManagedGitHubOutbound( + request: Request, + env: Cloudflare.Env, + capability: { capability: string }, + outboundContainerId: string +): Promise { + const logFields = { + ...getSafeRequestLogFields(request), + provider: 'github', + capabilityVersion: getCapabilityVersion(capability.capability), + outboundContainerId, + }; + logDiagnostic('debug', logFields, 'Redeeming managed GitHub outbound request'); + + const tokenService = env.GIT_TOKEN_SERVICE; + if (!supportsGitHubSessionCapabilityRedemption(tokenService)) { + logDiagnostic( + 'warn', + { ...logFields, failureStage: 'redemption-binding' }, + 'Managed GitHub outbound redemption unavailable' + ); + return new Response('GitHub authorization unavailable', { status: 502 }); + } + + let result: Awaited>; + try { + result = await tokenService.redeemGitHubSessionCapability({ + capability: capability.capability, + outboundContainerId, + requestMethod: request.method, + requestUrl: request.url, + }); + } catch (error) { + logDiagnostic( + 'warn', + { + ...logFields, + failureStage: 'redemption-rpc', + errorClass: classifyDiagnosticError(error), + }, + 'Managed GitHub outbound redemption failed' + ); + return new Response('GitHub authorization unavailable', { status: 502 }); + } + + if (!result.success) { + logDiagnostic( + 'warn', + { ...logFields, failureStage: 'redemption-policy', reason: result.reason }, + 'Managed GitHub outbound redemption rejected' + ); + return new Response('GitHub authorization unavailable', { status: 502 }); + } + + let response: Response; + try { + response = await forwardRedeemedRequest(request, { authorization: result.authorization }); + } catch (error) { + logDiagnostic( + 'warn', + { + ...logFields, + failureStage: 'upstream-forward', + errorClass: classifyDiagnosticError(error), + }, + 'Managed GitHub outbound forwarding failed' + ); + return new Response('GitHub authorization unavailable', { status: 502 }); + } + + logDiagnostic( + 'info', + { ...logFields, upstreamStatus: response.status }, + 'Managed GitHub outbound request forwarded' + ); + return response; +} + +async function handleManagedGitLabOutbound( + request: Request, + env: Cloudflare.Env, + capability: { capability: string }, + outboundContainerId: string +): Promise { + const tokenService = env.GIT_TOKEN_SERVICE; + if (!supportsGitLabSessionCapabilityRedemption(tokenService)) { + return new Response('GitLab authorization unavailable', { status: 502 }); + } + try { + const result = await tokenService.redeemGitLabSessionCapability({ + capability: capability.capability, + outboundContainerId, + requestMethod: request.method, + requestUrl: request.url, + }); + if (!result.success) { + return new Response('GitLab authorization unavailable', { status: 502 }); + } + return forwardRedeemedRequest(request, result.headers, true); + } catch { + return new Response('GitLab authorization unavailable', { status: 502 }); + } +} + +export function handleManagedScmOutbound( + request: Request, + env: Cloudflare.Env, + ctx: ManagedScmOutboundContext +): Promise { + const authorization = request.headers.get('Authorization'); + const gitCapability = extractGitCapability(authorization); + const apiCapability = extractApiCapability(authorization); + const privateTokenCapability = extractGitLabPrivateTokenCapability( + request.headers.get('PRIVATE-TOKEN') + ); + const extractions = [gitCapability, apiCapability, privateTokenCapability]; + const hasUnmanagedAuthorization = + (authorization !== null && gitCapability.type === 'none' && apiCapability.type === 'none') || + (request.headers.has('PRIVATE-TOKEN') && privateTokenCapability.type === 'none'); + const safeRequestLogFields = getSafeRequestLogFields(request); + if (safeRequestLogFields.client === 'github-cli') { + logDiagnostic( + 'debug', + { + ...safeRequestLogFields, + authorizationClass: getAuthorizationClass(extractions, hasUnmanagedAuthorization), + outboundContainerId: ctx.containerId, + }, + 'Observed GitHub CLI outbound request' + ); + } + if ( + gitCapability.type === 'unsupported_capability' || + apiCapability.type === 'unsupported_capability' || + privateTokenCapability.type === 'unsupported_capability' + ) { + return Promise.resolve(new Response('SCM authorization unavailable', { status: 502 })); + } + const authorizationCapability = + gitCapability.type === 'capability' + ? gitCapability.value + : apiCapability.type === 'capability' + ? apiCapability.value + : null; + const gitLabPrivateTokenCapability = + privateTokenCapability.type === 'capability' ? privateTokenCapability.value : null; + if ( + authorizationCapability && + gitLabPrivateTokenCapability && + (authorizationCapability.provider !== 'gitlab' || + authorizationCapability.capability !== gitLabPrivateTokenCapability.capability) + ) { + return Promise.resolve(new Response('GitLab authorization unavailable', { status: 502 })); + } + const capability = authorizationCapability ?? gitLabPrivateTokenCapability; + if (!capability) return fetch(request); + return capability.provider === 'github' + ? handleManagedGitHubOutbound(request, env, capability, ctx.containerId) + : handleManagedGitLabOutbound(request, env, capability, ctx.containerId); +} + +export class Sandbox extends StockSandbox { + enableInternet = true; + interceptHttps = true; +} + +Sandbox.outbound = handleManagedScmOutbound; + +export class SandboxSmall extends StockSandbox { + enableInternet = true; + interceptHttps = true; +} + +SandboxSmall.outbound = handleManagedScmOutbound; + +export class SandboxDIND extends StockSandbox { + enableInternet = true; + interceptHttps = true; +} + +SandboxDIND.outbound = handleManagedScmOutbound; + +export { ContainerProxy }; diff --git a/services/cloud-agent-next/src/services/git-token-service-client.test.ts b/services/cloud-agent-next/src/services/git-token-service-client.test.ts index a2d2a54f61..ac24a9707b 100644 --- a/services/cloud-agent-next/src/services/git-token-service-client.test.ts +++ b/services/cloud-agent-next/src/services/git-token-service-client.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it, vi } from 'vitest'; import type { GitTokenService } from '../types.js'; import { + issueCloudAgentGitHubSessionCapability, + issueCloudAgentGitLabSessionCapability, resolveCloudAgentGitHubAuthForRepo, resolveManagedGitLabToken, } from './git-token-service-client.js'; @@ -8,6 +10,7 @@ import { vi.mock('../logger.js', () => ({ logger: { info: vi.fn(), + error: vi.fn(), withFields: vi.fn(() => ({ info: vi.fn(), error: vi.fn(), warn: vi.fn() })), }, })); @@ -17,6 +20,10 @@ function createGitTokenService() { getTokenForRepo: vi.fn(), getToken: vi.fn(), getGitLabToken: vi.fn(), + issueGitHubSessionCapability: vi.fn(), + redeemGitHubSessionCapability: vi.fn(), + issueGitLabSessionCapability: vi.fn(), + redeemGitLabSessionCapability: vi.fn(), } satisfies GitTokenService; } @@ -81,6 +88,190 @@ describe('resolveManagedGitLabToken', () => { }); }); +describe('issueCloudAgentGitHubSessionCapability', () => { + it('returns an opaque capability and preserves managed identity metadata', async () => { + const issueGitHubSessionCapability = vi.fn().mockResolvedValue({ + success: true, + capability: 'kgh2.opaque', + installationId: '123', + accountLogin: 'acme', + appType: 'standard', + source: 'user', + gitAuthor: { name: 'octocat', email: '101+octocat@users.noreply.github.com' }, + commitCoAuthor: { name: 'kiloconnect[bot]', email: 'bot@example.com' }, + }); + const getCloudAgentAuthForRepo = vi.fn(); + const getTokenForRepo = vi.fn(); + + const result = await issueCloudAgentGitHubSessionCapability( + createEnv({ issueGitHubSessionCapability, getCloudAgentAuthForRepo, getTokenForRepo }), + { + githubRepo: 'acme/repo', + userId: 'user_1', + outboundContainerId: 'container-test', + allowUserAuthorization: true, + } + ); + + expect(issueGitHubSessionCapability).toHaveBeenCalledWith({ + githubRepo: 'acme/repo', + userId: 'user_1', + outboundContainerId: 'container-test', + allowUserAuthorization: true, + }); + expect(getCloudAgentAuthForRepo).not.toHaveBeenCalled(); + expect(getTokenForRepo).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + success: true, + value: { + capability: 'kgh2.opaque', + source: 'user', + gitAuthor: { name: 'octocat' }, + }, + }); + }); + + it('reports issuance failure without resolving raw authentication', async () => { + const issueGitHubSessionCapability = vi.fn().mockResolvedValue({ + success: false, + reason: 'capability_configuration_error', + }); + const getCloudAgentAuthForRepo = vi.fn(); + const getTokenForRepo = vi.fn(); + + const result = await issueCloudAgentGitHubSessionCapability( + createEnv({ issueGitHubSessionCapability, getCloudAgentAuthForRepo, getTokenForRepo }), + { + githubRepo: 'acme/repo', + userId: 'user_1', + outboundContainerId: 'container-test', + allowUserAuthorization: false, + } + ); + + expect(result).toEqual({ + success: false, + error: { + reason: 'capability_configuration_error', + message: 'GitHub managed auth lookup failed (capability_configuration_error)', + }, + }); + expect(getCloudAgentAuthForRepo).not.toHaveBeenCalled(); + expect(getTokenForRepo).not.toHaveBeenCalled(); + }); + + it('fails closed when capability RPC throws without a raw token fallback', async () => { + const issueGitHubSessionCapability = vi + .fn() + .mockRejectedValue(new Error('service unavailable')); + const getCloudAgentAuthForRepo = vi.fn(); + const getTokenForRepo = vi.fn(); + + const result = await issueCloudAgentGitHubSessionCapability( + createEnv({ issueGitHubSessionCapability, getCloudAgentAuthForRepo, getTokenForRepo }), + { + githubRepo: 'acme/repo', + userId: 'user_1', + outboundContainerId: 'container-test', + allowUserAuthorization: true, + } + ); + + expect(result).toMatchObject({ success: false, error: { reason: 'rpc_error' } }); + expect(getCloudAgentAuthForRepo).not.toHaveBeenCalled(); + expect(getTokenForRepo).not.toHaveBeenCalled(); + }); +}); + +describe('issueCloudAgentGitLabSessionCapability', () => { + it('returns an opaque code-review project capability and preserves CLI mode metadata', async () => { + const issueGitLabSessionCapability = vi.fn().mockResolvedValue({ + success: true, + capability: 'kgl2.project', + instanceOrigin: 'https://gitlab.example.com:8443/gitlab', + instanceHost: 'gitlab.example.com:8443', + projectPath: 'acme/platform/repo', + integrationId: 'project_token_1', + authType: 'pat', + identity: { accountId: null, accountLogin: null }, + glabIsOAuth2: false, + }); + const getGitLabToken = vi.fn(); + + const result = await issueCloudAgentGitLabSessionCapability( + createEnv({ issueGitLabSessionCapability, getGitLabToken }), + { + gitUrl: 'https://gitlab.example.com:8443/gitlab/acme/platform/repo.git', + userId: 'user_1', + outboundContainerId: 'container-test', + createdOnPlatform: 'code-review', + } + ); + + expect(issueGitLabSessionCapability).toHaveBeenCalledWith({ + gitUrl: 'https://gitlab.example.com:8443/gitlab/acme/platform/repo.git', + userId: 'user_1', + outboundContainerId: 'container-test', + createdOnPlatform: 'code-review', + }); + expect(getGitLabToken).not.toHaveBeenCalled(); + expect(result).toEqual({ + success: true, + value: { + capability: 'kgl2.project', + gitUrl: 'https://gitlab.example.com:8443/gitlab/acme/platform/repo.git', + instanceOrigin: 'https://gitlab.example.com:8443/gitlab', + instanceHost: 'gitlab.example.com:8443', + projectPath: 'acme/platform/repo', + integrationId: 'project_token_1', + authType: 'pat', + identity: { accountId: null, accountLogin: null }, + glabIsOAuth2: false, + }, + }); + expect(JSON.stringify(result)).not.toContain('project-access-token'); + }); + + it('reports issuance failure without resolving a raw token', async () => { + const issueGitLabSessionCapability = vi.fn().mockResolvedValue({ + success: false, + reason: 'capability_configuration_error', + }); + const getGitLabToken = vi.fn(); + + const result = await issueCloudAgentGitLabSessionCapability( + createEnv({ issueGitLabSessionCapability, getGitLabToken }), + { + gitUrl: 'https://gitlab.com/acme/repo.git', + userId: 'user_1', + outboundContainerId: 'container-test', + } + ); + + expect(result).toEqual({ success: false, reason: 'capability_configuration_error' }); + expect(getGitLabToken).not.toHaveBeenCalled(); + }); + + it('fails closed when capability RPC throws without a raw token fallback', async () => { + const issueGitLabSessionCapability = vi + .fn() + .mockRejectedValue(new Error('service unavailable')); + const getGitLabToken = vi.fn(); + + const result = await issueCloudAgentGitLabSessionCapability( + createEnv({ issueGitLabSessionCapability, getGitLabToken }), + { + gitUrl: 'https://gitlab.com/acme/repo.git', + userId: 'user_1', + outboundContainerId: 'container-test', + } + ); + + expect(result).toEqual({ success: false, reason: 'rpc_error' }); + expect(getGitLabToken).not.toHaveBeenCalled(); + }); +}); + describe('resolveCloudAgentGitHubAuthForRepo', () => { it('passes explicit user-auth eligibility to the managed resolver when it is available', async () => { const getCloudAgentAuthForRepo = vi.fn().mockResolvedValue({ @@ -97,7 +288,11 @@ describe('resolveCloudAgentGitHubAuthForRepo', () => { const result = await resolveCloudAgentGitHubAuthForRepo( createEnv({ getCloudAgentAuthForRepo, getTokenForRepo }), - { githubRepo: 'acme/repo', userId: 'user_1', allowUserAuthorization: true } + { + githubRepo: 'acme/repo', + userId: 'user_1', + allowUserAuthorization: true, + } ); expect(getCloudAgentAuthForRepo).toHaveBeenCalledWith({ @@ -127,7 +322,11 @@ describe('resolveCloudAgentGitHubAuthForRepo', () => { const result = await resolveCloudAgentGitHubAuthForRepo( createEnv({ getCloudAgentAuthForRepo, getTokenForRepo }), - { githubRepo: 'acme/repo', userId: 'user_1', allowUserAuthorization: true } + { + githubRepo: 'acme/repo', + userId: 'user_1', + allowUserAuthorization: true, + } ); expect(getTokenForRepo).not.toHaveBeenCalled(); @@ -159,7 +358,11 @@ describe('resolveCloudAgentGitHubAuthForRepo', () => { const result = await resolveCloudAgentGitHubAuthForRepo( createEnv({ getCloudAgentAuthForRepo, getTokenForRepo }), - { githubRepo: 'acme/repo', userId: 'user_1', allowUserAuthorization: true } + { + githubRepo: 'acme/repo', + userId: 'user_1', + allowUserAuthorization: true, + } ); expect(getCloudAgentAuthForRepo).toHaveBeenCalledWith({ diff --git a/services/cloud-agent-next/src/services/git-token-service-client.ts b/services/cloud-agent-next/src/services/git-token-service-client.ts index 7199535f56..41cd43b827 100644 --- a/services/cloud-agent-next/src/services/git-token-service-client.ts +++ b/services/cloud-agent-next/src/services/git-token-service-client.ts @@ -85,6 +85,17 @@ export type ResolvedCloudAgentGitHubAuth = { fallbackReason?: ManagedGitHubFallbackReason; }; +export type ResolvedCloudAgentGitHubCapability = { + capability: string; + installationId: string; + appType: 'standard' | 'lite'; + accountLogin: string; + source: 'user' | 'installation'; + gitAuthor: GitAuthorConfig; + commitCoAuthor?: GitAuthorConfig; + fallbackReason?: ManagedGitHubFallbackReason; +}; + type CloudAgentGitHubAuthResult = | { success: true; value: ResolvedCloudAgentGitHubAuth } | { success: false; error: ResolveGitHubTokenError }; @@ -176,10 +187,137 @@ export async function resolveCloudAgentGitHubAuthForRepo( } } +export async function issueCloudAgentGitHubSessionCapability( + env: GitTokenServiceEnv, + params: { + githubRepo: string; + userId: string; + outboundContainerId: string; + orgId?: string; + allowUserAuthorization: boolean; + } +): Promise< + | { success: true; value: ResolvedCloudAgentGitHubCapability } + | { success: false; error: ResolveGitHubTokenError } +> { + if (!env.GIT_TOKEN_SERVICE) { + return { + success: false, + error: { + reason: 'service_not_configured', + message: 'git-token-service capability issuance is not configured', + }, + }; + } + + try { + const result = await env.GIT_TOKEN_SERVICE.issueGitHubSessionCapability(params); + if (!result.success) { + return { + success: false, + error: { + reason: result.reason, + message: `GitHub managed auth lookup failed (${result.reason})`, + }, + }; + } + logger + .withFields({ + installationId: result.installationId, + accountLogin: result.accountLogin, + githubAppType: result.appType, + source: result.source, + fallbackReason: result.fallbackReason, + }) + .info('Issued managed GitHub session capability via git-token-service'); + return { + success: true, + value: { + capability: result.capability, + installationId: result.installationId, + appType: result.appType, + accountLogin: result.accountLogin, + source: result.source, + gitAuthor: result.gitAuthor, + ...(result.commitCoAuthor ? { commitCoAuthor: result.commitCoAuthor } : {}), + ...(result.fallbackReason ? { fallbackReason: result.fallbackReason } : {}), + }, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error('Failed to issue managed GitHub session capability'); + return { + success: false, + error: { reason: 'rpc_error', message: `git-token-service RPC failed: ${message}` }, + }; + } +} + +export type ResolvedCloudAgentGitLabCapability = { + capability: string; + gitUrl: string; + instanceOrigin: string; + instanceHost: string; + projectPath: string; + integrationId: string; + authType: 'oauth' | 'pat'; + identity: { accountId: string | null; accountLogin: string | null }; + glabIsOAuth2: boolean; +}; + export type ResolveManagedGitLabTokenResult = | { success: true; token: string; glabIsOAuth2: boolean } | { success: false; reason: string }; +export async function issueCloudAgentGitLabSessionCapability( + env: GitTokenServiceEnv, + params: { + gitUrl: string; + userId: string; + outboundContainerId: string; + orgId?: string; + createdOnPlatform?: string; + } +): Promise< + { success: true; value: ResolvedCloudAgentGitLabCapability } | { success: false; reason: string } +> { + if (!env.GIT_TOKEN_SERVICE) { + return { success: false, reason: 'service_not_configured' }; + } + + try { + const result = await env.GIT_TOKEN_SERVICE.issueGitLabSessionCapability(params); + if (!result.success) return result; + logger + .withFields({ + instanceHost: result.instanceHost, + projectPath: result.projectPath, + authType: result.authType, + }) + .info('Issued managed GitLab session capability via git-token-service'); + return { + success: true, + value: { + capability: result.capability, + gitUrl: `${result.instanceOrigin}/${result.projectPath}.git`, + instanceOrigin: result.instanceOrigin, + instanceHost: result.instanceHost, + projectPath: result.projectPath, + integrationId: result.integrationId, + authType: result.authType, + identity: result.identity, + glabIsOAuth2: result.glabIsOAuth2, + }, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger + .withFields({ error: message }) + .error('Failed to issue managed GitLab session capability'); + return { success: false, reason: 'rpc_error' }; + } +} + export async function resolveManagedGitLabToken( env: GitTokenServiceEnv, params: { diff --git a/services/cloud-agent-next/src/session-service.test.ts b/services/cloud-agent-next/src/session-service.test.ts index a8cf78f6c8..f845cefab3 100644 --- a/services/cloud-agent-next/src/session-service.test.ts +++ b/services/cloud-agent-next/src/session-service.test.ts @@ -40,6 +40,8 @@ vi.mock('./workspace.js', () => ({ })); const tokenMocks = vi.hoisted(() => ({ + issueCloudAgentGitHubSessionCapability: vi.fn(), + issueCloudAgentGitLabSessionCapability: vi.fn(), resolveCloudAgentGitHubAuthForRepo: vi.fn(), resolveManagedGitLabToken: vi.fn(), })); @@ -163,7 +165,15 @@ function createSandbox( function createEnv(metadata?: CloudAgentSessionState | null): PersistenceEnv { return { - Sandbox: {} as PersistenceEnv['Sandbox'], + Sandbox: { + idFromName: vi.fn(() => 'sandbox-do-id' as unknown as DurableObjectId), + } as unknown as PersistenceEnv['Sandbox'], + SandboxSmall: { + idFromName: vi.fn(() => 'small-sandbox-do-id' as unknown as DurableObjectId), + } as unknown as PersistenceEnv['SandboxSmall'], + SandboxDIND: { + idFromName: vi.fn(() => 'dind-sandbox-do-id' as unknown as DurableObjectId), + } as unknown as PersistenceEnv['SandboxDIND'], CLOUD_AGENT_SESSION: { idFromName: vi.fn(() => 'do-id' as unknown as DurableObjectId), get: vi.fn(() => ({ @@ -198,12 +208,24 @@ function createEnv(metadata?: CloudAgentSessionState | null): PersistenceEnv { source: 'installation', gitAuthor: { name: 'kiloconnect[bot]', email: 'bot@example.com' }, }), + issueGitHubSessionCapability: vi.fn().mockResolvedValue({ + success: true, + capability: 'kgh2.default', + installationId: '123', + accountLogin: 'acme', + appType: 'standard', + source: 'installation', + gitAuthor: { name: 'kiloconnect[bot]', email: 'bot@example.com' }, + }), + redeemGitHubSessionCapability: vi.fn(), getGitLabToken: vi.fn().mockResolvedValue({ success: true, token: 'resolved-gitlab-token', instanceUrl: 'https://gitlab.com', glabIsOAuth2: true, }), + issueGitLabSessionCapability: vi.fn(), + redeemGitLabSessionCapability: vi.fn(), }, NOTIFICATIONS: {} as unknown as PersistenceEnv['NOTIFICATIONS'], } satisfies PersistenceEnv; @@ -272,6 +294,30 @@ describe('SessionService.prepareWorkspace', () => { gitAuthor: { name: 'kiloconnect[bot]', email: 'bot@example.com' }, }, }); + tokenMocks.issueCloudAgentGitHubSessionCapability.mockResolvedValue({ + success: true, + value: { + capability: 'kgh2.default', + installationId: '123', + accountLogin: 'acme', + appType: 'standard', + source: 'installation', + gitAuthor: { name: 'kiloconnect[bot]', email: 'bot@example.com' }, + }, + }); + tokenMocks.issueCloudAgentGitLabSessionCapability.mockResolvedValue({ + success: true, + value: { + capability: 'kgl2.default', + instanceOrigin: 'https://gitlab.com', + instanceHost: 'gitlab.com', + projectPath: 'acme/repo', + integrationId: 'integration_1', + authType: 'oauth', + identity: { accountId: '42', accountLogin: 'octocat' }, + glabIsOAuth2: true, + }, + }); tokenMocks.resolveManagedGitLabToken.mockResolvedValue({ success: true, token: 'resolved-gitlab-token', @@ -310,10 +356,12 @@ describe('SessionService.prepareWorkspace', () => { session, '/workspace/user/sessions/agent_test', 'https://gitlab.com/acme/repo.git', - 'resolved-gitlab-token', + 'kgl2.default', undefined, { platform: 'gitlab' } ); + expect(tokenMocks.issueCloudAgentGitLabSessionCapability).toHaveBeenCalled(); + expect(tokenMocks.resolveManagedGitLabToken).not.toHaveBeenCalled(); expect(workspaceMocks.manageBranch).toHaveBeenCalledWith( session, '/workspace/user/sessions/agent_test', @@ -328,7 +376,7 @@ describe('SessionService.prepareWorkspace', () => { sessionHome: '/home/agent_test', branchName: 'main', kiloSessionId: 'kilo-session', - gitToken: 'resolved-gitlab-token', + gitToken: 'kgl2.default', gitlabTokenManaged: true, }); }); @@ -594,7 +642,7 @@ describe('SessionService.prepareWorkspace', () => { expect(restoreCommand).not.toContain('KILOCODE_TOKEN='); }); - it('refreshes the warm fast path GitHub remote with repo lookup token when legacy metadata stored a token', async () => { + it('refreshes prepared GitHub workspace metadata with a managed capability', async () => { const session = createSession(true); const sandbox = createSandbox(session, true); const metadata = createMetadata({ @@ -620,22 +668,24 @@ describe('SessionService.prepareWorkspace', () => { }); expect(workspaceMocks.cloneGitHubRepo).not.toHaveBeenCalled(); - expect(tokenMocks.resolveCloudAgentGitHubAuthForRepo).toHaveBeenCalledWith( + expect(tokenMocks.issueCloudAgentGitHubSessionCapability).toHaveBeenCalledWith( expect.objectContaining({ GIT_TOKEN_SERVICE: expect.any(Object), }), { githubRepo: 'acme/repo', userId: 'user_test', + outboundContainerId: 'sandbox-do-id', orgId: undefined, allowUserAuthorization: false, } ); + expect(tokenMocks.resolveCloudAgentGitHubAuthForRepo).not.toHaveBeenCalled(); expect(workspaceMocks.updateGitRemoteToken).toHaveBeenCalledWith( session, '/workspace/user/sessions/agent_test', 'https://github.com/acme/repo.git', - 'resolved-gh-token' + 'kgh2.default' ); }); @@ -776,7 +826,7 @@ describe('SessionService.prepareWorkspace', () => { }); }); - it('refreshes the warm fast path git remote with a fresh GitHub installation token', async () => { + it('refreshes a prepared warm GitHub remote with a managed capability', async () => { const session = createSession(true); const sandbox = createSandbox(session, true); const getTokenMock = vi.fn().mockResolvedValue('legacy-installation-token'); @@ -785,17 +835,6 @@ describe('SessionService.prepareWorkspace', () => { ...env.GIT_TOKEN_SERVICE, getToken: getTokenMock, } as PersistenceEnv['GIT_TOKEN_SERVICE']; - tokenMocks.resolveCloudAgentGitHubAuthForRepo.mockResolvedValueOnce({ - success: true, - value: { - githubToken: 'installation-token', - installationId: '123', - accountLogin: 'acme', - appType: 'standard', - source: 'installation', - gitAuthor: { name: 'kiloconnect[bot]', email: 'bot@example.com' }, - }, - }); const metadata = createMetadata({ githubRepo: 'acme/repo', githubToken: 'stale-installation-token', @@ -822,16 +861,17 @@ describe('SessionService.prepareWorkspace', () => { expect(workspaceMocks.cloneGitHubRepo).not.toHaveBeenCalled(); expect(getTokenMock).not.toHaveBeenCalled(); - expect(tokenMocks.resolveCloudAgentGitHubAuthForRepo).toHaveBeenCalled(); + expect(tokenMocks.issueCloudAgentGitHubSessionCapability).toHaveBeenCalled(); + expect(tokenMocks.resolveCloudAgentGitHubAuthForRepo).not.toHaveBeenCalled(); expect(workspaceMocks.updateGitRemoteToken).toHaveBeenCalledWith( session, '/workspace/user/sessions/agent_test', 'https://github.com/acme/repo.git', - 'installation-token' + 'kgh2.default' ); }); - it('refreshes the warm fast path git remote with a fresh managed GitLab token even when legacy metadata opted out', async () => { + it('refreshes the warm fast path GitLab remote with a capability even when legacy metadata opted out', async () => { const session = createSession(true); const sandbox = createSandbox(session, true); const metadata = createMetadata({ @@ -856,23 +896,33 @@ describe('SessionService.prepareWorkspace', () => { }); expect(workspaceMocks.cloneGitRepo).not.toHaveBeenCalled(); - expect(tokenMocks.resolveManagedGitLabToken).toHaveBeenCalled(); + expect(tokenMocks.issueCloudAgentGitLabSessionCapability).toHaveBeenCalled(); + expect(tokenMocks.resolveManagedGitLabToken).not.toHaveBeenCalled(); expect(workspaceMocks.updateGitRemoteToken).toHaveBeenCalledWith( session, '/workspace/user/sessions/agent_test', 'https://gitlab.com/acme/repo.git', - 'resolved-gitlab-token', + 'kgl2.default', 'gitlab' ); }); - it('refreshes a warm GitLab code-review remote with the generically resolved project token', async () => { + it('refreshes a warm GitLab code-review remote with a contained project capability', async () => { const session = createSession(true); const sandbox = createSandbox(session, true); - tokenMocks.resolveManagedGitLabToken.mockResolvedValueOnce({ + tokenMocks.issueCloudAgentGitLabSessionCapability.mockResolvedValueOnce({ success: true, - token: 'resolved-project-token', - glabIsOAuth2: false, + value: { + capability: 'kgl2.project', + gitUrl: 'https://gitlab.com/acme/repo.git', + instanceOrigin: 'https://gitlab.com', + instanceHost: 'gitlab.com', + projectPath: 'acme/repo', + integrationId: 'project_token_1', + authType: 'pat', + identity: { accountId: null, accountLogin: null }, + glabIsOAuth2: false, + }, }); await new SessionService().prepareWorkspace({ @@ -885,22 +935,27 @@ describe('SessionService.prepareWorkspace', () => { kilocodeModel: 'test-model', }); - expect(tokenMocks.resolveManagedGitLabToken).toHaveBeenCalledWith(expect.any(Object), { - userId: 'user_test', - orgId: undefined, - repositoryUrl: 'https://gitlab.com/acme/repo.git', - createdOnPlatform: 'code-review', - }); + expect(tokenMocks.issueCloudAgentGitLabSessionCapability).toHaveBeenCalledWith( + expect.any(Object), + { + gitUrl: 'https://gitlab.com/acme/repo.git', + userId: 'user_test', + outboundContainerId: 'sandbox-do-id', + orgId: undefined, + createdOnPlatform: 'code-review', + } + ); + expect(tokenMocks.resolveManagedGitLabToken).not.toHaveBeenCalled(); expect(workspaceMocks.updateGitRemoteToken).toHaveBeenCalledWith( session, '/workspace/user/sessions/agent_test', 'https://gitlab.com/acme/repo.git', - 'resolved-project-token', + 'kgl2.project', 'gitlab' ); }); - it('refreshes the warm fast path GitHub remote when repo lookup resolves a managed token', async () => { + it('refreshes a prepared warm GitHub remote through managed capability authentication', async () => { const session = createSession(true); const sandbox = createSandbox(session, true); const metadata = createMetadata({ @@ -926,14 +981,110 @@ describe('SessionService.prepareWorkspace', () => { kilocodeModel: 'test-model', }); + expect(tokenMocks.issueCloudAgentGitHubSessionCapability).toHaveBeenCalled(); + expect(tokenMocks.resolveCloudAgentGitHubAuthForRepo).not.toHaveBeenCalled(); expect(workspaceMocks.updateGitRemoteToken).toHaveBeenCalledWith( session, '/workspace/user/sessions/agent_test', 'https://github.com/acme/repo.git', - 'resolved-gh-token' + 'kgh2.default' + ); + }); + + it('uses managed GitHub capability authentication for requested devcontainer preparation', async () => { + const session = createSession(false); + const sandbox = createSandbox(session); + const metadata = { + ...createMetadata({ + githubRepo: 'acme/repo', + gitUrl: undefined, + gitToken: undefined, + platform: 'github', + }), + workspace: { + sandboxId: 'dind-abcdef' as const, + devcontainerRequested: true, + }, + } satisfies CloudAgentSessionState; + devcontainerMocks.detectDevContainer.mockResolvedValue({ + configPath: '.devcontainer/devcontainer.json', + }); + devcontainerMocks.bringUpDevContainer.mockResolvedValue({ + containerId: 'container-dev', + innerWorkspaceFolder: '/workspaces/repo', + workspacePath: '/workspace/user/sessions/agent_test', + agentSessionId: 'agent_test', + overrideConfigPath: '/tmp/devcontainer-override-agent_test/devcontainer.json', + teardown: vi.fn().mockResolvedValue(undefined), + }); + + await new SessionService().prepareWorkspace({ + sandbox, + sandboxId: 'dind-abcdef', + userId: 'user_test', + sessionId: 'agent_test' as SessionId, + env: createEnv(), + metadata, + kilocodeModel: 'test-model', + }); + + expect(tokenMocks.issueCloudAgentGitHubSessionCapability).toHaveBeenCalled(); + expect(tokenMocks.resolveCloudAgentGitHubAuthForRepo).not.toHaveBeenCalled(); + expect(workspaceMocks.cloneGitHubRepo).toHaveBeenCalledWith( + session, + '/workspace/user/sessions/agent_test', + 'acme/repo', + 'kgh2.default', + { name: 'kiloconnect[bot]', email: 'bot@example.com' }, + undefined ); }); + it('fails closed without a raw GitLab token fallback when prepared workspace capability issuance fails', async () => { + tokenMocks.issueCloudAgentGitLabSessionCapability.mockResolvedValueOnce({ + success: false, + reason: 'rpc_error', + }); + + await expect( + new SessionService().prepareWorkspace({ + sandbox: createSandbox(createSession()), + sandboxId: 'dind-abcdef', + userId: 'user_test', + sessionId: 'agent_test' as SessionId, + env: createEnv(), + metadata: createMetadata(), + }) + ).rejects.toThrow('GitLab token lookup failed (rpc_error)'); + + expect(tokenMocks.resolveManagedGitLabToken).not.toHaveBeenCalled(); + }); + + it('fails closed without raw GitHub auth fallback when prepared workspace capability issuance fails', async () => { + tokenMocks.issueCloudAgentGitHubSessionCapability.mockResolvedValueOnce({ + success: false, + error: { reason: 'rpc_error', message: 'RPC unavailable' }, + }); + + await expect( + new SessionService().prepareWorkspace({ + sandbox: createSandbox(createSession()), + sandboxId: 'dind-abcdef', + userId: 'user_test', + sessionId: 'agent_test' as SessionId, + env: createEnv(), + metadata: createMetadata({ + githubRepo: 'acme/repo', + gitUrl: undefined, + gitToken: undefined, + platform: 'github', + }), + }) + ).rejects.toThrow('GitHub token or active app installation required'); + + expect(tokenMocks.resolveCloudAgentGitHubAuthForRepo).not.toHaveBeenCalled(); + }); + it('throws when required metadata is missing', async () => { const metadata = createMetadata({ kilocodeToken: undefined }); @@ -964,6 +1115,30 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { gitAuthor: { name: 'kiloconnect[bot]', email: 'bot@example.com' }, }, }); + tokenMocks.issueCloudAgentGitHubSessionCapability.mockResolvedValue({ + success: true, + value: { + capability: 'kgh2.default', + installationId: '123', + accountLogin: 'acme', + appType: 'standard', + source: 'installation', + gitAuthor: { name: 'kiloconnect[bot]', email: 'bot@example.com' }, + }, + }); + tokenMocks.issueCloudAgentGitLabSessionCapability.mockResolvedValue({ + success: true, + value: { + capability: 'kgl2.default', + instanceOrigin: 'https://gitlab.com', + instanceHost: 'gitlab.com', + projectPath: 'acme/repo', + integrationId: 'integration_1', + authType: 'oauth', + identity: { accountId: '42', accountLogin: 'octocat' }, + glabIsOAuth2: true, + }, + }); tokenMocks.resolveManagedGitLabToken.mockResolvedValue({ success: true, token: 'resolved-gitlab-token', @@ -1001,7 +1176,7 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { model: 'test-model', }, workspace: { - sandboxId: 'usr-abcdef', + sandboxId: metadata.workspace?.sandboxId ?? 'usr-abcdef', metadata, }, wrapper: { @@ -1044,7 +1219,7 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { model: 'test-model', }, workspace: { - sandboxId: 'dind-abcdef', + sandboxId: metadata.workspace?.sandboxId ?? 'usr-abcdef', metadata, }, wrapper: { @@ -1061,6 +1236,183 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { expect(result.ready.devcontainer).toBeUndefined(); }); + it('uses managed GitLab capability authentication for a DIND sandbox without devcontainer metadata', async () => { + const result = await buildPromptWrapperRequests({ + ...createMetadata(), + workspace: { sandboxId: 'dind-abcdef' }, + } satisfies CloudAgentSessionState); + + expect(tokenMocks.issueCloudAgentGitLabSessionCapability).toHaveBeenCalled(); + expect(tokenMocks.resolveManagedGitLabToken).not.toHaveBeenCalled(); + expect(result.readyRequest.repo).toMatchObject({ + token: 'kgl2.default', + }); + expect(result.readyRequest.materialized.env.GITLAB_TOKEN).toBe('kgl2.default'); + expect(JSON.stringify(result.readyRequest)).not.toContain('resolved-gitlab-token'); + }); + + it.each([ + ['ses-abcdef', 'small-sandbox-do-id'], + ['dind-abcdef', 'dind-sandbox-do-id'], + ] as const)( + 'derives managed capability outboundContainerId for %s through its sandbox namespace', + async (sandboxId, outboundContainerId) => { + await buildPromptWrapperRequests({ + ...createMetadata(), + workspace: { sandboxId }, + } satisfies CloudAgentSessionState); + + expect(tokenMocks.issueCloudAgentGitLabSessionCapability).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ outboundContainerId }) + ); + } + ); + + it('uses managed GitLab capability authentication in DIND devcontainer wrapper readiness', async () => { + const result = await buildPromptWrapperRequests({ + ...createMetadata(), + workspace: { + sandboxId: 'dind-abcdef', + devcontainerRequested: true, + }, + } satisfies CloudAgentSessionState); + + expect(tokenMocks.issueCloudAgentGitLabSessionCapability).toHaveBeenCalled(); + expect(tokenMocks.resolveManagedGitLabToken).not.toHaveBeenCalled(); + expect(result.readyRequest.repo).toMatchObject({ + kind: 'git', + token: 'kgl2.default', + platform: 'gitlab', + }); + expect(result.readyRequest.materialized.env.GITLAB_TOKEN).toBe('kgl2.default'); + expect(JSON.stringify(result.readyRequest)).not.toContain('resolved-gitlab-token'); + }); + + it('fails closed without raw GitLab fallback when DIND wrapper capability issuance fails', async () => { + tokenMocks.issueCloudAgentGitLabSessionCapability.mockResolvedValueOnce({ + success: false, + reason: 'rpc_error', + }); + + await expect( + buildPromptWrapperRequests({ + ...createMetadata(), + workspace: { sandboxId: 'dind-abcdef', devcontainerRequested: true }, + } satisfies CloudAgentSessionState) + ).rejects.toThrow('GitLab token lookup failed (rpc_error)'); + + expect(tokenMocks.resolveManagedGitLabToken).not.toHaveBeenCalled(); + }); + + it('fails closed without raw GitHub fallback when DIND wrapper capability issuance fails', async () => { + tokenMocks.issueCloudAgentGitHubSessionCapability.mockResolvedValueOnce({ + success: false, + error: { reason: 'rpc_error', message: 'RPC unavailable' }, + }); + + await expect( + buildPromptWrapperRequests({ + ...createMetadata({ + githubRepo: 'acme/repo', + gitUrl: undefined, + gitToken: undefined, + platform: 'github', + }), + workspace: { sandboxId: 'dind-abcdef', devcontainerRequested: true }, + } satisfies CloudAgentSessionState) + ).rejects.toThrow('GitHub token or active app installation required'); + + expect(tokenMocks.resolveCloudAgentGitHubAuthForRepo).not.toHaveBeenCalled(); + }); + + it('uses a managed GitLab capability for a prepared standard wrapper workspace', async () => { + const result = await buildPromptWrapperRequests(createMetadata({ preparedAt: 1 })); + + expect(tokenMocks.issueCloudAgentGitLabSessionCapability).toHaveBeenCalled(); + expect(tokenMocks.resolveManagedGitLabToken).not.toHaveBeenCalled(); + expect(result.readyRequest.repo).toMatchObject({ + token: 'kgl2.default', + }); + expect(result.readyRequest.materialized.env.GITLAB_TOKEN).toBe('kgl2.default'); + expect(JSON.stringify(result.readyRequest)).not.toContain('resolved-gitlab-token'); + }); + + it('uses managed GitLab capability authentication for a resumed DIND session', async () => { + const result = await buildPromptWrapperRequests({ + ...createMetadata({ preparedAt: 1 }), + workspace: { sandboxId: 'dind-abcdef' }, + devcontainer: { + workspacePath: '/workspace/user/sessions/agent_test', + innerWorkspaceFolder: '/workspaces/repo', + wrapperPort: 4173, + configPath: '.devcontainer/devcontainer.json', + }, + } satisfies CloudAgentSessionState); + + expect(tokenMocks.issueCloudAgentGitLabSessionCapability).toHaveBeenCalled(); + expect(tokenMocks.resolveManagedGitLabToken).not.toHaveBeenCalled(); + expect(result.readyRequest.repo).toMatchObject({ + token: 'kgl2.default', + }); + expect(result.readyRequest.materialized.env.GITLAB_TOKEN).toBe('kgl2.default'); + expect(JSON.stringify(result.readyRequest)).not.toContain('resolved-gitlab-token'); + }); + + it('uses managed GitHub capability authentication in DIND devcontainer wrapper readiness', async () => { + const result = await buildPromptWrapperRequests({ + ...createMetadata({ + githubRepo: 'acme/repo', + gitUrl: undefined, + gitToken: undefined, + platform: 'github', + }), + workspace: { + sandboxId: 'dind-abcdef', + devcontainerRequested: true, + }, + } satisfies CloudAgentSessionState); + + expect(tokenMocks.issueCloudAgentGitHubSessionCapability).toHaveBeenCalled(); + expect(tokenMocks.resolveCloudAgentGitHubAuthForRepo).not.toHaveBeenCalled(); + expect(result.readyRequest.repo).toMatchObject({ + kind: 'github', + token: 'kgh2.default', + }); + expect(result.readyRequest.materialized.env.GH_TOKEN).toBe('kgh2.default'); + expect(JSON.stringify(result.readyRequest)).not.toContain('resolved-gh-token'); + }); + + it('uses managed GitHub capability authentication for a resumed DIND session with resolved devcontainer metadata', async () => { + const devcontainer = { + workspacePath: '/workspace/user/sessions/agent_test', + innerWorkspaceFolder: '/workspaces/repo', + wrapperPort: 4173, + configPath: '.devcontainer/devcontainer.json', + }; + const result = await buildPromptWrapperRequests({ + ...createMetadata({ + preparedAt: 1, + githubRepo: 'acme/repo', + gitUrl: undefined, + gitToken: undefined, + platform: 'github', + }), + workspace: { sandboxId: 'dind-abcdef' }, + devcontainer, + } satisfies CloudAgentSessionState); + + expect(tokenMocks.issueCloudAgentGitHubSessionCapability).toHaveBeenCalled(); + expect(tokenMocks.resolveCloudAgentGitHubAuthForRepo).not.toHaveBeenCalled(); + expect(result.readyRequest.repo).toMatchObject({ + kind: 'github', + token: 'kgh2.default', + }); + expect(result.readyRequest.materialized.env.GH_TOKEN).toBe('kgh2.default'); + expect(JSON.stringify(result.readyRequest)).not.toContain('resolved-gh-token'); + expect(result.readyRequest.devcontainer).toEqual({ requested: true, resolved: devcontainer }); + }); + it('materializes workspace setup and prompt delivery without using provider token overrides for ingest auth', async () => { const service = new SessionService(); const env = createEnv(); @@ -1115,9 +1467,19 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { sessionHome: '/home/agent_test', branchName: 'main', kiloSessionId: 'kilo-session', - gitToken: 'resolved-gitlab-token', + gitToken: 'kgl2.default', gitlabTokenManaged: true, }); + expect(tokenMocks.issueCloudAgentGitLabSessionCapability).toHaveBeenCalledWith( + expect.any(Object), + { + gitUrl: 'https://gitlab.com/acme/repo.git', + userId: 'user_test', + outboundContainerId: 'sandbox-do-id', + orgId: undefined, + } + ); + expect(tokenMocks.resolveManagedGitLabToken).not.toHaveBeenCalled(); expect(result.readyRequest).toMatchObject({ agentSessionId: 'agent_test', userId: 'user_test', @@ -1132,7 +1494,7 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { repo: { kind: 'git', url: 'https://gitlab.com/acme/repo.git', - token: 'resolved-gitlab-token', + token: 'kgl2.default', platform: 'gitlab', }, materialized: { @@ -1149,7 +1511,8 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { expect(JSON.parse(result.readyRequest.materialized.env.KILO_AUTH_CONTENT)).toEqual({ kilo: { type: 'api', key: 'kilo-token' }, }); - expect(result.readyRequest.materialized.env.GITLAB_TOKEN).toBe('resolved-gitlab-token'); + expect(result.readyRequest.materialized.env.GITLAB_TOKEN).toBe('kgl2.default'); + expect(JSON.stringify(result.readyRequest)).not.toContain('resolved-gitlab-token'); expect(result.readyRequest.materialized.env.GITLAB_HOST).toBe('gitlab.com'); expect(result.readyRequest.materialized.env.GLAB_IS_OAUTH2).toBe('true'); expect(result.readyRequest.session.workerAuthToken).toBe('kilo-token'); @@ -1244,11 +1607,45 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { expect(result.promptRequest.message.attachments).toEqual(signedAttachments); }); - it('uses selected user GitHub auth for the remote, author, and managed GH_TOKEN', async () => { - tokenMocks.resolveCloudAgentGitHubAuthForRepo.mockResolvedValueOnce({ + it('fails closed without a raw managed GitLab token fallback when wrapper capability issuance fails', async () => { + tokenMocks.issueCloudAgentGitLabSessionCapability.mockResolvedValueOnce({ + success: false, + reason: 'rpc_error', + }); + + await expect(buildPromptWrapperRequests(createMetadata())).rejects.toThrow( + 'GitLab token lookup failed (rpc_error)' + ); + + expect(tokenMocks.issueCloudAgentGitLabSessionCapability).toHaveBeenCalled(); + expect(tokenMocks.resolveManagedGitLabToken).not.toHaveBeenCalled(); + }); + + it('fails closed without a raw managed token fallback when wrapper capability issuance fails', async () => { + tokenMocks.issueCloudAgentGitHubSessionCapability.mockResolvedValueOnce({ + success: false, + error: { reason: 'rpc_error', message: 'RPC unavailable' }, + }); + const metadata = createMetadata({ + githubRepo: 'acme/repo', + gitUrl: undefined, + gitToken: undefined, + platform: 'github', + }); + + await expect(buildPromptWrapperRequests(metadata)).rejects.toThrow( + 'GitHub token or active app installation required' + ); + + expect(tokenMocks.issueCloudAgentGitHubSessionCapability).toHaveBeenCalled(); + expect(tokenMocks.resolveCloudAgentGitHubAuthForRepo).not.toHaveBeenCalled(); + }); + + it('uses a capability for covered selected-user GitHub remote and managed GH_TOKEN', async () => { + tokenMocks.issueCloudAgentGitHubSessionCapability.mockResolvedValueOnce({ success: true, value: { - githubToken: 'selected-user-token', + capability: 'kgh2.selected-user', installationId: '123', accountLogin: 'acme', appType: 'standard', @@ -1266,18 +1663,24 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { }); const result = await buildPromptWrapperRequests(metadata); - expect(tokenMocks.resolveCloudAgentGitHubAuthForRepo).toHaveBeenCalledWith(expect.any(Object), { - githubRepo: 'acme/repo', - userId: 'user_test', - orgId: undefined, - allowUserAuthorization: true, - }); + expect(tokenMocks.issueCloudAgentGitHubSessionCapability).toHaveBeenCalledWith( + expect.any(Object), + { + githubRepo: 'acme/repo', + userId: 'user_test', + outboundContainerId: 'sandbox-do-id', + orgId: undefined, + allowUserAuthorization: true, + } + ); + expect(tokenMocks.resolveCloudAgentGitHubAuthForRepo).not.toHaveBeenCalled(); expect(result.readyRequest.repo).toMatchObject({ kind: 'github', - token: 'selected-user-token', + token: 'kgh2.selected-user', gitAuthor: { name: 'octocat', email: '1+octocat@users.noreply.github.com' }, }); - expect(result.readyRequest.materialized.env.GH_TOKEN).toBe('selected-user-token'); + expect(result.readyRequest.materialized.env.GH_TOKEN).toBe('kgh2.selected-user'); + expect(JSON.stringify(result.readyRequest)).not.toContain('selected-user-token'); if (result.type !== 'prompt') throw new Error('Expected prompt delivery request'); expect(result.promptRequest.finalization?.commitCoAuthor).toEqual({ name: 'kiloconnect[bot]', @@ -1295,16 +1698,20 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { }); await buildPromptWrapperRequests(metadata); - expect(tokenMocks.resolveCloudAgentGitHubAuthForRepo).toHaveBeenCalledWith(expect.any(Object), { - githubRepo: 'acme/repo', - userId: 'user_test', - orgId: undefined, - allowUserAuthorization: true, - }); + expect(tokenMocks.issueCloudAgentGitHubSessionCapability).toHaveBeenCalledWith( + expect.any(Object), + { + githubRepo: 'acme/repo', + userId: 'user_test', + outboundContainerId: 'sandbox-do-id', + orgId: undefined, + allowUserAuthorization: true, + } + ); }); it.each([undefined, 'code-review', 'discord', 'github'])( - 'requests installation-only GitHub auth for %s-origin sessions', + 'requests installation-only GitHub capability for %s-origin sessions', async createdOnPlatform => { await buildPromptWrapperRequests( createMetadata({ @@ -1316,11 +1723,12 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { }) ); - expect(tokenMocks.resolveCloudAgentGitHubAuthForRepo).toHaveBeenCalledWith( + expect(tokenMocks.issueCloudAgentGitHubSessionCapability).toHaveBeenCalledWith( expect.any(Object), { githubRepo: 'acme/repo', userId: 'user_test', + outboundContainerId: 'sandbox-do-id', orgId: undefined, allowUserAuthorization: false, } @@ -1328,15 +1736,19 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { } ); - it('reconstructs installation author identity during legacy token-service fallback', async () => { - tokenMocks.resolveCloudAgentGitHubAuthForRepo.mockResolvedValueOnce({ + it('preserves installation author identity supplied with capability metadata', async () => { + tokenMocks.issueCloudAgentGitHubSessionCapability.mockResolvedValueOnce({ success: true, value: { - githubToken: 'legacy-installation-token', + capability: 'kgh2.installation', installationId: '123', accountLogin: 'acme', appType: 'standard', source: 'installation', + gitAuthor: { + name: 'kiloconnect-development[bot]', + email: '242397087+kiloconnect-development[bot]@users.noreply.github.com', + }, }, }); const result = await buildPromptWrapperRequests( @@ -1345,16 +1757,12 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { gitUrl: undefined, gitToken: undefined, platform: 'github', - }), - env => { - env.GITHUB_APP_SLUG = 'kiloconnect-development'; - env.GITHUB_APP_BOT_USER_ID = '242397087'; - } + }) ); expect(result.readyRequest.repo).toMatchObject({ kind: 'github', - token: 'legacy-installation-token', + token: 'kgh2.installation', gitAuthor: { name: 'kiloconnect-development[bot]', email: '242397087+kiloconnect-development[bot]@users.noreply.github.com', @@ -1362,11 +1770,11 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { }); }); - it('preserves an explicit profile GH_TOKEN over selected user authorization', async () => { - tokenMocks.resolveCloudAgentGitHubAuthForRepo.mockResolvedValueOnce({ + it('preserves an explicit profile GH_TOKEN over a managed capability', async () => { + tokenMocks.issueCloudAgentGitHubSessionCapability.mockResolvedValueOnce({ success: true, value: { - githubToken: 'selected-user-token', + capability: 'kgh2.selected-user', installationId: '123', accountLogin: 'acme', appType: 'standard', @@ -1389,29 +1797,73 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { expect(result.readyRequest.materialized.env.GH_TOKEN).toBe('explicit-profile-token'); }); - it('materializes OAuth bearer mode with a self-managed GitLab host', async () => { + it('materializes a canonical capability URL with a nested namespace and self-managed base URL', async () => { + tokenMocks.issueCloudAgentGitLabSessionCapability.mockResolvedValueOnce({ + success: true, + value: { + capability: 'kgl2.self-managed', + gitUrl: 'https://gitlab.example.com:8443/gitlab/acme/platform/repo.git', + instanceOrigin: 'https://gitlab.example.com:8443/gitlab', + instanceHost: 'gitlab.example.com:8443', + projectPath: 'acme/platform/repo', + integrationId: 'integration_1', + authType: 'oauth', + identity: { accountId: '42', accountLogin: 'octocat' }, + glabIsOAuth2: true, + }, + }); const result = await buildPromptWrapperRequests( createMetadata({ - gitUrl: 'https://gitlab.example.com:8443/acme/repo.git', + gitUrl: 'https://gitlab.example.com:8443/gitlab/acme/platform/repo', platform: 'gitlab', }) ); expect(result.ready).toMatchObject({ - gitToken: 'resolved-gitlab-token', + gitToken: 'kgl2.self-managed', gitlabTokenManaged: true, }); + expect(tokenMocks.issueCloudAgentGitLabSessionCapability).toHaveBeenCalledWith( + expect.any(Object), + { + gitUrl: 'https://gitlab.example.com:8443/gitlab/acme/platform/repo', + userId: 'user_test', + outboundContainerId: 'sandbox-do-id', + orgId: undefined, + } + ); expect(result.readyRequest.repo).toMatchObject({ kind: 'git', - url: 'https://gitlab.example.com:8443/acme/repo.git', - token: 'resolved-gitlab-token', + url: 'https://gitlab.example.com:8443/gitlab/acme/platform/repo.git', + token: 'kgl2.self-managed', platform: 'gitlab', }); - expect(result.readyRequest.materialized.env.GITLAB_TOKEN).toBe('resolved-gitlab-token'); + expect(result.readyRequest.materialized.env.GITLAB_TOKEN).toBe('kgl2.self-managed'); expect(result.readyRequest.materialized.env.GITLAB_HOST).toBe('gitlab.example.com:8443'); + expect(result.readyRequest.materialized.env.GITLAB_SUBFOLDER).toBe('gitlab'); expect(result.readyRequest.materialized.env.GLAB_IS_OAUTH2).toBe('true'); }); + it('preserves explicit profile GitLab CLI values over a managed capability', async () => { + const result = await buildPromptWrapperRequests( + createMetadata({ + envVars: { + GITLAB_TOKEN: 'explicit-profile-token', + GITLAB_HOST: 'profile.gitlab.example.com', + GLAB_IS_OAUTH2: 'false', + }, + }) + ); + + expect(result.readyRequest.repo).toMatchObject({ + token: 'kgl2.default', + platform: 'gitlab', + }); + expect(result.readyRequest.materialized.env.GITLAB_TOKEN).toBe('explicit-profile-token'); + expect(result.readyRequest.materialized.env.GITLAB_HOST).toBe('profile.gitlab.example.com'); + expect(result.readyRequest.materialized.env.GLAB_IS_OAUTH2).toBe('false'); + }); + it('preserves an explicit profile GLAB_IS_OAUTH2 value when injecting a managed GitLab token', async () => { const result = await buildPromptWrapperRequests( createMetadata({ @@ -1422,47 +1874,71 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { ); expect(result.ready).toMatchObject({ - gitToken: 'resolved-gitlab-token', + gitToken: 'kgl2.default', gitlabTokenManaged: true, }); expect(result.readyRequest.repo).toMatchObject({ - token: 'resolved-gitlab-token', + token: 'kgl2.default', platform: 'gitlab', }); - expect(result.readyRequest.materialized.env.GITLAB_TOKEN).toBe('resolved-gitlab-token'); + expect(result.readyRequest.materialized.env.GITLAB_TOKEN).toBe('kgl2.default'); expect(result.readyRequest.materialized.env.GITLAB_HOST).toBe('gitlab.com'); expect(result.readyRequest.materialized.env.GLAB_IS_OAUTH2).toBe('false'); }); - it('materializes generic review-origin GitLab project tokens with OAuth mode disabled', async () => { - tokenMocks.resolveManagedGitLabToken.mockResolvedValueOnce({ + it('materializes a review-origin GitLab project capability with OAuth mode disabled', async () => { + tokenMocks.issueCloudAgentGitLabSessionCapability.mockResolvedValueOnce({ success: true, - token: 'resolved-project-token', - glabIsOAuth2: false, + value: { + capability: 'kgl2.project', + gitUrl: 'https://gitlab.com/acme/repo.git', + instanceOrigin: 'https://gitlab.com', + instanceHost: 'gitlab.com', + projectPath: 'acme/repo', + integrationId: 'project_token_1', + authType: 'pat', + identity: { accountId: null, accountLogin: null }, + glabIsOAuth2: false, + }, }); const result = await buildPromptWrapperRequests(createGitLabCodeReviewMetadata()); - expect(tokenMocks.resolveManagedGitLabToken).toHaveBeenCalledWith(expect.any(Object), { - userId: 'user_test', - orgId: undefined, - repositoryUrl: 'https://gitlab.com/acme/repo.git', - createdOnPlatform: 'code-review', - }); + expect(tokenMocks.issueCloudAgentGitLabSessionCapability).toHaveBeenCalledWith( + expect.any(Object), + { + gitUrl: 'https://gitlab.com/acme/repo.git', + userId: 'user_test', + outboundContainerId: 'sandbox-do-id', + orgId: undefined, + createdOnPlatform: 'code-review', + } + ); + expect(tokenMocks.resolveManagedGitLabToken).not.toHaveBeenCalled(); expect(result.readyRequest.repo).toMatchObject({ kind: 'git', - token: 'resolved-project-token', + token: 'kgl2.project', platform: 'gitlab', refreshRemote: true, }); - expect(result.readyRequest.materialized.env.GITLAB_TOKEN).toBe('resolved-project-token'); + expect(result.readyRequest.materialized.env.GITLAB_TOKEN).toBe('kgl2.project'); expect(result.readyRequest.materialized.env.GLAB_IS_OAUTH2).toBe('false'); + expect(JSON.stringify(result.readyRequest)).not.toContain('resolved-project-token'); }); - it('does not allow profile GitLab credentials to replace a resolved project token', async () => { - tokenMocks.resolveManagedGitLabToken.mockResolvedValueOnce({ + it('does not allow profile GitLab credentials to replace a review project capability', async () => { + tokenMocks.issueCloudAgentGitLabSessionCapability.mockResolvedValueOnce({ success: true, - token: 'resolved-project-token', - glabIsOAuth2: false, + value: { + capability: 'kgl2.project', + gitUrl: 'https://gitlab.com/acme/repo.git', + instanceOrigin: 'https://gitlab.com', + instanceHost: 'gitlab.com', + projectPath: 'acme/repo', + integrationId: 'project_token_1', + authType: 'pat', + identity: { accountId: null, accountLogin: null }, + glabIsOAuth2: false, + }, }); const metadata = { ...createGitLabCodeReviewMetadata(), @@ -1477,12 +1953,18 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { const result = await buildPromptWrapperRequests(metadata); - expect(result.readyRequest.materialized.env.GITLAB_TOKEN).toBe('resolved-project-token'); + expect(tokenMocks.resolveManagedGitLabToken).not.toHaveBeenCalled(); + expect(result.readyRequest.materialized.env.GITLAB_TOKEN).toBe('kgl2.project'); expect(result.readyRequest.materialized.env.GLAB_IS_OAUTH2).toBe('false'); expect(result.readyRequest.materialized.env.GITLAB_HOST).toBe('gitlab.com'); + expect(JSON.stringify(result.readyRequest)).not.toContain('configured-human-token'); }); it.each([ + [ + 'integration_identity_missing', + 'GitLab token lookup failed (integration_identity_missing). The connected GitLab integration is missing its account identity. Reconnect or reconfigure the integration.', + ], [ 'no_project_token', 'GitLab token lookup failed (no_project_token). No GitLab project access token is configured for this repository. Reconfigure or reinstall the GitLab code-review bot for the project.', @@ -1500,7 +1982,7 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { 'GitLab token lookup failed (project_lookup_failed). The connected GitLab integration cannot read this project. Grant repository access, then reconnect GitLab if required.', ], ])( - 'reports actionable review-origin GitLab token lookup failure for %s without using a human-token fallback', + 'reports actionable review-origin GitLab capability lookup failure for %s without using a human-token fallback', async (reason, expectedMessage) => { const metadata = createGitLabCodeReviewMetadata(); if (!metadata.repository || metadata.repository.type !== 'gitlab') { @@ -1514,7 +1996,7 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { }, } satisfies CloudAgentSessionState; - tokenMocks.resolveManagedGitLabToken.mockResolvedValueOnce({ + tokenMocks.issueCloudAgentGitLabSessionCapability.mockResolvedValueOnce({ success: false, reason, }); @@ -1522,12 +2004,22 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { await expect(buildPromptWrapperRequests(metadataWithFallbackToken)).rejects.toThrow( expectedMessage ); - expect(tokenMocks.resolveManagedGitLabToken).toHaveBeenCalledOnce(); + expect(tokenMocks.issueCloudAgentGitLabSessionCapability).toHaveBeenCalledWith( + expect.any(Object), + { + gitUrl: 'https://gitlab.com/acme/repo.git', + userId: 'user_test', + outboundContainerId: 'sandbox-do-id', + orgId: undefined, + createdOnPlatform: 'code-review', + } + ); + expect(tokenMocks.resolveManagedGitLabToken).not.toHaveBeenCalled(); } ); it('keeps reconnect guidance for GitLab OAuth-token lifecycle failures', async () => { - tokenMocks.resolveManagedGitLabToken.mockResolvedValueOnce({ + tokenMocks.issueCloudAgentGitLabSessionCapability.mockResolvedValueOnce({ success: false, reason: 'token_refresh_failed', }); @@ -1535,7 +2027,8 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { await expect(buildPromptWrapperRequests(createGitLabCodeReviewMetadata())).rejects.toThrow( 'GitLab token lookup failed (token_refresh_failed). Please reconnect your GitLab account.' ); - expect(tokenMocks.resolveManagedGitLabToken).toHaveBeenCalledOnce(); + expect(tokenMocks.issueCloudAgentGitLabSessionCapability).toHaveBeenCalledOnce(); + expect(tokenMocks.resolveManagedGitLabToken).not.toHaveBeenCalled(); }); it('does not use OAuth bearer mode for inferred legacy GitLab tokens', async () => { diff --git a/services/cloud-agent-next/src/session-service.ts b/services/cloud-agent-next/src/session-service.ts index 94bd3d3b22..c856eedb31 100644 --- a/services/cloud-agent-next/src/session-service.ts +++ b/services/cloud-agent-next/src/session-service.ts @@ -8,11 +8,11 @@ import type { GitAuthorConfig, ManagedGitHubFallbackReason, } from './types.js'; -import { generateSandboxId } from './sandbox-id.js'; +import { generateSandboxId, getOutboundContainerId } from './sandbox-id.js'; import { normalizeKilocodeModel } from './persistence/model-utils.js'; import { - resolveCloudAgentGitHubAuthForRepo, - resolveManagedGitLabToken, + issueCloudAgentGitHubSessionCapability, + issueCloudAgentGitLabSessionCapability, } from './services/git-token-service-client.js'; import { ExecutionError } from './execution/errors.js'; import { @@ -84,6 +84,8 @@ function gitLabTokenLookupFailureMessage(reason: string): string { case 'no_integration_found': case 'invalid_org_id': return `No GitLab integration found (${reason}). Please connect your GitLab account first.`; + case 'integration_identity_missing': + return `GitLab token lookup failed (${reason}). The connected GitLab integration is missing its account identity. Reconnect or reconfigure the integration.`; case 'no_token': case 'token_refresh_failed': case 'token_expired_no_refresh': @@ -340,7 +342,9 @@ export type ResolvedWorkspaceTokens = { githubCommitCoAuthor?: GitAuthorConfig; githubFallbackReason?: ManagedGitHubFallbackReason; gitToken?: string; + gitlabCapabilityGitUrl?: string; gitlabTokenManaged?: boolean; + gitlabInstanceUrl?: string; glabIsOAuth2?: boolean; }; @@ -905,6 +909,7 @@ export class SessionService { gitUrl?: string; gitToken?: string; gitlabTokenManaged?: boolean; + gitlabInstanceUrl?: string; glabIsOAuth2?: boolean; upstreamBranch?: string; branchName?: string; @@ -935,6 +940,7 @@ export class SessionService { gitUrl: options.gitUrl, gitToken: options.gitToken, gitlabTokenManaged: options.gitlabTokenManaged, + gitlabInstanceUrl: options.gitlabInstanceUrl, glabIsOAuth2: options.glabIsOAuth2, platform: options.platform, envVars: options.envVars, @@ -964,6 +970,7 @@ export class SessionService { appendSystemPrompt: opts.appendSystemPrompt, gitUrl: context.gitUrl, gitToken: context.gitToken, + gitlabInstanceUrl: context.gitlabInstanceUrl, glabIsOAuth2: context.glabIsOAuth2, platform: context.platform, profile: effectiveProfile, @@ -985,6 +992,7 @@ export class SessionService { appendSystemPrompt, gitUrl, gitToken, + gitlabInstanceUrl, glabIsOAuth2, platform, profile, @@ -1196,7 +1204,11 @@ export class SessionService { if (gitUrl) { try { const url = new URL(gitUrl); + const instanceUrl = gitlabInstanceUrl ? new URL(gitlabInstanceUrl) : undefined; envVars.GITLAB_HOST = url.host; + if (instanceUrl && instanceUrl.pathname !== '/') { + envVars.GITLAB_SUBFOLDER = instanceUrl.pathname.replace(/^\/+|\/+$/g, ''); + } } catch { envVars.GITLAB_HOST = 'gitlab.com'; } @@ -1284,8 +1296,10 @@ export class SessionService { async resolveWorkspaceTokens( env: PersistenceEnv, - metadata: CloudAgentSessionState + metadata: CloudAgentSessionState, + sandboxId: SandboxId ): Promise { + const outboundContainerId = getOutboundContainerId(env, sandboxId); const github = githubRepository(metadata); const git = gitRepository(metadata); let githubToken: string | undefined; @@ -1297,16 +1311,18 @@ export class SessionService { let githubFallbackReason: ManagedGitHubFallbackReason | undefined; if (github) { - const result = await resolveCloudAgentGitHubAuthForRepo(env, { + const authParams = { githubRepo: github.repo, userId: metadata.identity.userId, + outboundContainerId, orgId: metadata.identity.orgId, allowUserAuthorization: metadata.identity.createdOnPlatform === 'cloud-agent-web' || metadata.identity.createdOnPlatform === 'slack', - }); + }; + const result = await issueCloudAgentGitHubSessionCapability(env, authParams); if (result.success) { - githubToken = result.value.githubToken; + githubToken = result.value.capability; githubInstallationId = result.value.installationId; githubAppType = result.value.appType; githubSource = result.value.source; @@ -1326,23 +1342,27 @@ export class SessionService { } let gitToken = repositoryPlatform(metadata) === 'gitlab' ? undefined : git?.token; + let gitlabCapabilityGitUrl: string | undefined; let gitlabTokenManaged = git?.type === 'gitlab' ? git.gitlabTokenManaged : undefined; + let gitlabInstanceUrl: string | undefined; let glabIsOAuth2: boolean | undefined; if (git?.url && repositoryPlatform(metadata) === 'gitlab') { if (!env.GIT_TOKEN_SERVICE) { throw ExecutionError.invalidRequest('Git token service is not configured'); } - - const result = await resolveManagedGitLabToken(env, { + const result = await issueCloudAgentGitLabSessionCapability(env, { + gitUrl: git.url, userId: metadata.identity.userId, + outboundContainerId, orgId: metadata.identity.orgId, - repositoryUrl: git.url, createdOnPlatform: metadata.identity.createdOnPlatform, }); if (result.success) { - gitToken = result.token; + gitToken = result.value.capability; + gitlabCapabilityGitUrl = result.value.gitUrl; gitlabTokenManaged = true; - glabIsOAuth2 = result.glabIsOAuth2; + gitlabInstanceUrl = result.value.instanceOrigin; + glabIsOAuth2 = result.value.glabIsOAuth2; } else { throw ExecutionError.invalidRequest(gitLabTokenLookupFailureMessage(result.reason)); } @@ -1363,7 +1383,9 @@ export class SessionService { githubCommitCoAuthor, githubFallbackReason, gitToken, + gitlabCapabilityGitUrl, gitlabTokenManaged, + gitlabInstanceUrl, glabIsOAuth2, }; } @@ -1392,7 +1414,9 @@ export class SessionService { throw ExecutionError.invalidRequest('Missing kiloSessionId in session metadata'); } - const resolvedTokens = await this.resolveWorkspaceTokens(env, metadata); + const devcontainerRequested = + metadata.workspace?.devcontainerRequested === true || metadata.devcontainer !== undefined; + const resolvedTokens = await this.resolveWorkspaceTokens(env, metadata, sandboxId as SandboxId); const workspacePath = getSessionWorkspacePath(orgId, userId, sessionId); const sessionHome = getSessionHomePath(sessionId); const branchName = @@ -1402,8 +1426,6 @@ export class SessionService { const github = githubRepository(metadata); const git = gitRepository(metadata); const platform = repositoryPlatform(metadata); - const devcontainerRequested = - metadata.workspace?.devcontainerRequested === true || metadata.devcontainer !== undefined; const context = this.buildContext({ sandboxId: sandboxId as SandboxId, orgId, @@ -1413,9 +1435,10 @@ export class SessionService { sessionHome, githubRepo: github?.repo, githubToken: resolvedTokens.githubToken, - gitUrl: git?.url, + gitUrl: resolvedTokens.gitlabCapabilityGitUrl ?? git?.url, gitToken: resolvedTokens.gitToken, gitlabTokenManaged: resolvedTokens.gitlabTokenManaged, + gitlabInstanceUrl: resolvedTokens.gitlabInstanceUrl, glabIsOAuth2: resolvedTokens.glabIsOAuth2, upstreamBranch: metadata.repository?.upstreamBranch, branchName, @@ -1436,8 +1459,9 @@ export class SessionService { githubRepo: github?.repo, createdOnPlatform: metadata.identity.createdOnPlatform, appendSystemPrompt: metadata.agent?.appendSystemPrompt, - gitUrl: git?.url, + gitUrl: resolvedTokens.gitlabCapabilityGitUrl ?? git?.url, gitToken: resolvedTokens.gitToken, + gitlabInstanceUrl: resolvedTokens.gitlabInstanceUrl, glabIsOAuth2: resolvedTokens.glabIsOAuth2, platform, profile, @@ -1577,7 +1601,7 @@ export class SessionService { if (git) { return { kind: 'git', - url: git.url, + url: tokens.gitlabCapabilityGitUrl ?? git.url, ...(tokens.gitToken ? { token: tokens.gitToken } : {}), ...(repositoryPlatform(metadata) ? { platform: repositoryPlatform(metadata) } : {}), ...(repositoryShallow(metadata) !== undefined @@ -1616,7 +1640,7 @@ export class SessionService { throw ExecutionError.invalidRequest('Missing kiloSessionId in session metadata'); } - const resolvedTokens = await this.resolveWorkspaceTokens(env, metadata); + const resolvedTokens = await this.resolveWorkspaceTokens(env, metadata, sandboxId); const github = githubRepository(metadata); const git = gitRepository(metadata); const platform = repositoryPlatform(metadata); @@ -1638,9 +1662,10 @@ export class SessionService { sessionHome, githubRepo: github?.repo, githubToken: resolvedTokens.githubToken, - gitUrl: git?.url, + gitUrl: resolvedTokens.gitlabCapabilityGitUrl ?? git?.url, gitToken: resolvedTokens.gitToken, gitlabTokenManaged: resolvedTokens.gitlabTokenManaged, + gitlabInstanceUrl: resolvedTokens.gitlabInstanceUrl, glabIsOAuth2: resolvedTokens.glabIsOAuth2, upstreamBranch: metadata.repository?.upstreamBranch, branchName, @@ -1916,10 +1941,17 @@ export class SessionService { const cloneOptions = repositoryShallow(metadata) ? { shallow: true } : undefined; const git = gitRepository(metadata); if (git) { - await cloneGitRepo(session, workspacePath, git.url, tokens.gitToken, undefined, { - ...cloneOptions, - platform: repositoryPlatform(metadata), - }); + await cloneGitRepo( + session, + workspacePath, + tokens.gitlabCapabilityGitUrl ?? git.url, + tokens.gitToken, + undefined, + { + ...cloneOptions, + platform: repositoryPlatform(metadata), + } + ); return; } const github = githubRepository(metadata); @@ -2013,7 +2045,7 @@ export class SessionService { await updateGitRemoteToken( session, context.workspacePath, - git.url, + tokens.gitlabCapabilityGitUrl ?? git.url, tokens.gitToken, repositoryPlatform(metadata) ); @@ -2292,6 +2324,7 @@ type GetSaferEnvVarsOptions = { appendSystemPrompt?: string; gitUrl?: string; gitToken?: string; + gitlabInstanceUrl?: string; glabIsOAuth2?: boolean; platform?: 'github' | 'gitlab'; profile?: SessionProfileBundle; diff --git a/services/cloud-agent-next/src/types.ts b/services/cloud-agent-next/src/types.ts index 73fc1b9439..ce89d493b1 100644 --- a/services/cloud-agent-next/src/types.ts +++ b/services/cloud-agent-next/src/types.ts @@ -78,6 +78,8 @@ export type SessionContext = { gitToken?: string; /** Whether the GitLab token was resolved server-side and its remote should be refreshed. */ gitlabTokenManaged?: boolean; + /** Canonical self-managed GitLab instance URL used to configure glab. */ + gitlabInstanceUrl?: string; /** GitLab CLI bearer-mode instruction returned with a server-resolved credential. */ glabIsOAuth2?: boolean; /** Git platform type for correct token/env var handling */ @@ -123,6 +125,13 @@ export type GitAuthorConfig = { email: string; }; +type ManagedGitHubAuthParams = { + githubRepo: string; + userId: string; + orgId?: string; + allowUserAuthorization: boolean; +}; + type GetCloudAgentAuthForRepoResult = | { success: true; @@ -145,23 +154,113 @@ type GetCloudAgentAuthForRepoResult = | 'invalid_org_id'; }; -type GetGitLabTokenResult = - | { success: true; token: string; instanceUrl: string; glabIsOAuth2: boolean } +type IssueGitHubSessionCapabilityResult = + | { + success: true; + capability: string; + installationId: string; + accountLogin: string; + appType: 'standard' | 'lite'; + source: 'user' | 'installation'; + gitAuthor: GitAuthorConfig; + commitCoAuthor?: GitAuthorConfig; + fallbackReason?: ManagedGitHubFallbackReason; + } | { success: false; reason: | 'database_not_configured' - | 'no_integration_found' + | 'invalid_repo_format' + | 'no_installation_found' + | 'repository_not_installed' | 'invalid_org_id' - | 'no_token' - | 'token_refresh_failed' - | 'token_expired_no_refresh' - | 'repository_url_required' - | 'invalid_repository_url' - | 'no_matching_integration' - | 'ambiguous_integration' - | 'project_lookup_failed' - | 'no_project_token'; + | 'capability_configuration_error'; + }; + +type RedeemGitHubSessionCapabilityResult = + | { success: true; authorization: string } + | { + success: false; + reason: + | 'invalid_capability' + | 'expired_capability' + | 'capability_configuration_error' + | 'container_mismatch' + | 'invalid_upstream_url' + | 'upstream_host_not_allowed' + | 'repository_mismatch' + | 'invalid_upstream_request' + | 'source_unavailable' + | 'identity_mismatch'; + }; + +type GetGitLabTokenFailureReason = + | 'database_not_configured' + | 'no_integration_found' + | 'invalid_org_id' + | 'no_token' + | 'token_refresh_failed' + | 'token_expired_no_refresh' + | 'repository_url_required' + | 'invalid_repository_url' + | 'no_matching_integration' + | 'ambiguous_integration' + | 'project_lookup_failed' + | 'no_project_token' + | 'invalid_instance_url'; + +type GetGitLabTokenResult = + | { success: true; token: string; instanceUrl: string; glabIsOAuth2: boolean } + | { success: false; reason: GetGitLabTokenFailureReason }; + +type GitLabSessionIdentity = { + accountId: string | null; + accountLogin: string | null; +}; + +type GitLabCapabilityCredentialSource = + | { type: 'integration' } + | { type: 'project'; projectId: number; tokenDigest: string }; + +type IssueGitLabSessionCapabilityResult = + | { + success: true; + capability: string; + instanceOrigin: string; + instanceHost: string; + projectPath: string; + integrationId: string; + authType: 'oauth' | 'pat'; + identity: GitLabSessionIdentity; + source: GitLabCapabilityCredentialSource; + glabIsOAuth2: boolean; + } + | { + success: false; + reason: + | GetGitLabTokenFailureReason + | 'invalid_gitlab_url' + | 'unsupported_gitlab_instance' + | 'integration_identity_missing' + | 'capability_configuration_error'; + }; + +type RedeemGitLabSessionCapabilityResult = + | { success: true; headers: { authorization: string; 'PRIVATE-TOKEN'?: never } } + | { success: true; headers: { authorization?: never; 'PRIVATE-TOKEN': string } } + | { + success: false; + reason: + | 'invalid_capability' + | 'expired_capability' + | 'capability_configuration_error' + | 'container_mismatch' + | 'invalid_upstream_url' + | 'upstream_origin_not_allowed' + | 'repository_mismatch' + | 'invalid_upstream_request' + | 'source_unavailable' + | 'identity_mismatch'; }; export type GitTokenService = { @@ -171,18 +270,37 @@ export type GitTokenService = { orgId?: string; }): Promise; getToken(installationId: string, appType?: 'standard' | 'lite'): Promise; - getCloudAgentAuthForRepo?(params: { - githubRepo: string; - userId: string; - orgId?: string; - allowUserAuthorization: boolean; - }): Promise; + getCloudAgentAuthForRepo?( + params: ManagedGitHubAuthParams + ): Promise; + issueGitHubSessionCapability( + params: ManagedGitHubAuthParams & { outboundContainerId: string } + ): Promise; + redeemGitHubSessionCapability(params: { + capability: string; + outboundContainerId: string; + requestMethod: string; + requestUrl: string; + }): Promise; getGitLabToken(params: { userId: string; orgId?: string; repositoryUrl?: string; createdOnPlatform?: string; }): Promise; + issueGitLabSessionCapability(params: { + gitUrl: string; + userId: string; + outboundContainerId: string; + orgId?: string; + createdOnPlatform?: string; + }): Promise; + redeemGitLabSessionCapability(params: { + capability: string; + outboundContainerId: string; + requestMethod: string; + requestUrl: string; + }): Promise; }; export type Env = { diff --git a/services/cloud-agent-next/test/e2e/outbound-git-rewrite-dind-probe.ts b/services/cloud-agent-next/test/e2e/outbound-git-rewrite-dind-probe.ts new file mode 100644 index 0000000000..4b65c3f9cf --- /dev/null +++ b/services/cloud-agent-next/test/e2e/outbound-git-rewrite-dind-probe.ts @@ -0,0 +1,318 @@ +import { execFile, spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { createServer } from 'node:net'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { fileURLToPath } from 'node:url'; + +const SERVICE_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const CONFIG_PATH = path.join(SERVICE_DIR, 'wrangler.outbound-git-rewrite-dind-probe.jsonc'); +const DOCKER_PRIVILEGED_PROXY = path.join(SERVICE_DIR, 'scripts/docker-privileged-proxy.mjs'); +const STARTUP_TIMEOUT_MS = 600_000; +const PROBE_TIMEOUT_MS = 300_000; +const execFileAsync = promisify(execFile); + +type Protocol = 'http' | 'https'; +type CaPropagation = 'explicit' | 'none'; +type ExpectedOutcome = 'success' | 'tls-rejection' | 'auth-rejection'; + +type ProbePayload = { + ok: boolean; + protocol?: Protocol; + caPropagation?: CaPropagation; + expectedOutcome?: ExpectedOutcome; + exitCode?: number; + stdout?: string; + stderr?: string; + error?: string; +}; + +type ProbeObservation = { + label: string; + httpStatus?: number; + payload?: ProbePayload; + transportError?: string; +}; + +function reservePort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(); + reject(new Error('could not allocate a local port')); + return; + } + const { port } = address; + server.close(error => (error ? reject(error) : resolve(port))); + }); + }); +} + +function wait(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +async function waitForHealth( + baseUrl: string, + processOutput: () => string, + processExited: () => boolean +): Promise { + const deadline = Date.now() + STARTUP_TIMEOUT_MS; + while (Date.now() < deadline) { + if (processExited()) { + throw new Error( + `wrangler DIND probe worker exited before becoming healthy\n${processOutput()}` + ); + } + try { + const response = await fetch(`${baseUrl}/health`); + if (response.ok) return; + } catch { + // Wrangler may still be building the local DIND Sandbox image. + } + await wait(500); + } + throw new Error(`wrangler DIND probe worker did not become healthy\n${processOutput()}`); +} + +async function invokeProbe( + baseUrl: string, + label: string, + pathName: string +): Promise { + try { + const response = await fetch(`${baseUrl}${pathName}`, { + signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), + }); + const payload = (await response.json()) as ProbePayload; + return { label, httpStatus: response.status, payload }; + } catch (error) { + return { label, transportError: error instanceof Error ? error.message : String(error) }; + } +} + +function printObservation(observation: ProbeObservation): void { + if (observation.transportError) { + console.log(`${observation.label} FAIL transport=${observation.transportError}`); + return; + } + + const payload = observation.payload; + console.log( + `${observation.label} ${payload?.ok ? 'PASS' : 'FAIL'} status=${observation.httpStatus ?? 'unknown'} exitCode=${payload?.exitCode ?? 'n/a'} expected=${payload?.expectedOutcome ?? 'unknown'} ca=${payload?.caPropagation ?? 'unknown'}` + ); + if (payload?.stdout) console.log(`${observation.label} stdout:\n${payload.stdout}`); + if (payload?.stderr) console.log(`${observation.label} stderr:\n${payload.stderr}`); + if (payload?.error) console.log(`${observation.label} error=${payload.error}`); +} + +async function stopProcess(child: ChildProcessWithoutNullStreams): Promise { + if (child.exitCode !== null || child.signalCode !== null) return; + await new Promise(resolve => { + const timeout = setTimeout(() => { + child.kill('SIGKILL'); + resolve(); + }, 5_000); + child.once('exit', () => { + clearTimeout(timeout); + resolve(); + }); + child.kill('SIGTERM'); + }); +} + +async function waitForSocket(socketPath: string, processExited: () => boolean): Promise { + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + if (processExited()) + throw new Error('Docker privileged proxy exited before opening its socket'); + if (fs.existsSync(socketPath)) return; + await wait(100); + } + throw new Error(`Docker privileged proxy socket not found at ${socketPath}`); +} + +async function fetchSandboxDoId(baseUrl: string, probeId: string): Promise { + const response = await fetch(`${baseUrl}/sandbox-id?probeId=${encodeURIComponent(probeId)}`, { + signal: AbortSignal.timeout(10_000), + }); + if (!response.ok) throw new Error(`probe sandbox ID request returned HTTP ${response.status}`); + const body = (await response.json()) as { sandboxDoId?: string }; + if (!body.sandboxDoId) throw new Error('probe sandbox ID response did not include sandboxDoId'); + return body.sandboxDoId; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +async function cleanupSandbox(baseUrl: string, probeId: string): Promise { + try { + const response = await fetch(`${baseUrl}/cleanup?probeId=${encodeURIComponent(probeId)}`, { + method: 'POST', + signal: AbortSignal.timeout(60_000), + }); + return response.ok ? undefined : `probe sandbox cleanup returned HTTP ${response.status}`; + } catch (error) { + return `probe sandbox cleanup failed: ${errorMessage(error)}`; + } +} + +async function invocationArtifactExists(name: string): Promise { + try { + await execFileAsync('docker', ['container', 'inspect', name]); + return true; + } catch (error) { + const message = errorMessage(error); + if (/No such (?:object|container)/i.test(message)) return false; + throw new Error(`could not inspect invocation-specific Docker artifact ${name}: ${message}`); + } +} + +async function removeInvocationDockerArtifacts(sandboxDoId: string | undefined): Promise { + if (!sandboxDoId) return []; + const errors: string[] = []; + const sandboxName = `workerd-cloud-agent-next-outbound-git-rewrite-dind-probe-OutboundGitRewriteDindProbeSandbox-${sandboxDoId}`; + const names = [sandboxName, `${sandboxName}-proxy`]; + for (const name of names) { + try { + if (await invocationArtifactExists(name)) await execFileAsync('docker', ['rm', '-f', name]); + } catch (error) { + errors.push(`probe Docker artifact cleanup failed for ${name}: ${errorMessage(error)}`); + } + } + for (const name of names) { + try { + if (await invocationArtifactExists(name)) { + errors.push(`probe cleanup left Docker artifact: ${name}`); + continue; + } + console.log(`CLEANUP Docker artifact absent: ${name}`); + } catch (error) { + errors.push(errorMessage(error)); + } + } + return errors; +} + +function casePassed(observation: ProbeObservation): boolean { + return observation.payload?.ok === true; +} + +async function main(): Promise { + const port = await reservePort(); + const probeId = `probe-${randomUUID()}`; + const output: string[] = []; + const dockerProxySocket = path.join(os.tmpdir(), `dind-probe-${randomUUID().slice(0, 8)}.sock`); + const dockerProxy = spawn('node', [DOCKER_PRIVILEGED_PROXY], { + cwd: SERVICE_DIR, + env: { ...process.env, DOCKER_PROXY_SOCKET: dockerProxySocket }, + }); + const captureOutput = (chunk: Buffer): void => { + output.push(chunk.toString('utf8')); + }; + dockerProxy.stdout.on('data', captureOutput); + dockerProxy.stderr.on('data', captureOutput); + let wrangler: ChildProcessWithoutNullStreams | undefined; + const baseUrl = `http://127.0.0.1:${port}`; + let workerReady = false; + let sandboxDoId: string | undefined; + + try { + await waitForSocket( + dockerProxySocket, + () => dockerProxy.exitCode !== null || dockerProxy.signalCode !== null + ); + wrangler = spawn( + 'pnpm', + [ + 'exec', + 'wrangler', + 'dev', + '--config', + CONFIG_PATH, + '--env-file', + '/dev/null', + '--local', + '--port', + String(port), + '--show-interactive-dev-session=false', + '--log-level=log', + ], + { cwd: SERVICE_DIR, env: { ...process.env, DOCKER_HOST: `unix://${dockerProxySocket}` } } + ); + wrangler.stdout.on('data', captureOutput); + wrangler.stderr.on('data', captureOutput); + const spawnedWrangler = wrangler; + await waitForHealth( + baseUrl, + () => output.join(''), + () => spawnedWrangler.exitCode !== null || spawnedWrangler.signalCode !== null + ); + workerReady = true; + const idQuery = `probeId=${encodeURIComponent(probeId)}`; + sandboxDoId = await fetchSandboxDoId(baseUrl, probeId); + const gitHttp = await invokeProbe( + baseUrl, + 'NESTED_GIT_HTTP_REWRITE', + `/probe?${idQuery}&protocol=http&ca=none` + ); + const gitHttpsWithCa = await invokeProbe( + baseUrl, + 'NESTED_GIT_HTTPS_REWRITE_WITH_CA', + `/probe?${idQuery}&protocol=https&ca=explicit` + ); + const gitHttpsWithoutCa = await invokeProbe( + baseUrl, + 'NESTED_GIT_HTTPS_WITHOUT_CA_NEGATIVE', + `/probe?${idQuery}&protocol=https&ca=none` + ); + const retainedAuth = await invokeProbe( + baseUrl, + 'NESTED_GIT_HTTPS_RETAIN_AUTH_NEGATIVE', + `/probe?${idQuery}&protocol=https&ca=explicit&mode=retain` + ); + const observations = [gitHttp, gitHttpsWithCa, gitHttpsWithoutCa, retainedAuth]; + for (const observation of observations) printObservation(observation); + + if (observations.every(casePassed)) { + console.log( + 'RESULT nested --network=host routing, explicit nested CA propagation, missing-CA TLS rejection, and retained-placeholder auth rejection validated.' + ); + return; + } + console.log('RESULT Required nested DIND outbound Git rewrite assertions failed.'); + process.exitCode = 1; + } finally { + const cleanupErrors: string[] = []; + if (workerReady) { + const sandboxCleanupError = await cleanupSandbox(baseUrl, probeId); + if (sandboxCleanupError) cleanupErrors.push(sandboxCleanupError); + } + if (wrangler) await stopProcess(wrangler); + cleanupErrors.push(...(await removeInvocationDockerArtifacts(sandboxDoId))); + await stopProcess(dockerProxy); + fs.rmSync(dockerProxySocket, { force: true }); + if (cleanupErrors.length > 0) { + process.exitCode = 1; + console.error(`CLEANUP FAIL\n${cleanupErrors.map(error => `- ${error}`).join('\n')}`); + } + const wranglerOutput = output.join('').trim(); + if (process.exitCode && wranglerOutput) { + console.log(`WRANGLER output:\n${wranglerOutput}`); + } + } +} + +main().catch(error => { + console.error( + 'DIND probe runner failed:', + error instanceof Error ? error.message : String(error) + ); + process.exitCode = 1; +}); diff --git a/services/cloud-agent-next/test/e2e/outbound-git-rewrite-dind-probe.worker-configuration.d.ts b/services/cloud-agent-next/test/e2e/outbound-git-rewrite-dind-probe.worker-configuration.d.ts new file mode 100644 index 0000000000..500a58951f --- /dev/null +++ b/services/cloud-agent-next/test/e2e/outbound-git-rewrite-dind-probe.worker-configuration.d.ts @@ -0,0 +1,14 @@ +/* eslint-disable */ +// Generated by Wrangler by running `wrangler types test/e2e/outbound-git-rewrite-dind-probe.worker-configuration.d.ts --config wrangler.outbound-git-rewrite-dind-probe.jsonc --env-file /dev/null --include-runtime=false` (hash: 1d7d6e5cb4dd091b444efbce3f566653) +declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import('./outbound-git-rewrite-dind-probe.worker'); + durableNamespaces: 'OutboundGitRewriteDindProbeSandbox'; + } + interface Env { + PROBE_SANDBOX: DurableObjectNamespace< + import('./outbound-git-rewrite-dind-probe.worker').OutboundGitRewriteDindProbeSandbox + >; + } +} +interface Env extends Cloudflare.Env {} diff --git a/services/cloud-agent-next/test/e2e/outbound-git-rewrite-dind-probe.worker.ts b/services/cloud-agent-next/test/e2e/outbound-git-rewrite-dind-probe.worker.ts new file mode 100644 index 0000000000..c35871e5c0 --- /dev/null +++ b/services/cloud-agent-next/test/e2e/outbound-git-rewrite-dind-probe.worker.ts @@ -0,0 +1,237 @@ +/// + +import { ContainerProxy, getSandbox, Sandbox, type ExecutionSession } from '@cloudflare/sandbox'; + +const SYNTHETIC_GIT_HOST = 'rewrite-git.invalid'; +const SYNTHETIC_AUTH_RETAINED_HOST = 'rewrite-git-retain-auth.invalid'; +const SYNTHETIC_REPOSITORY_PATH = '/octocat/Hello-World.git'; +const PUBLIC_REPOSITORY_URL = 'https://github.com/octocat/Hello-World.git'; +const PLACEHOLDER_AUTHORIZATION = 'Basic eC1hY2Nlc3MtdG9rZW46c2FuZGJveC1wbGFjZWhvbGRlcg=='; +const NESTED_GIT_IMAGE = + 'alpine/git@sha256:8786a6a02273827d0aa039d174aacd5e017fcce9aba0af62596d991970cab01a'; +const OUTER_TRUSTED_CA_BUNDLE = '/etc/ssl/certs/ca-certificates.crt'; +const NESTED_TRUSTED_CA_BUNDLE = '/probe-ca-certificates.crt'; +const REF_OUTPUT = /^[0-9a-f]{40}\t(?:HEAD|refs\/heads\/(?:master|main))$/m; +const TLS_REJECTION = + /server certificate verification failed|SSL certificate problem|certificate verify failed|self-signed certificate in certificate chain/i; +const AUTH_REJECTION = + /Invalid username or token|Authentication failed|could not read Username.*terminal prompts disabled/; +const PROBE_ID = /^probe-[0-9a-f-]{36}$/; + +const DOCKER_SOCKET_COMMAND = + 'if [ -S /run/user/1000/docker.sock ]; then printf /run/user/1000/docker.sock; elif [ -S /var/run/docker.sock ]; then printf /var/run/docker.sock; fi'; + +const DOCKER_READY_COMMAND = `socket="$(${DOCKER_SOCKET_COMMAND})"; if [ -z "$socket" ]; then printf 'Docker socket not found' >&2; false; else DOCKER_HOST="unix://$socket" docker version --format '{{.Server.Version}}'; fi`; + +type ProbeProtocol = 'https' | 'http'; +type ForwardingMode = 'strip' | 'retain'; +type CaPropagation = 'explicit' | 'none'; +type ExpectedOutcome = 'success' | 'tls-rejection' | 'auth-rejection'; + +type ProbeResult = { + ok: boolean; + protocol: ProbeProtocol; + caPropagation: CaPropagation; + expectedOutcome: ExpectedOutcome; + exitCode?: number; + stdout?: string; + stderr?: string; + error?: string; +}; + +export { ContainerProxy }; + +function shellEscape(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +async function handleProbeOutbound(request: Request): Promise { + const source = new URL(request.url); + if (source.hostname !== SYNTHETIC_GIT_HOST && source.hostname !== SYNTHETIC_AUTH_RETAINED_HOST) { + return fetch(request); + } + if (request.method !== 'GET' && request.method !== 'HEAD') { + return new Response('unsupported method for ls-remote probe', { status: 405 }); + } + if (request.headers.get('Authorization') !== PLACEHOLDER_AUTHORIZATION) { + return new Response('expected placeholder authorization was not received', { status: 401 }); + } + if (!source.pathname.startsWith(SYNTHETIC_REPOSITORY_PATH)) { + return new Response('unexpected repository path', { status: 404 }); + } + + const target = new URL(PUBLIC_REPOSITORY_URL); + target.pathname = `${target.pathname}${source.pathname.slice(SYNTHETIC_REPOSITORY_PATH.length)}`; + target.search = source.search; + + const headers = new Headers(request.headers); + headers.delete('Host'); + if (source.hostname === SYNTHETIC_GIT_HOST) { + headers.delete('Authorization'); + } + + const response = await fetch(target, { method: request.method, headers, redirect: 'follow' }); + return new Response(response.body, response); +} + +export class OutboundGitRewriteDindProbeSandbox extends Sandbox { + enableInternet = true; + interceptHttps = true; +} + +OutboundGitRewriteDindProbeSandbox.outbound = handleProbeOutbound; + +function parseProbeId(url: URL): string | null { + const probeId = url.searchParams.get('probeId'); + return probeId && PROBE_ID.test(probeId) ? probeId : null; +} + +function getProbeSandbox(env: Env, probeId: string) { + return getSandbox(env.PROBE_SANDBOX, probeId, { normalizeId: true, sleepAfter: '1m' }); +} + +async function waitForDocker(session: ExecutionSession): Promise { + const deadline = Date.now() + 60_000; + let stderr = ''; + while (Date.now() < deadline) { + const result = await session.exec(DOCKER_READY_COMMAND); + if (result.success) return; + stderr = result.stderr.trim(); + await scheduler.wait(500); + } + throw new Error(`nested dockerd did not become ready: ${stderr}`); +} + +function expectedOutcome( + protocol: ProbeProtocol, + mode: ForwardingMode, + caPropagation: CaPropagation +): ExpectedOutcome { + if (protocol === 'https' && caPropagation === 'none') return 'tls-rejection'; + return mode === 'retain' ? 'auth-rejection' : 'success'; +} + +function nestedContainerName( + probeId: string, + protocol: ProbeProtocol, + outcome: ExpectedOutcome +): string { + return `${probeId}-${protocol}-${outcome}`; +} + +function nestedDockerCommand( + probeId: string, + protocol: ProbeProtocol, + mode: ForwardingMode, + caPropagation: CaPropagation +): string { + const host = mode === 'retain' ? SYNTHETIC_AUTH_RETAINED_HOST : SYNTHETIC_GIT_HOST; + const remote = `${protocol}://${host}${SYNTHETIC_REPOSITORY_PATH}`; + const outcome = expectedOutcome(protocol, mode, caPropagation); + const caMount = + caPropagation === 'explicit' + ? ` --volume ${shellEscape(`${OUTER_TRUSTED_CA_BUNDLE}:${NESTED_TRUSTED_CA_BUNDLE}:ro`)} --env GIT_SSL_CAINFO=${shellEscape(NESTED_TRUSTED_CA_BUNDLE)}` + : ''; + const gitCommand = `git -c protocol.version=0 -c http.extraHeader=\"Authorization: $PROBE_AUTHORIZATION\" ls-remote ${shellEscape(remote)} HEAD refs/heads/master refs/heads/main`; + return `socket="$(${DOCKER_SOCKET_COMMAND})"; if [ -z "$socket" ]; then printf 'Docker socket not found' >&2; false; else DOCKER_HOST="unix://$socket" docker run --pull=missing --rm --name ${shellEscape(nestedContainerName(probeId, protocol, outcome))} --label ${shellEscape(`kilo.outbound-git-rewrite-dind-probe=${probeId}`)} --network=host --env GIT_TERMINAL_PROMPT=0 --env PROBE_AUTHORIZATION=${shellEscape(PLACEHOLDER_AUTHORIZATION)}${caMount} --entrypoint sh ${shellEscape(NESTED_GIT_IMAGE)} -c ${shellEscape(gitCommand)}; fi`; +} + +async function runGitProbe( + protocol: ProbeProtocol, + mode: ForwardingMode, + caPropagation: CaPropagation, + env: Env, + probeId: string +): Promise { + const outcome = expectedOutcome(protocol, mode, caPropagation); + const session = await getProbeSandbox(env, probeId).createSession({ + name: `outbound-git-rewrite-dind-${protocol}-${outcome}`, + commandTimeoutMs: 180_000, + }); + await waitForDocker(session); + const result = await session.exec(nestedDockerCommand(probeId, protocol, mode, caPropagation), { + timeout: 180_000, + }); + const stdout = result.stdout.trim(); + const stderr = result.stderr.trim(); + const ok = + outcome === 'success' + ? result.success && REF_OUTPUT.test(stdout) + : !result.success && + (outcome === 'tls-rejection' ? TLS_REJECTION : AUTH_REJECTION).test(stderr); + const payload: ProbeResult = { + ok, + protocol, + caPropagation, + expectedOutcome: outcome, + exitCode: result.exitCode, + stdout, + stderr, + }; + return Response.json(payload, { status: ok ? 200 : 502 }); +} + +async function removeNestedContainers(env: Env, probeId: string): Promise { + const session = await getProbeSandbox(env, probeId).createSession({ + name: 'outbound-git-rewrite-dind-cleanup', + commandTimeoutMs: 30_000, + }); + await waitForDocker(session); + const label = shellEscape(`label=kilo.outbound-git-rewrite-dind-probe=${probeId}`); + const cleanupCommand = `socket="$(${DOCKER_SOCKET_COMMAND})"; if [ -z "$socket" ]; then printf 'Docker socket not found' >&2; false; else ids="$(DOCKER_HOST="unix://$socket" docker ps -aq --filter ${label})"; [ -z "$ids" ] || DOCKER_HOST="unix://$socket" docker rm -f $ids; remaining="$(DOCKER_HOST="unix://$socket" docker ps -aq --filter ${label})"; if [ -n "$remaining" ]; then printf 'Invocation-specific nested containers remain after cleanup: %s' "$remaining" >&2; false; fi; fi`; + const result = await session.exec(cleanupCommand, { timeout: 30_000 }); + if (!result.success) { + throw new Error( + `could not remove invocation-specific nested containers: ${result.stderr.trim()}` + ); + } +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + if (url.pathname === '/health') { + return Response.json({ ok: true }); + } + + const probeId = parseProbeId(url); + if (!probeId) { + return Response.json({ error: 'valid probeId is required' }, { status: 400 }); + } + + const sandboxDoId = env.PROBE_SANDBOX.idFromName(probeId).toString(); + if (url.pathname === '/sandbox-id') { + return Response.json({ sandboxDoId }); + } + + try { + if (url.pathname === '/cleanup') { + const sandbox = getProbeSandbox(env, probeId); + await removeNestedContainers(env, probeId); + await sandbox.destroy(); + return Response.json({ ok: true, sandboxDoId }); + } + if (url.pathname === '/probe') { + const protocol = url.searchParams.get('protocol'); + const mode = url.searchParams.get('mode') ?? 'strip'; + const caPropagation = url.searchParams.get('ca') ?? 'explicit'; + if (protocol !== 'http' && protocol !== 'https') { + return Response.json({ error: 'protocol must be https or http' }, { status: 400 }); + } + if (mode !== 'strip' && mode !== 'retain') { + return Response.json({ error: 'mode must be strip or retain' }, { status: 400 }); + } + if (caPropagation !== 'explicit' && caPropagation !== 'none') { + return Response.json({ error: 'ca must be explicit or none' }, { status: 400 }); + } + return await runGitProbe(protocol, mode, caPropagation, env, probeId); + } + return new Response('not found', { status: 404 }); + } catch (error) { + return Response.json( + { ok: false, error: error instanceof Error ? error.message : String(error) }, + { status: 500 } + ); + } + }, +} satisfies ExportedHandler; diff --git a/services/cloud-agent-next/test/e2e/outbound-git-rewrite-probe.ts b/services/cloud-agent-next/test/e2e/outbound-git-rewrite-probe.ts new file mode 100644 index 0000000000..6b4d0520c1 --- /dev/null +++ b/services/cloud-agent-next/test/e2e/outbound-git-rewrite-probe.ts @@ -0,0 +1,250 @@ +import { execFile, spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { createServer } from 'node:net'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { fileURLToPath } from 'node:url'; + +const SERVICE_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const CONFIG_PATH = path.join(SERVICE_DIR, 'wrangler.outbound-git-rewrite-probe.jsonc'); +const STARTUP_TIMEOUT_MS = 180_000; +const PROBE_TIMEOUT_MS = 180_000; +const execFileAsync = promisify(execFile); + +type Protocol = 'http' | 'https'; + +type ProbePayload = { + ok: boolean; + protocol?: Protocol; + expectedSuccess?: boolean; + exitCode?: number; + stdout?: string; + stderr?: string; + error?: string; +}; + +type ProbeObservation = { + label: string; + httpStatus?: number; + payload?: ProbePayload; + transportError?: string; +}; + +function reservePort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(); + reject(new Error('could not allocate a local port')); + return; + } + const { port } = address; + server.close(error => (error ? reject(error) : resolve(port))); + }); + }); +} + +function wait(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +async function waitForHealth( + baseUrl: string, + processOutput: () => string, + processExited: () => boolean +): Promise { + const deadline = Date.now() + STARTUP_TIMEOUT_MS; + while (Date.now() < deadline) { + if (processExited()) { + throw new Error(`wrangler probe worker exited before becoming healthy\n${processOutput()}`); + } + try { + const response = await fetch(`${baseUrl}/health`); + if (response.ok) return; + } catch { + // Wrangler may still be building the local Sandbox image. + } + await wait(500); + } + throw new Error(`wrangler probe worker did not become healthy\n${processOutput()}`); +} + +async function invokeProbe( + baseUrl: string, + label: string, + pathName: string +): Promise { + try { + const response = await fetch(`${baseUrl}${pathName}`, { + signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), + }); + const payload = (await response.json()) as ProbePayload; + return { label, httpStatus: response.status, payload }; + } catch (error) { + return { label, transportError: error instanceof Error ? error.message : String(error) }; + } +} + +function printObservation(observation: ProbeObservation): void { + if (observation.transportError) { + console.log(`${observation.label} FAIL transport=${observation.transportError}`); + return; + } + + const payload = observation.payload; + const expectation = payload?.expectedSuccess === false ? ' expected=git-failure' : ''; + console.log( + `${observation.label} ${payload?.ok ? 'PASS' : 'FAIL'} status=${observation.httpStatus ?? 'unknown'} exitCode=${payload?.exitCode ?? 'n/a'}${expectation}` + ); + if (payload?.stdout) console.log(`${observation.label} stdout:\n${payload.stdout}`); + if (payload?.stderr) console.log(`${observation.label} stderr:\n${payload.stderr}`); + if (payload?.error) console.log(`${observation.label} error=${payload.error}`); +} + +async function stopWorker(child: ChildProcessWithoutNullStreams): Promise { + if (child.exitCode !== null || child.signalCode !== null) return; + await new Promise(resolve => { + const timeout = setTimeout(() => { + child.kill('SIGKILL'); + resolve(); + }, 5_000); + child.once('exit', () => { + clearTimeout(timeout); + resolve(); + }); + child.kill('SIGTERM'); + }); +} + +async function cleanupSandbox(baseUrl: string, probeId: string): Promise { + try { + const response = await fetch(`${baseUrl}/cleanup?probeId=${encodeURIComponent(probeId)}`, { + method: 'POST', + signal: AbortSignal.timeout(30_000), + }); + if (!response.ok) { + console.warn(`probe sandbox cleanup returned HTTP ${response.status}`); + return undefined; + } + const body = (await response.json()) as { sandboxDoId?: string }; + return body.sandboxDoId; + } catch (error) { + console.warn( + `probe sandbox cleanup failed: ${error instanceof Error ? error.message : String(error)}` + ); + return undefined; + } +} + +async function invocationProxyExists(proxyName: string): Promise { + try { + await execFileAsync('docker', ['container', 'inspect', proxyName]); + return true; + } catch { + return false; + } +} + +async function removeInvocationProxyArtifact(sandboxDoId: string | undefined): Promise { + if (!sandboxDoId) return; + const proxyName = `workerd-cloud-agent-next-outbound-git-rewrite-probe-OutboundGitRewriteProbeSandbox-${sandboxDoId}-proxy`; + const deadline = Date.now() + 5_000; + while (Date.now() < deadline) { + if (await invocationProxyExists(proxyName)) { + try { + await execFileAsync('docker', ['rm', '-f', proxyName]); + } catch (error) { + console.warn( + `probe proxy cleanup failed: ${error instanceof Error ? error.message : String(error)}` + ); + return; + } + } + await wait(100); + } + if (await invocationProxyExists(proxyName)) { + console.warn(`probe proxy cleanup left artifact: ${proxyName}`); + return; + } + console.log(`CLEANUP proxy artifact absent: ${proxyName}`); +} + +async function main(): Promise { + const port = await reservePort(); + const probeId = `probe-${randomUUID()}`; + const output: string[] = []; + const wrangler = spawn( + 'pnpm', + [ + 'exec', + 'wrangler', + 'dev', + '--config', + CONFIG_PATH, + '--env-file', + '/dev/null', + '--local', + '--port', + String(port), + '--show-interactive-dev-session=false', + '--log-level=log', + ], + { cwd: SERVICE_DIR, env: process.env } + ); + const captureOutput = (chunk: Buffer): void => { + output.push(chunk.toString('utf8')); + }; + wrangler.stdout.on('data', captureOutput); + wrangler.stderr.on('data', captureOutput); + const baseUrl = `http://127.0.0.1:${port}`; + let workerReady = false; + + try { + await waitForHealth( + baseUrl, + () => output.join(''), + () => wrangler.exitCode !== null || wrangler.signalCode !== null + ); + workerReady = true; + const idQuery = `probeId=${encodeURIComponent(probeId)}`; + const gitHttp = await invokeProbe(baseUrl, 'GIT_HTTP', `/probe?${idQuery}&protocol=http`); + const gitHttps = await invokeProbe(baseUrl, 'GIT_HTTPS', `/probe?${idQuery}&protocol=https`); + const retainedAuth = await invokeProbe( + baseUrl, + 'GIT_HTTPS_RETAIN_AUTH_NEGATIVE', + `/probe?${idQuery}&protocol=https&mode=retain` + ); + printObservation(gitHttp); + printObservation(gitHttps); + printObservation(retainedAuth); + + if ( + gitHttp.payload?.ok === true && + gitHttps.payload?.ok === true && + retainedAuth.payload?.ok === true + ) { + console.log( + 'RESULT HTTP and HTTPS rewrite validated; retained-placeholder negative control validated.' + ); + return; + } + console.log('RESULT Required outbound Git rewrite assertions failed.'); + process.exitCode = 1; + } finally { + const sandboxDoId = workerReady ? await cleanupSandbox(baseUrl, probeId) : undefined; + await stopWorker(wrangler); + await removeInvocationProxyArtifact(sandboxDoId); + const wranglerOutput = output.join('').trim(); + if (process.exitCode && wranglerOutput) { + console.log(`WRANGLER output:\n${wranglerOutput}`); + } + } +} + +main().catch(error => { + console.error('probe runner failed:', error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +}); diff --git a/services/cloud-agent-next/test/e2e/outbound-git-rewrite-probe.worker-configuration.d.ts b/services/cloud-agent-next/test/e2e/outbound-git-rewrite-probe.worker-configuration.d.ts new file mode 100644 index 0000000000..2c0c02bb50 --- /dev/null +++ b/services/cloud-agent-next/test/e2e/outbound-git-rewrite-probe.worker-configuration.d.ts @@ -0,0 +1,14 @@ +/* eslint-disable */ +// Generated by Wrangler by running `wrangler types test/e2e/outbound-git-rewrite-probe.worker-configuration.d.ts --config wrangler.outbound-git-rewrite-probe.jsonc --env-file /dev/null --include-runtime=false` (hash: 40c21212a45d20b34254f7087834b3e0) +declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import('./outbound-git-rewrite-probe.worker'); + durableNamespaces: 'OutboundGitRewriteProbeSandbox'; + } + interface Env { + PROBE_SANDBOX: DurableObjectNamespace< + import('./outbound-git-rewrite-probe.worker').OutboundGitRewriteProbeSandbox + >; + } +} +interface Env extends Cloudflare.Env {} diff --git a/services/cloud-agent-next/test/e2e/outbound-git-rewrite-probe.worker.ts b/services/cloud-agent-next/test/e2e/outbound-git-rewrite-probe.worker.ts new file mode 100644 index 0000000000..1a195ca2df --- /dev/null +++ b/services/cloud-agent-next/test/e2e/outbound-git-rewrite-probe.worker.ts @@ -0,0 +1,149 @@ +/// + +import { ContainerProxy, getSandbox, Sandbox } from '@cloudflare/sandbox'; + +const SYNTHETIC_GIT_HOST = 'rewrite-git.invalid'; +const SYNTHETIC_AUTH_RETAINED_HOST = 'rewrite-git-retain-auth.invalid'; +const SYNTHETIC_REPOSITORY_PATH = '/octocat/Hello-World.git'; +const PUBLIC_REPOSITORY_URL = 'https://github.com/octocat/Hello-World.git'; +const PLACEHOLDER_AUTHORIZATION = 'Basic eC1hY2Nlc3MtdG9rZW46c2FuZGJveC1wbGFjZWhvbGRlcg=='; +const REF_OUTPUT = /^[0-9a-f]{40}\t(?:HEAD|refs\/heads\/(?:master|main))$/m; +const AUTH_REJECTION = + /Invalid username or token|Authentication failed|could not read Username.*terminal prompts disabled/; +const PROBE_ID = /^probe-[0-9a-f-]{36}$/; + +type ProbeProtocol = 'https' | 'http'; +type ForwardingMode = 'strip' | 'retain'; + +type ProbeResult = { + ok: boolean; + protocol: ProbeProtocol; + expectedSuccess: boolean; + exitCode?: number; + stdout?: string; + stderr?: string; + error?: string; +}; + +export { ContainerProxy }; + +function createGitHandler(mode: ForwardingMode) { + return async (request: Request): Promise => { + if (request.method !== 'GET' && request.method !== 'HEAD') { + return new Response('unsupported method for ls-remote probe', { status: 405 }); + } + if (request.headers.get('Authorization') !== PLACEHOLDER_AUTHORIZATION) { + return new Response('expected placeholder authorization was not received', { status: 401 }); + } + + const source = new URL(request.url); + if (!source.pathname.startsWith(SYNTHETIC_REPOSITORY_PATH)) { + return new Response('unexpected repository path', { status: 404 }); + } + + const target = new URL(PUBLIC_REPOSITORY_URL); + target.pathname = `${target.pathname}${source.pathname.slice(SYNTHETIC_REPOSITORY_PATH.length)}`; + target.search = source.search; + + const headers = new Headers(request.headers); + headers.delete('Host'); + if (mode === 'strip') { + headers.delete('Authorization'); + } + + const response = await fetch(target, { method: request.method, headers, redirect: 'follow' }); + return new Response(response.body, response); + }; +} + +export class OutboundGitRewriteProbeSandbox extends Sandbox { + enableInternet = true; + interceptHttps = true; +} + +OutboundGitRewriteProbeSandbox.outboundByHost = { + [SYNTHETIC_GIT_HOST]: createGitHandler('strip'), + [SYNTHETIC_AUTH_RETAINED_HOST]: createGitHandler('retain'), +}; + +function parseProbeId(url: URL): string | null { + const probeId = url.searchParams.get('probeId'); + return probeId && PROBE_ID.test(probeId) ? probeId : null; +} + +function getProbeSandbox(env: Env, probeId: string) { + return getSandbox(env.PROBE_SANDBOX, probeId, { normalizeId: true, sleepAfter: '1m' }); +} + +async function runGitProbe( + protocol: ProbeProtocol, + mode: ForwardingMode, + env: Env, + probeId: string +): Promise { + const expectedSuccess = mode === 'strip'; + const session = await getProbeSandbox(env, probeId).createSession({ + name: `outbound-git-rewrite-${protocol}-${mode}`, + env: { PROBE_AUTHORIZATION: PLACEHOLDER_AUTHORIZATION }, + commandTimeoutMs: 120_000, + }); + const host = mode === 'retain' ? SYNTHETIC_AUTH_RETAINED_HOST : SYNTHETIC_GIT_HOST; + const remote = `${protocol}://${host}${SYNTHETIC_REPOSITORY_PATH}`; + const command = `GIT_TERMINAL_PROMPT=0 git -c protocol.version=0 -c http.extraHeader=\"Authorization: $PROBE_AUTHORIZATION\" ls-remote '${remote}' HEAD refs/heads/master refs/heads/main`; + const result = await session.exec(command); + const stdout = result.stdout.trim(); + const stderr = result.stderr.trim(); + const succeedsWithRefOutput = result.success && REF_OUTPUT.test(stdout); + const ok = expectedSuccess + ? succeedsWithRefOutput + : !result.success && AUTH_REJECTION.test(stderr); + const payload: ProbeResult = { + ok, + protocol, + expectedSuccess, + exitCode: result.exitCode, + stdout, + stderr, + }; + return Response.json(payload, { status: ok ? 200 : 502 }); +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + if (url.pathname === '/health') { + return Response.json({ ok: true }); + } + + const probeId = parseProbeId(url); + if (!probeId) { + return Response.json({ error: 'valid probeId is required' }, { status: 400 }); + } + + try { + if (url.pathname === '/cleanup') { + const sandbox = getProbeSandbox(env, probeId); + const sandboxDoId = env.PROBE_SANDBOX.idFromName(probeId).toString(); + await sandbox.destroy(); + return Response.json({ ok: true, sandboxDoId }); + } + if (url.pathname === '/probe') { + const protocol = url.searchParams.get('protocol'); + const mode = url.searchParams.get('mode') ?? 'strip'; + if (protocol !== 'http' && protocol !== 'https') { + return Response.json({ error: 'protocol must be https or http' }, { status: 400 }); + } + if (mode !== 'strip' && mode !== 'retain') { + return Response.json({ error: 'mode must be strip or retain' }, { status: 400 }); + } + return await runGitProbe(protocol, mode, env, probeId); + } + return new Response('not found', { status: 404 }); + } catch (error) { + return Response.json( + { ok: false, error: error instanceof Error ? error.message : String(error) }, + { status: 500 } + ); + } + }, +} satisfies { fetch(request: Request, env: Env): Promise }; diff --git a/services/cloud-agent-next/worker-configuration.d.ts b/services/cloud-agent-next/worker-configuration.d.ts index 16777b1b29..a9f238366b 100644 --- a/services/cloud-agent-next/worker-configuration.d.ts +++ b/services/cloud-agent-next/worker-configuration.d.ts @@ -1,6 +1,6 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: 0264ea9ef0ff7fd80bb9ebde4d3d6652) -// Runtime types generated with workerd@1.20260508.1 2025-09-15 nodejs_compat +// Generated by Wrangler by running `wrangler types` (hash: 3edbebf6e51b375c8ce0f36c95ec1139) +// Runtime types generated with workerd@1.20260508.1 2026-05-15 nodejs_compat declare namespace Cloudflare { interface GlobalProps { mainModule: typeof import("./src/index"); @@ -37,7 +37,6 @@ declare namespace Cloudflare { GITHUB_APP_BOT_USER_ID: string; GITHUB_LITE_APP_ID: string; GITHUB_LITE_APP_PRIVATE_KEY: string; - KILOCODE_SANDBOX_BACKEND_BASE_URL: string; Sandbox: DurableObjectNamespace; SandboxSmall: DurableObjectNamespace; SandboxDIND: DurableObjectNamespace; @@ -77,7 +76,6 @@ declare namespace Cloudflare { GITHUB_APP_BOT_USER_ID: string; GITHUB_LITE_APP_ID: string; GITHUB_LITE_APP_PRIVATE_KEY: string; - KILOCODE_SANDBOX_BACKEND_BASE_URL: string; Sandbox: DurableObjectNamespace; SandboxSmall: DurableObjectNamespace; SandboxDIND: DurableObjectNamespace; @@ -92,7 +90,7 @@ type StringifyValues> = { [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; }; declare namespace NodeJS { - interface ProcessEnv extends StringifyValues> {} + interface ProcessEnv extends StringifyValues> {} } declare module "*.sql" { const value: string; @@ -518,6 +516,7 @@ interface TestController { interface ExecutionContext { waitUntil(promise: Promise): void; passThroughOnException(): void; + readonly exports: Cloudflare.Exports; readonly props: Props; cache?: CacheContext; tracing?: Tracing; @@ -619,6 +618,7 @@ interface DurableObjectClass<_T extends Rpc.DurableObjectBranded | undefined = u } interface DurableObjectState { waitUntil(promise: Promise): void; + readonly exports: Cloudflare.Exports; readonly props: Props; readonly id: DurableObjectId; readonly storage: DurableObjectStorage; @@ -1735,7 +1735,7 @@ declare class Headers { value: string ]>; } -type BodyInit = ReadableStream | string | ArrayBuffer | ArrayBufferView | Blob | URLSearchParams | FormData; +type BodyInit = ReadableStream | string | ArrayBuffer | ArrayBufferView | Blob | URLSearchParams | FormData | Iterable | AsyncIterable; declare abstract class Body { /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/body) */ get body(): ReadableStream | null; diff --git a/services/cloud-agent-next/wrangler.jsonc b/services/cloud-agent-next/wrangler.jsonc index 623d73fed9..215a581597 100644 --- a/services/cloud-agent-next/wrangler.jsonc +++ b/services/cloud-agent-next/wrangler.jsonc @@ -7,7 +7,7 @@ "name": "cloud-agent-next", "account_id": "e115e769bcdd4c3d66af59d3332cb394", "main": "src/index.ts", - "compatibility_date": "2025-09-15", + "compatibility_date": "2026-05-15", "compatibility_flags": ["nodejs_compat"], "preview_urls": false, "rules": [{ "type": "Text", "globs": ["**/*.sql"], "fallthrough": true }], diff --git a/services/cloud-agent-next/wrangler.outbound-git-rewrite-dind-probe.jsonc b/services/cloud-agent-next/wrangler.outbound-git-rewrite-dind-probe.jsonc new file mode 100644 index 0000000000..bc5793e568 --- /dev/null +++ b/services/cloud-agent-next/wrangler.outbound-git-rewrite-dind-probe.jsonc @@ -0,0 +1,39 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "cloud-agent-next-outbound-git-rewrite-dind-probe", + "main": "test/e2e/outbound-git-rewrite-dind-probe.worker.ts", + "compatibility_date": "2026-05-15", + "compatibility_flags": ["nodejs_compat"], + "workers_dev": false, + "preview_urls": false, + "dev": { + "local_protocol": "http", + "ip": "127.0.0.1", + }, + "containers": [ + { + "class_name": "OutboundGitRewriteDindProbeSandbox", + "image": "./Dockerfile.dind", + "instance_type": "standard-3", + "image_vars": { + "KILOCODE_CLI_VERSION": "7.2.52", + }, + "max_instances": 1, + "rollout_active_grace_period": 60, + }, + ], + "durable_objects": { + "bindings": [ + { + "class_name": "OutboundGitRewriteDindProbeSandbox", + "name": "PROBE_SANDBOX", + }, + ], + }, + "migrations": [ + { + "new_sqlite_classes": ["OutboundGitRewriteDindProbeSandbox"], + "tag": "v1", + }, + ], +} diff --git a/services/cloud-agent-next/wrangler.outbound-git-rewrite-probe.jsonc b/services/cloud-agent-next/wrangler.outbound-git-rewrite-probe.jsonc new file mode 100644 index 0000000000..33cd6c3d9a --- /dev/null +++ b/services/cloud-agent-next/wrangler.outbound-git-rewrite-probe.jsonc @@ -0,0 +1,39 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "cloud-agent-next-outbound-git-rewrite-probe", + "main": "test/e2e/outbound-git-rewrite-probe.worker.ts", + "compatibility_date": "2026-05-15", + "compatibility_flags": ["nodejs_compat"], + "workers_dev": false, + "preview_urls": false, + "dev": { + "local_protocol": "http", + "ip": "127.0.0.1", + }, + "containers": [ + { + "class_name": "OutboundGitRewriteProbeSandbox", + "image": "./Dockerfile.dev", + "instance_type": "standard-2", + "image_vars": { + "KILOCODE_CLI_VERSION": "7.2.52", + }, + "max_instances": 1, + "rollout_active_grace_period": 60, + }, + ], + "durable_objects": { + "bindings": [ + { + "class_name": "OutboundGitRewriteProbeSandbox", + "name": "PROBE_SANDBOX", + }, + ], + }, + "migrations": [ + { + "new_sqlite_classes": ["OutboundGitRewriteProbeSandbox"], + "tag": "v1", + }, + ], +} diff --git a/services/cloud-agent-next/wrangler.test.jsonc b/services/cloud-agent-next/wrangler.test.jsonc index c5e03db06b..5c43cc02e5 100644 --- a/services/cloud-agent-next/wrangler.test.jsonc +++ b/services/cloud-agent-next/wrangler.test.jsonc @@ -2,7 +2,7 @@ "$schema": "node_modules/wrangler/config-schema.json", "name": "cloud-agent-test", "main": "test/test-worker.ts", - "compatibility_date": "2025-09-15", + "compatibility_date": "2026-05-15", "compatibility_flags": ["nodejs_compat"], "rules": [{ "type": "Text", "globs": ["**/*.sql"], "fallthrough": true }], "durable_objects": { diff --git a/services/git-token-service/.dev.vars.example b/services/git-token-service/.dev.vars.example index 3da8489b64..c1c0938735 100644 --- a/services/git-token-service/.dev.vars.example +++ b/services/git-token-service/.dev.vars.example @@ -23,8 +23,6 @@ USER_GITHUB_APP_TOKEN_ACTIVE_PRIVATE_KEY= # @from NEXTAUTH_SECRET # Short-lived user-token verification for POST /internal/github-user-authorizations/disconnect NEXTAUTH_SECRET= -# Dedicated 32-byte base64 AES-GCM key for short-lived SCM session capabilities -SCM_SESSION_CAPABILITY_ENCRYPTION_KEY= # GitHub Lite App credentials (for OSS organizations with read-only permissions) # Same format as standard app credentials above diff --git a/services/git-token-service/src/index.test.ts b/services/git-token-service/src/index.test.ts index 172ded024d..d96c1330e5 100644 --- a/services/git-token-service/src/index.test.ts +++ b/services/git-token-service/src/index.test.ts @@ -739,6 +739,32 @@ describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { expect(serviceMocks.getTokenForRepo).toHaveBeenCalledOnce(); }); + it.each([ + ['POST', 'https://api.github.com/graphql'], + ['GET', 'https://github.com/acme/other.git/info/refs?service=git-upload-pack'], + ] as const)( + 'keeps legacy unbound GitHub capabilities repository-confined for %s %s', + async (requestMethod, requestUrl) => { + const service = createService(); + const issued = await service.issueGitHubSessionCapability({ + githubRepo: 'acme/repo', + userId: 'user_1', + }); + if (!issued.success) throw new Error('Expected successful issuance'); + expect(issued.capability).toMatch(/^kgh1\./); + serviceMocks.getTokenForRepo.mockClear(); + + await expect( + service.redeemGitHubSessionCapability({ + capability: issued.capability, + requestMethod, + requestUrl, + }) + ).resolves.toEqual({ success: false, reason: 'repository_mismatch' }); + expect(serviceMocks.getTokenForRepo).not.toHaveBeenCalled(); + } + ); + it('rejects tampered capabilities before resolving any upstream authorization', async () => { const service = createService(); const issued = await service.issueGitHubSessionCapability({ @@ -850,17 +876,15 @@ describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { }); it.each([ - ['GET', 'https://github.com/acme/repo.git/info/lfs/objects/batch', 'invalid_upstream_request'], - [ - 'POST', - 'https://github.com/acme/repo.git/info/lfs/objects/batch?operation=upload', - 'invalid_upstream_request', - ], - ['POST', 'https://github.com/acme/other.git/info/lfs/objects/batch', 'repository_mismatch'], - ['POST', 'https://github.com/acme/repo.git/info/lfs/locks', 'invalid_upstream_request'], + ['POST', 'https://api.github.com/graphql'], + ['GET', 'https://api.github.com/user/repos'], + ['DELETE', 'https://api.github.com/repos/acme/other/issues/42/comments/1'], + ['GET', 'https://api.github.com/repos/acme/repo/contents/src%2Findex.ts'], + ['POST', 'https://uploads.github.com/repos/acme/other/releases/1/assets?name=asset.zip'], + ['POST', 'https://github.com/acme/other.git/info/lfs/objects/batch'], ] as const)( - 'rejects unsupported LFS control request %s %s', - async (requestMethod, requestUrl, reason) => { + 'redeems an installation-pinned capability for unrestricted GitHub request %s %s', + async (requestMethod, requestUrl) => { const service = createService(); const issued = await service.issueGitHubSessionCapability({ githubRepo: 'acme/repo', @@ -877,8 +901,13 @@ describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { requestMethod, requestUrl, }) - ).resolves.toEqual({ success: false, reason }); - expect(serviceMocks.getTokenForRepo).not.toHaveBeenCalled(); + ).resolves.toEqual({ + success: true, + authorization: requestUrl.startsWith('https://github.com/') + ? `Basic ${Buffer.from('x-access-token:installation-token').toString('base64')}` + : 'Bearer installation-token', + }); + expect(serviceMocks.getTokenForRepo).toHaveBeenCalledOnce(); } ); @@ -939,39 +968,14 @@ describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { }); it.each([ - 'https://api.github.com/user/repos', - 'https://api.github.com/repos/acme/other/issues/42/comments', - 'https://api.github.com/graphql', - ])( - 'does not redeem a GitHub capability for an API request outside its repository: %s', - async requestUrl => { - const service = createService(); - const issued = await service.issueGitHubSessionCapability({ - githubRepo: 'acme/repo', - userId: 'user_1', - outboundContainerId, - allowUserAuthorization: true, - }); - if (!issued.success) throw new Error('Expected successful issuance'); - serviceMocks.selectUserAuthorization.mockClear(); - - await expect( - service.redeemGitHubSessionCapability({ - capability: issued.capability, - outboundContainerId, - requestMethod: 'POST', - requestUrl, - }) - ).resolves.toEqual({ success: false, reason: 'repository_mismatch' }); - expect(serviceMocks.selectUserAuthorization).not.toHaveBeenCalled(); - } - ); - - it.each([ + ['POST', 'https://api.github.com/graphql'], + ['GET', 'https://api.github.com/user/repos'], + ['DELETE', 'https://api.github.com/repos/acme/other/issues/42/comments/1'], + ['GET', 'https://api.github.com/repos/acme/repo/contents/src%2Findex.ts'], + ['POST', 'https://uploads.github.com/repos/acme/other/releases/1/assets?name=asset.zip'], ['GET', 'https://github.com/acme/other.git/info/refs?service=git-upload-pack'], - ['POST', 'https://github.com/acme/other.git/git-receive-pack'], ] as const)( - 'does not redeem a selected-user capability for another Git repository via %s %s', + 'redeems a selected-user capability for unrestricted GitHub request %s %s', async (requestMethod, requestUrl) => { const service = createService(); const issued = await service.issueGitHubSessionCapability({ @@ -991,8 +995,13 @@ describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { requestMethod, requestUrl, }) - ).resolves.toEqual({ success: false, reason: 'repository_mismatch' }); - expect(serviceMocks.selectUserAuthorization).not.toHaveBeenCalled(); + ).resolves.toEqual({ + success: true, + authorization: requestUrl.startsWith('https://github.com/') + ? `Basic ${Buffer.from('x-access-token:user-token').toString('base64')}` + : 'Bearer user-token', + }); + expect(serviceMocks.selectUserAuthorization).toHaveBeenCalledOnce(); } ); @@ -1017,28 +1026,8 @@ describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { 'https://gitlab.com/acme/repo.git/info/refs?service=git-upload-pack', 'upstream_host_not_allowed', ], - [ - 'GET', - 'https://github.com/acme/other.git/info/refs?service=git-upload-pack', - 'repository_mismatch', - ], - ['GET', 'https://github.com/acme/repo/settings', 'invalid_upstream_request'], - ['GET', 'https://github.com/acme/repo.git/info/refs', 'invalid_upstream_request'], - [ - 'POST', - 'https://github.com/acme/repo.git/info/refs?service=git-upload-pack', - 'invalid_upstream_request', - ], - ['GET', 'https://github.com/acme/repo.git/git-receive-pack', 'invalid_upstream_request'], - ['CONNECT', 'https://api.github.com/user/repos', 'invalid_upstream_request'], - ['PATCH', 'https://api.github.com/repos/acme/repo/pulls/42', 'invalid_upstream_request'], - ['PUT', 'https://api.github.com/repos/acme/repo/pulls/42/merge', 'invalid_upstream_request'], - ['GET', 'https://api.github.com/repos/acme/repo/actions/variables', 'invalid_upstream_request'], - [ - 'POST', - 'https://api.github.com/repos/acme/repo/issues/42%2F..%2F43/comments', - 'invalid_upstream_url', - ], + ['GET', 'https://api.github.com:8443/user/repos', 'upstream_host_not_allowed'], + ['GET', 'https://api.github.com/user/repos#fragment', 'invalid_upstream_url'], ] as const)( 'rejects unsafe upstream request %s %s without forwarding authorization', async (requestMethod, requestUrl, reason) => { @@ -1063,6 +1052,29 @@ describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { } ); + it('rejects unsafe selected-user destinations before resolving authorization', async () => { + const service = createService(); + const issued = await service.issueGitHubSessionCapability({ + githubRepo: 'acme/repo', + userId: 'user_1', + outboundContainerId, + allowUserAuthorization: true, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + expect(issued.source).toBe('user'); + serviceMocks.selectUserAuthorization.mockClear(); + + await expect( + service.redeemGitHubSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod: 'POST', + requestUrl: 'https://github.com.evil.example/graphql', + }) + ).resolves.toEqual({ success: false, reason: 'upstream_host_not_allowed' }); + expect(serviceMocks.selectUserAuthorization).not.toHaveBeenCalled(); + }); + it('rejects user-source redemption rather than falling back to installation authorization', async () => { const service = createService(); const issued = await service.issueGitHubSessionCapability({ @@ -1233,6 +1245,28 @@ describe('GitTokenRPCEntrypoint GitLab session capability RPCs', () => { } ); + it('reports a missing GitLab integration identity distinctly from a missing token', async () => { + serviceMocks.findGitLabIntegration.mockResolvedValue({ + success: true, + integrationId: 'ef2eb5c7-27ce-4f43-b6d3-8f282abc145c', + integrationType: 'oauth', + accountId: null, + accountLogin: null, + metadata: { + access_token: 'gitlab-oauth-token', + auth_type: 'oauth', + }, + }); + + await expect( + createService().issueGitLabSessionCapability({ + gitUrl: 'https://gitlab.com/acme/widgets.git', + userId: 'user_1', + outboundContainerId, + }) + ).resolves.toEqual({ success: false, reason: 'integration_identity_missing' }); + }); + it('does not redeem a capability from another outbound container or resolve its source', async () => { const service = createService(); const issued = await service.issueGitLabSessionCapability({ @@ -1307,6 +1341,51 @@ describe('GitTokenRPCEntrypoint GitLab session capability RPCs', () => { expect(serviceMocks.getGitLabToken).toHaveBeenCalledTimes(2); }); + it.each([ + ['POST', 'https://gitlab.com/api/graphql', 'invalid_upstream_request'], + ['GET', 'https://gitlab.com/api/v4/projects?membership=true', 'invalid_upstream_request'], + ['GET', 'https://gitlab.com/api/v4/projects/acme%2Fother/issues', 'repository_mismatch'], + ['CONNECT', 'https://gitlab.com/api/v4/projects', 'invalid_upstream_request'], + [ + 'GET', + 'https://gitlab.com/api/v4/projects/acme%2Fwidgets/variables', + 'invalid_upstream_request', + ], + [ + 'PUT', + 'https://gitlab.com/api/v4/projects/acme%2Fwidgets/merge_requests/42/merge', + 'invalid_upstream_request', + ], + [ + 'GET', + 'https://gitlab.com/other/project.git/info/refs?service=git-upload-pack', + 'repository_mismatch', + ], + ] as const)( + 'keeps legacy unbound GitLab capabilities project-confined for %s %s', + async (requestMethod, requestUrl, reason) => { + const service = createService(); + const issued = await service.issueGitLabSessionCapability({ + gitUrl: 'https://gitlab.com/acme/widgets.git', + userId: 'user_1', + }); + if (!issued.success) throw new Error('Expected successful issuance'); + expect(issued.capability).toMatch(/^kgl1\./); + serviceMocks.findGitLabIntegration.mockClear(); + serviceMocks.getGitLabToken.mockClear(); + + await expect( + service.redeemGitLabSessionCapability({ + capability: issued.capability, + requestMethod, + requestUrl, + }) + ).resolves.toEqual({ success: false, reason }); + expect(serviceMocks.findGitLabIntegration).not.toHaveBeenCalled(); + expect(serviceMocks.getGitLabToken).not.toHaveBeenCalled(); + } + ); + it('issues an opaque project-source capability for a code-review repository without exposing its token', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue(Response.json({ id: 42 }))); serviceMocks.findAuthorizedGitLabIntegrations.mockResolvedValueOnce({ @@ -1363,13 +1442,34 @@ describe('GitTokenRPCEntrypoint GitLab session capability RPCs', () => { 'https://gitlab.com/api/v4/projects/42/merge_requests/42/changes', { 'PRIVATE-TOKEN': 'project-access-token' }, ], + ['POST', 'https://gitlab.com/api/graphql', { 'PRIVATE-TOKEN': 'project-access-token' }], + [ + 'DELETE', + 'https://gitlab.com/api/v4/projects/99/merge_requests/7', + { 'PRIVATE-TOKEN': 'project-access-token' }, + ], [ 'GET', 'https://gitlab.com/acme/widgets.git/info/refs?service=git-upload-pack', { authorization: `Basic ${Buffer.from('oauth2:project-access-token').toString('base64')}` }, ], + [ + 'GET', + 'https://gitlab.com/other/project.git/info/refs?service=git-upload-pack', + { authorization: `Basic ${Buffer.from('oauth2:project-access-token').toString('base64')}` }, + ], + [ + 'GET', + 'https://gitlab.com/api/project.git/info/refs?service=git-upload-pack', + { authorization: `Basic ${Buffer.from('oauth2:project-access-token').toString('base64')}` }, + ], + [ + 'POST', + 'https://gitlab.com/api/v4/project.git/info/lfs/locks/123/unlock', + { authorization: `Basic ${Buffer.from('oauth2:project-access-token').toString('base64')}` }, + ], ] as const)( - 'redeems a project-source capability server-side for %s %s', + 'redeems an unrestricted bound project-source capability server-side for %s %s', async (requestMethod, requestUrl, headers) => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue(Response.json({ id: 42 }))); const projectIntegration = { @@ -1520,6 +1620,18 @@ describe('GitTokenRPCEntrypoint GitLab session capability RPCs', () => { 'https://gitlab.example.com:8443/gitlab/api/v4/projects/acme%2Fplatform%2Fwidgets/merge_requests/42/changes', { authorization: 'Bearer refreshed-self-managed-token' }, ], + [ + 'POST', + 'https://gitlab.example.com:8443/gitlab/api/graphql', + { authorization: 'Bearer refreshed-self-managed-token' }, + ], + [ + 'GET', + 'https://gitlab.example.com:8443/gitlab/other/project.git/info/refs?service=git-upload-pack', + { + authorization: `Basic ${Buffer.from('oauth2:refreshed-self-managed-token').toString('base64')}`, + }, + ], ] as const)( 'issues and redeems a nested self-managed GitLab capability for %s %s', async (requestMethod, requestUrl, headers) => { @@ -1565,11 +1677,13 @@ describe('GitTokenRPCEntrypoint GitLab session capability RPCs', () => { ); it.each([ - ['https://gitlab.example.com:8443/api/v4/projects/42/issues', 'invalid_upstream_request'], + ['https://gitlab.example.com:8443/api/v4/projects/42/issues', 'upstream_origin_not_allowed'], [ 'https://gitlab.example.com:8443/acme/platform/widgets.git/info/refs?service=git-upload-pack', - 'repository_mismatch', + 'upstream_origin_not_allowed', ], + ['https://gitlab.example.com:8443/gitlab/%2e%2e%2fapi/v4/user', 'invalid_upstream_url'], + ['https://gitlab.example.com:8443/gitlab/%252e%252e%252fapi/v4/user', 'invalid_upstream_url'], ] as const)('rejects self-managed base-path escape %s', async (requestUrl, reason) => { serviceMocks.findGitLabIntegration.mockResolvedValue({ success: true, @@ -1608,16 +1722,9 @@ describe('GitTokenRPCEntrypoint GitLab session capability RPCs', () => { expect(serviceMocks.findGitLabIntegration).not.toHaveBeenCalled(); }); - it.each([ - [ - 'https://sibling.example.com/acme/platform/widgets.git/info/refs?service=git-upload-pack', - 'upstream_origin_not_allowed', - ], - [ - 'https://gitlab.example.com/acme/platform/sibling.git/info/refs?service=git-upload-pack', - 'repository_mismatch', - ], - ] as const)('rejects self-managed sibling scope %s', async (requestUrl, reason) => { + it('rejects a self-managed sibling origin', async () => { + const requestUrl = + 'https://sibling.example.com/acme/platform/widgets.git/info/refs?service=git-upload-pack'; serviceMocks.findGitLabIntegration.mockResolvedValue({ success: true, integrationId: 'ef2eb5c7-27ce-4f43-b6d3-8f282abc145c', @@ -1651,7 +1758,7 @@ describe('GitTokenRPCEntrypoint GitLab session capability RPCs', () => { requestMethod: 'GET', requestUrl, }) - ).resolves.toEqual({ success: false, reason }); + ).resolves.toEqual({ success: false, reason: 'upstream_origin_not_allowed' }); expect(serviceMocks.findGitLabIntegration).not.toHaveBeenCalled(); }); @@ -1754,12 +1861,12 @@ describe('GitTokenRPCEntrypoint GitLab session capability RPCs', () => { }); it.each([ - ['GET', 'https://gitlab.com/api/v4/projects?membership=true', 'invalid_upstream_request'], - ['POST', 'https://gitlab.com/api/graphql', 'invalid_upstream_request'], - ['GET', 'https://gitlab.com/api/v4/projects/acme%2Fother/issues', 'repository_mismatch'], + ['GET', 'https://gitlab.com/api/v4/projects?membership=true'], + ['POST', 'https://gitlab.com/api/graphql'], + ['DELETE', 'https://gitlab.com/api/v4/projects/acme%2Fother/issues/1'], ] as const)( - 'does not redeem a GitLab capability for an API request outside its project: %s %s', - async (requestMethod, requestUrl, reason) => { + 'redeems an unrestricted bound GitLab integration capability for API request %s %s', + async (requestMethod, requestUrl) => { const service = createService(); const issued = await service.issueGitLabSessionCapability({ gitUrl: 'https://gitlab.com/acme/widgets.git', @@ -1768,6 +1875,11 @@ describe('GitTokenRPCEntrypoint GitLab session capability RPCs', () => { }); if (!issued.success) throw new Error('Expected successful issuance'); serviceMocks.findGitLabIntegration.mockClear(); + serviceMocks.getGitLabToken.mockResolvedValueOnce({ + success: true, + token: 'refreshed-gitlab-token', + instanceUrl: 'https://gitlab.com', + }); await expect( service.redeemGitLabSessionCapability({ @@ -1776,8 +1888,11 @@ describe('GitTokenRPCEntrypoint GitLab session capability RPCs', () => { requestMethod, requestUrl, }) - ).resolves.toEqual({ success: false, reason }); - expect(serviceMocks.findGitLabIntegration).not.toHaveBeenCalled(); + ).resolves.toEqual({ + success: true, + headers: { authorization: 'Bearer refreshed-gitlab-token' }, + }); + expect(serviceMocks.findGitLabIntegration).toHaveBeenCalledOnce(); } ); @@ -1787,30 +1902,16 @@ describe('GitTokenRPCEntrypoint GitLab session capability RPCs', () => { 'https://other.example.com/acme/widgets.git/info/refs?service=git-upload-pack', 'upstream_origin_not_allowed', ], + ['GET', 'https://gitlab.com:8443/api/v4/projects', 'upstream_origin_not_allowed'], + ['GET', 'http://gitlab.com/api/v4/projects', 'invalid_upstream_url'], + ['GET', 'https://attacker@gitlab.com/api/v4/projects', 'invalid_upstream_url'], + ['GET', 'https://gitlab.com/api/v4/projects#fragment', 'invalid_upstream_url'], + ['GET', 'https://gitlab.com/../api/v4/projects', 'invalid_upstream_url'], [ 'GET', - 'https://gitlab.com/acme/other.git/info/refs?service=git-upload-pack', - 'repository_mismatch', - ], - ['GET', 'https://gitlab.com/acme/widgets/settings', 'invalid_upstream_request'], - ['GET', 'https://gitlab.com/oauth/authorize', 'invalid_upstream_request'], - ['GET', 'https://gitlab.com/users/sign_in', 'invalid_upstream_request'], - [ - 'GET', - 'https://gitlab.com/acme%2Fwidgets.git/info/refs?service=git-upload-pack', + 'https://gitlab.com/acme%5Cwidgets.git/info/refs?service=git-upload-pack', 'invalid_upstream_url', ], - ['CONNECT', 'https://gitlab.com/api/v4/projects', 'invalid_upstream_request'], - [ - 'GET', - 'https://gitlab.com/api/v4/projects/acme%2Fwidgets/variables', - 'invalid_upstream_request', - ], - [ - 'PUT', - 'https://gitlab.com/api/v4/projects/acme%2Fwidgets/merge_requests/42/merge', - 'invalid_upstream_request', - ], ] as const)('rejects unsafe GitLab request %s %s', async (requestMethod, requestUrl, reason) => { const service = createService(); const issued = await service.issueGitLabSessionCapability({ diff --git a/services/git-token-service/src/index.ts b/services/git-token-service/src/index.ts index 84f0f00ab8..1e55773bb1 100644 --- a/services/git-token-service/src/index.ts +++ b/services/git-token-service/src/index.ts @@ -150,7 +150,13 @@ export type IssueGitLabSessionCapabilitySuccess = { export type IssueGitLabSessionCapabilityResult = | IssueGitLabSessionCapabilitySuccess | GetGitLabTokenFailure - | { success: false; reason: GitLabCloneUrlFailureReason | 'capability_configuration_error' }; + | { + success: false; + reason: + | GitLabCloneUrlFailureReason + | 'integration_identity_missing' + | 'capability_configuration_error'; + }; export type RedeemGitLabSessionCapabilityParams = { capability: string; outboundContainerId?: string; @@ -186,6 +192,24 @@ async function resolveSecret(secret: SecretsStoreSecret | string): Promise { return { success: false, reason: 'container_mismatch' }; } - const upstreamFailure = validateGitHubCapabilityUpstream( - params.requestMethod, - params.requestUrl, - claims - ); + const upstreamFailure = + claims.version === 2 + ? validateGitHubCapabilityUpstream(params.requestUrl) + : validateLegacyGitHubCapabilityUpstream(params.requestMethod, params.requestUrl, claims); if (upstreamFailure) return { success: false, reason: upstreamFailure }; const authParams = { @@ -738,7 +829,7 @@ export class GitTokenRPCEntrypoint extends WorkerEntrypoint { const repository = parseGitLabCloneUrl(params.gitUrl, instanceOrigin); if (!repository.success) return repository; const identity = this.getGitLabSessionIdentity(integration); - if (!identity) return { success: false, reason: 'no_token' }; + if (!identity) return { success: false, reason: 'integration_identity_missing' }; let capability: string; try { @@ -791,11 +882,10 @@ export class GitTokenRPCEntrypoint extends WorkerEntrypoint { return { success: false, reason: 'container_mismatch' }; } - const upstream = validateGitLabCapabilityUpstream( - params.requestMethod, - params.requestUrl, - claims - ); + const upstream = + claims.version === 2 + ? validateGitLabCapabilityUpstream(params.requestUrl, claims.instanceOrigin) + : validateLegacyGitLabCapabilityUpstream(params.requestMethod, params.requestUrl, claims); if (upstream.failure) return { success: false, reason: upstream.failure }; const context = { userId: claims.userId, diff --git a/services/git-token-service/wrangler.jsonc b/services/git-token-service/wrangler.jsonc index 2a4cef6e44..eca349f094 100644 --- a/services/git-token-service/wrangler.jsonc +++ b/services/git-token-service/wrangler.jsonc @@ -77,6 +77,7 @@ { "binding": "SCM_SESSION_CAPABILITY_ENCRYPTION_KEY", "store_id": "342a86d9e3a94da698e82d0c6e2a36f0", + // @dev-generate base64 32 "secret_name": "SCM_SESSION_CAPABILITY_ENCRYPTION_KEY_DEV", }, ],