diff --git a/services/cloud-agent-next/src/index.ts b/services/cloud-agent-next/src/index.ts index d15d592963..3cfaa8aa46 100644 --- a/services/cloud-agent-next/src/index.ts +++ b/services/cloud-agent-next/src/index.ts @@ -1,3 +1,3 @@ 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'; diff --git a/services/cloud-agent-next/src/kilo/devcontainer.test.ts b/services/cloud-agent-next/src/kilo/devcontainer.test.ts index f20bab63af..56c549da78 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, @@ -197,6 +199,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 +333,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 +350,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 +380,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 +433,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 +446,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 +474,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 +525,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 +704,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..3d73ba7a90 --- /dev/null +++ b/services/cloud-agent-next/src/sandbox-outbound.test.ts @@ -0,0 +1,667 @@ +import { Buffer } from 'node:buffer'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const sdk = vi.hoisted(() => { + class StockSandbox {} + class ContainerProxy {} + return { StockSandbox, ContainerProxy }; +}); + +vi.mock('@cloudflare/sandbox', () => ({ + Sandbox: sdk.StockSandbox, + ContainerProxy: sdk.ContainerProxy, +})); + +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); +} + +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.unstubAllGlobals(); + }); + + 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('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('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..e23d2529a5 --- /dev/null +++ b/services/cloud-agent-next/src/sandbox-outbound.ts @@ -0,0 +1,230 @@ +import { Buffer } from 'node:buffer'; +import { ContainerProxy, Sandbox as StockSandbox } from '@cloudflare/sandbox'; +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; + +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 identifyCapability(capability: string): RedeemableAuthorization | null { + if (GITHUB_CAPABILITY_PREFIXES.some(prefix => capability.startsWith(prefix))) { + return { provider: 'github', capability }; + } + if (GITLAB_CAPABILITY_PREFIXES.some(prefix => capability.startsWith(prefix))) { + return { provider: 'gitlab', capability }; + } + return null; +} + +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 capability = identifyCapability(credential.slice(separator + 1)); + if (!capability) return NO_AUTHORIZATION_CAPABILITY; + if (username === 'x-access-token' && capability.provider === 'github') { + return { type: 'capability', value: capability }; + } + if (username === 'oauth2' && capability.provider === 'gitlab') { + return { type: 'capability', value: capability }; + } + 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) return NO_AUTHORIZATION_CAPABILITY; + const capability = match[2] ? identifyCapability(match[2]) : null; + if (!capability) return NO_AUTHORIZATION_CAPABILITY; + if (capability.provider === 'gitlab' && match[1]?.toLowerCase() !== 'bearer') { + return { type: 'unsupported_capability' }; + } + return { type: 'capability', value: capability }; +} + +function extractGitLabPrivateTokenCapability(privateToken: string | null): AuthorizationExtraction { + if (!privateToken) return NO_AUTHORIZATION_CAPABILITY; + const capability = identifyCapability(privateToken.trim()); + if (!capability) return NO_AUTHORIZATION_CAPABILITY; + return capability.provider === 'gitlab' + ? { type: 'capability', value: capability } + : { type: 'unsupported_capability' }; +} + +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 tokenService = env.GIT_TOKEN_SERVICE; + if (!supportsGitHubSessionCapabilityRedemption(tokenService)) { + return new Response('GitHub authorization unavailable', { status: 502 }); + } + try { + const result = await tokenService.redeemGitHubSessionCapability({ + capability: capability.capability, + outboundContainerId, + requestMethod: request.method, + requestUrl: request.url, + }); + if (!result.success) { + return new Response('GitHub authorization unavailable', { status: 502 }); + } + return forwardRedeemedRequest(request, { authorization: result.authorization }); + } catch { + return new Response('GitHub authorization unavailable', { status: 502 }); + } +} + +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') + ); + 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..1de4bfc1ba 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', + instanceHost: 'gitlab.example.com', + 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/acme/platform/repo.git', + userId: 'user_1', + outboundContainerId: 'container-test', + createdOnPlatform: 'code-review', + } + ); + + expect(issueGitLabSessionCapability).toHaveBeenCalledWith({ + gitUrl: 'https://gitlab.example.com/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/acme/platform/repo.git', + instanceOrigin: 'https://gitlab.example.com', + instanceHost: 'gitlab.example.com', + 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 2a0175d147..59fc455317 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 into separate wrapper requests', async () => { const service = new SessionService(); const env = createEnv(); @@ -1114,9 +1466,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', @@ -1131,7 +1493,7 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { repo: { kind: 'git', url: 'https://gitlab.com/acme/repo.git', - token: 'resolved-gitlab-token', + token: 'kgl2.default', platform: 'gitlab', }, materialized: { @@ -1145,7 +1507,8 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { expect(result.promptRequest).not.toHaveProperty('materialized'); expect(result.readyRequest.materialized.env.PUBLIC_VALUE).toBe('visible'); expect(result.readyRequest.materialized.env.KILOCODE_TOKEN).toBe('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'); @@ -1240,11 +1603,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', @@ -1262,18 +1659,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]', @@ -1291,16 +1694,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({ @@ -1312,11 +1719,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, } @@ -1324,15 +1732,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( @@ -1341,16 +1753,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', @@ -1358,11 +1766,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', @@ -1385,29 +1793,72 @@ 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 standard HTTPS host', async () => { + tokenMocks.issueCloudAgentGitLabSessionCapability.mockResolvedValueOnce({ + success: true, + value: { + capability: 'kgl2.self-managed', + gitUrl: 'https://gitlab.example.com/acme/platform/repo.git', + instanceOrigin: 'https://gitlab.example.com', + instanceHost: 'gitlab.example.com', + 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:443/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:443/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/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_HOST).toBe('gitlab.example.com:8443'); + expect(result.readyRequest.materialized.env.GITLAB_TOKEN).toBe('kgl2.self-managed'); + expect(result.readyRequest.materialized.env.GITLAB_HOST).toBe('gitlab.example.com'); 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({ @@ -1418,47 +1869,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(), @@ -1473,9 +1948,11 @@ 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([ @@ -1496,7 +1973,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') { @@ -1510,7 +1987,7 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { }, } satisfies CloudAgentSessionState; - tokenMocks.resolveManagedGitLabToken.mockResolvedValueOnce({ + tokenMocks.issueCloudAgentGitLabSessionCapability.mockResolvedValueOnce({ success: false, reason, }); @@ -1518,12 +1995,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', }); @@ -1531,7 +2018,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 bccc9ed925..951bf807ed 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 { @@ -340,6 +340,7 @@ export type ResolvedWorkspaceTokens = { githubCommitCoAuthor?: GitAuthorConfig; githubFallbackReason?: ManagedGitHubFallbackReason; gitToken?: string; + gitlabCapabilityGitUrl?: string; gitlabTokenManaged?: boolean; glabIsOAuth2?: boolean; }; @@ -1285,8 +1286,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; @@ -1298,16 +1301,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; @@ -1327,23 +1332,25 @@ export class SessionService { } let gitToken = repositoryPlatform(metadata) === 'gitlab' ? undefined : git?.token; + let gitlabCapabilityGitUrl: string | undefined; let gitlabTokenManaged = git?.type === 'gitlab' ? git.gitlabTokenManaged : 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; + glabIsOAuth2 = result.value.glabIsOAuth2; } else { throw ExecutionError.invalidRequest(gitLabTokenLookupFailureMessage(result.reason)); } @@ -1364,6 +1371,7 @@ export class SessionService { githubCommitCoAuthor, githubFallbackReason, gitToken, + gitlabCapabilityGitUrl, gitlabTokenManaged, glabIsOAuth2, }; @@ -1393,7 +1401,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 = @@ -1403,8 +1413,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, @@ -1414,7 +1422,7 @@ export class SessionService { sessionHome, githubRepo: github?.repo, githubToken: resolvedTokens.githubToken, - gitUrl: git?.url, + gitUrl: resolvedTokens.gitlabCapabilityGitUrl ?? git?.url, gitToken: resolvedTokens.gitToken, gitlabTokenManaged: resolvedTokens.gitlabTokenManaged, glabIsOAuth2: resolvedTokens.glabIsOAuth2, @@ -1437,7 +1445,7 @@ 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, glabIsOAuth2: resolvedTokens.glabIsOAuth2, platform, @@ -1578,7 +1586,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 @@ -1617,7 +1625,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); @@ -1639,7 +1647,7 @@ export class SessionService { sessionHome, githubRepo: github?.repo, githubToken: resolvedTokens.githubToken, - gitUrl: git?.url, + gitUrl: resolvedTokens.gitlabCapabilityGitUrl ?? git?.url, gitToken: resolvedTokens.gitToken, gitlabTokenManaged: resolvedTokens.gitlabTokenManaged, glabIsOAuth2: resolvedTokens.glabIsOAuth2, @@ -1917,10 +1925,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); @@ -2014,7 +2029,7 @@ export class SessionService { await updateGitRemoteToken( session, context.workspacePath, - git.url, + tokens.gitlabCapabilityGitUrl ?? git.url, tokens.gitToken, repositoryPlatform(metadata) ); diff --git a/services/cloud-agent-next/src/types.ts b/services/cloud-agent-next/src/types.ts index a38e5e8290..3605ae6c14 100644 --- a/services/cloud-agent-next/src/types.ts +++ b/services/cloud-agent-next/src/types.ts @@ -122,6 +122,13 @@ export type GitAuthorConfig = { email: string; }; +type ManagedGitHubAuthParams = { + githubRepo: string; + userId: string; + orgId?: string; + allowUserAuthorization: boolean; +}; + type GetCloudAgentAuthForRepoResult = | { success: true; @@ -144,23 +151,112 @@ 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' + | '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 = { @@ -170,18 +266,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 6883ddea91..c547cc7829 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/git-token-service/.dev.vars.example b/services/git-token-service/.dev.vars.example index c1c0938735..3da8489b64 100644 --- a/services/git-token-service/.dev.vars.example +++ b/services/git-token-service/.dev.vars.example @@ -23,6 +23,8 @@ 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/github-session-capability.test.ts b/services/git-token-service/src/github-session-capability.test.ts new file mode 100644 index 0000000000..3d98c6f129 --- /dev/null +++ b/services/git-token-service/src/github-session-capability.test.ts @@ -0,0 +1,132 @@ +import { encryptWithSymmetricKey } from '@kilocode/encryption'; +import { describe, expect, it, vi } from 'vitest'; +import { + GitHubSessionCapabilityCodec, + GitHubSessionCapabilityError, +} from './github-session-capability.js'; + +const encryptionKey = Buffer.alloc(32, 7).toString('base64'); +const anotherEncryptionKey = Buffer.alloc(32, 8).toString('base64'); +const claims = { + userId: 'user_1', + outboundContainerId: 'outbound-container-1', + orgId: 'ef2eb5c7-27ce-4f43-b6d3-8f282abc145b', + owner: 'acme', + repo: 'widgets', + source: 'user', + identity: { + installationId: 'installation_1', + accountLogin: 'acme', + appType: 'standard', + gitAuthor: { name: 'octocat', email: '1+octocat@users.noreply.github.com' }, + commitCoAuthor: { + name: 'kiloconnect[bot]', + email: '240665456+kiloconnect[bot]@users.noreply.github.com', + }, + }, +} as const; + +describe('GitHubSessionCapabilityCodec', () => { + it('produces an opaque prefixed capability with time-bounded bound claims', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-30T12:00:00.000Z')); + const codec = new GitHubSessionCapabilityCodec(encryptionKey); + + const capability = codec.issue(claims); + const decoded = codec.decode(capability); + + expect(capability).toMatch(/^kgh2\./); + expect(capability).not.toContain('user_1'); + expect(capability).not.toContain('acme'); + expect(decoded).toEqual({ + purpose: 'github_scm_session', + version: 2, + userId: 'user_1', + outboundContainerId: claims.outboundContainerId, + orgId: claims.orgId, + owner: 'acme', + repo: 'widgets', + source: 'user', + identity: claims.identity, + issuedAt: Date.parse('2026-05-30T12:00:00.000Z'), + expiresAt: Date.parse('2026-05-30T13:00:00.000Z'), + }); + vi.useRealTimers(); + }); + + it('produces and decodes a legacy unbound v1 capability when the container is omitted', () => { + const codec = new GitHubSessionCapabilityCodec(encryptionKey); + const { outboundContainerId: _outboundContainerId, ...legacyClaims } = claims; + + const capability = codec.issue(legacyClaims); + + expect(capability).toMatch(/^kgh1\./); + expect(codec.decode(capability)).toMatchObject({ + version: 1, + userId: 'user_1', + owner: 'acme', + repo: 'widgets', + }); + expect(codec.decode(capability)).not.toHaveProperty('outboundContainerId'); + }); + + it('rejects expired and tampered capabilities', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-30T12:00:00.000Z')); + const codec = new GitHubSessionCapabilityCodec(encryptionKey); + const capability = codec.issue(claims); + + vi.setSystemTime(new Date('2026-05-30T12:59:59.999Z')); + expect(codec.decode(capability)).toMatchObject({ source: 'user' }); + + vi.setSystemTime(new Date('2026-05-30T13:00:00.000Z')); + expect(() => codec.decode(capability)).toThrowError( + expect.objectContaining({ reason: 'expired_capability' }) + ); + const changedOffset = capability.lastIndexOf('.') + 4; + const changedCharacter = capability[changedOffset] === 'A' ? 'B' : 'A'; + const tampered = `${capability.slice(0, changedOffset)}${changedCharacter}${capability.slice(changedOffset + 1)}`; + expect(() => codec.decode(tampered)).toThrowError(GitHubSessionCapabilityError); + expect(() => codec.decode(`${capability}corrupted`)).toThrowError(GitHubSessionCapabilityError); + vi.useRealTimers(); + }); + + it('does not decode a capability with a different valid encryption key', () => { + const capability = new GitHubSessionCapabilityCodec(encryptionKey).issue(claims); + + expect(() => + new GitHubSessionCapabilityCodec(anotherEncryptionKey).decode(capability) + ).toThrowError(expect.objectContaining({ reason: 'invalid_capability' })); + }); + + it.each([ + ['another purpose', 'kgh2.', { purpose: 'another_use', version: 2 }], + ['a v2 claim under the legacy marker', 'kgh1.', { purpose: 'github_scm_session', version: 2 }], + ])('rejects decrypted claims with %s', (_description, prefix, boundClaims) => { + const codec = new GitHubSessionCapabilityCodec(encryptionKey); + const serializedClaims = JSON.stringify({ + ...boundClaims, + userId: 'user_1', + outboundContainerId: claims.outboundContainerId, + owner: 'acme', + repo: 'widgets', + source: 'installation', + identity: { + installationId: 'installation_1', + accountLogin: 'acme', + appType: 'standard', + gitAuthor: { + name: 'kiloconnect[bot]', + email: '240665456+kiloconnect[bot]@users.noreply.github.com', + }, + }, + issuedAt: Date.now(), + expiresAt: Date.now() + 60_000, + }); + const capability = `${prefix}${encryptWithSymmetricKey(serializedClaims, encryptionKey)}`; + + expect(() => codec.decode(capability)).toThrowError( + expect.objectContaining({ reason: 'invalid_capability' }) + ); + }); +}); diff --git a/services/git-token-service/src/github-session-capability.ts b/services/git-token-service/src/github-session-capability.ts new file mode 100644 index 0000000000..83cdeb46ab --- /dev/null +++ b/services/git-token-service/src/github-session-capability.ts @@ -0,0 +1,172 @@ +import { decryptWithSymmetricKey, encryptWithSymmetricKey } from '@kilocode/encryption'; +import { Buffer } from 'node:buffer'; +import { z } from 'zod'; + +const LEGACY_CAPABILITY_PREFIX = 'kgh1.'; +const BOUND_CAPABILITY_PREFIX = 'kgh2.'; +const CAPABILITY_PURPOSE = 'github_scm_session'; +const MAX_GITHUB_SCM_SESSION_CAPABILITY_LIFETIME_MS = 60 * 60 * 1000; +const GitHubPathPartSchema = z + .string() + .trim() + .min(1) + .max(100) + .regex(/^[a-z0-9_.-]+$/) + .refine(value => value === value.toLowerCase()); + +const GitAuthorSchema = z + .object({ + name: z.string().min(1), + email: z.string().min(1), + }) + .strict(); +const GitHubSessionIdentitySchema = z + .object({ + installationId: z.string().min(1), + accountLogin: z.string().min(1), + appType: z.enum(['standard', 'lite']), + gitAuthor: GitAuthorSchema, + commitCoAuthor: GitAuthorSchema.optional(), + }) + .strict(); +const GitHubSessionCapabilityClaimsBaseSchema = z.object({ + purpose: z.literal(CAPABILITY_PURPOSE), + userId: z.string().min(1), + orgId: z.string().uuid().optional(), + owner: GitHubPathPartSchema, + repo: GitHubPathPartSchema, + source: z.enum(['user', 'installation']), + identity: GitHubSessionIdentitySchema, + issuedAt: z.number().int().nonnegative(), + expiresAt: z.number().int().positive(), +}); +const GitHubLegacySessionCapabilityClaimsSchema = GitHubSessionCapabilityClaimsBaseSchema.extend({ + version: z.literal(1), +}).strict(); +const GitHubBoundSessionCapabilityClaimsSchema = GitHubSessionCapabilityClaimsBaseSchema.extend({ + version: z.literal(2), + outboundContainerId: z.string().min(1), +}).strict(); +const GitHubSessionCapabilityClaimsSchema = z + .discriminatedUnion('version', [ + GitHubLegacySessionCapabilityClaimsSchema, + GitHubBoundSessionCapabilityClaimsSchema, + ]) + .refine(claims => claims.expiresAt > claims.issuedAt) + .refine( + claims => claims.expiresAt - claims.issuedAt <= MAX_GITHUB_SCM_SESSION_CAPABILITY_LIFETIME_MS + ); + +export type GitHubAuthSource = 'user' | 'installation'; +export type GitHubSessionIdentity = z.infer; +export type GitHubSessionCapabilitySubject = { + userId: string; + outboundContainerId?: string; + orgId?: string; + owner: string; + repo: string; + source: GitHubAuthSource; + identity: GitHubSessionIdentity; +}; +export type GitHubSessionCapabilityClaims = z.infer; +export type GitHubSessionCapabilityFailureReason = + | 'invalid_capability' + | 'expired_capability' + | 'capability_configuration_error'; + +export class GitHubSessionCapabilityError extends Error { + constructor(readonly reason: GitHubSessionCapabilityFailureReason) { + super(reason); + this.name = 'GitHubSessionCapabilityError'; + } +} + +export function hasCanonicalEncryptedValueFormat(encrypted: string): boolean { + const parts = encrypted.split(':'); + if (parts.length !== 3) return false; + const [iv, authTag, ciphertext] = parts; + if (!iv || !authTag || !ciphertext) return false; + return [ + [iv, 16], + [authTag, 16], + [ciphertext, null], + ].every(([encoded, expectedLength]) => { + if (typeof encoded !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(encoded)) return false; + const decoded = Buffer.from(encoded, 'base64'); + if (decoded.toString('base64') !== encoded) return false; + return expectedLength === null || decoded.length === expectedLength; + }); +} + +export function normalizeGitHubRepository( + githubRepo: string +): { owner: string; repo: string } | null { + const parts = githubRepo.split('/'); + if (parts.length !== 2) return null; + const owner = parts[0]?.trim().toLowerCase(); + const repo = parts[1]?.trim().toLowerCase(); + const parsed = z.object({ owner: GitHubPathPartSchema, repo: GitHubPathPartSchema }).safeParse({ + owner, + repo, + }); + return parsed.success ? parsed.data : null; +} + +export class GitHubSessionCapabilityCodec { + constructor(private readonly encryptionKey: string) {} + + issue(subject: GitHubSessionCapabilitySubject): string { + const issuedAt = Date.now(); + const bound = subject.outboundContainerId !== undefined; + const parsed = GitHubSessionCapabilityClaimsSchema.safeParse({ + purpose: CAPABILITY_PURPOSE, + version: bound ? 2 : 1, + ...subject, + issuedAt, + expiresAt: issuedAt + MAX_GITHUB_SCM_SESSION_CAPABILITY_LIFETIME_MS, + }); + if (!parsed.success) throw new GitHubSessionCapabilityError('invalid_capability'); + + try { + const prefix = bound ? BOUND_CAPABILITY_PREFIX : LEGACY_CAPABILITY_PREFIX; + return `${prefix}${encryptWithSymmetricKey(JSON.stringify(parsed.data), this.encryptionKey)}`; + } catch { + throw new GitHubSessionCapabilityError('capability_configuration_error'); + } + } + + decode(capability: string): GitHubSessionCapabilityClaims { + const format = capability.startsWith(LEGACY_CAPABILITY_PREFIX) + ? { prefix: LEGACY_CAPABILITY_PREFIX, version: 1 as const } + : capability.startsWith(BOUND_CAPABILITY_PREFIX) + ? { prefix: BOUND_CAPABILITY_PREFIX, version: 2 as const } + : null; + if (!format) throw new GitHubSessionCapabilityError('invalid_capability'); + + const encrypted = capability.slice(format.prefix.length); + if (!hasCanonicalEncryptedValueFormat(encrypted)) { + throw new GitHubSessionCapabilityError('invalid_capability'); + } + let serialized: string; + try { + serialized = decryptWithSymmetricKey(encrypted, this.encryptionKey); + } catch { + throw new GitHubSessionCapabilityError('invalid_capability'); + } + + let value: unknown; + try { + value = JSON.parse(serialized); + } catch { + throw new GitHubSessionCapabilityError('invalid_capability'); + } + const parsed = GitHubSessionCapabilityClaimsSchema.safeParse(value); + if (!parsed.success || parsed.data.version !== format.version) { + throw new GitHubSessionCapabilityError('invalid_capability'); + } + if (parsed.data.expiresAt <= Date.now()) { + throw new GitHubSessionCapabilityError('expired_capability'); + } + return parsed.data; + } +} diff --git a/services/git-token-service/src/gitlab-lookup-service.test.ts b/services/git-token-service/src/gitlab-lookup-service.test.ts index e0a2aaeb63..a981dee935 100644 --- a/services/git-token-service/src/gitlab-lookup-service.test.ts +++ b/services/git-token-service/src/gitlab-lookup-service.test.ts @@ -15,6 +15,9 @@ function integration( ): AuthorizedGitLabIntegration { return { integrationId, + integrationType: 'oauth', + accountId: '42', + accountLogin: 'octocat', metadata: { gitlab_instance_url: instanceUrl }, }; } diff --git a/services/git-token-service/src/gitlab-lookup-service.ts b/services/git-token-service/src/gitlab-lookup-service.ts index 2c76259020..138807edff 100644 --- a/services/git-token-service/src/gitlab-lookup-service.ts +++ b/services/git-token-service/src/gitlab-lookup-service.ts @@ -26,13 +26,14 @@ export type GitLabIntegrationMetadata = { export type AuthorizedGitLabIntegration = { integrationId: string; + integrationType: string; + accountId: string | null; + accountLogin: string | null; metadata: GitLabIntegrationMetadata; }; -type GitLabLookupSuccess = { +export type GitLabLookupSuccess = AuthorizedGitLabIntegration & { success: true; - integrationId: string; - metadata: GitLabIntegrationMetadata; }; export type GitLabLookupFailure = { @@ -104,6 +105,10 @@ function parseGitLabInstanceUrl(instanceUrl: string): ParsedGitLabInstanceUrl | }; } +export function normalizeGitLabInstanceUrl(instanceUrl: string): string | null { + return parseGitLabInstanceUrl(instanceUrl)?.instanceUrl ?? null; +} + export function isValidGitLabRepositoryUrl(repositoryUrl: string): boolean { const parsed = parseSecureUrl(repositoryUrl); return parsed !== null && parsed.pathname !== '/' && !parsed.pathname.endsWith('/'); @@ -159,10 +164,17 @@ export function matchGitLabRepositoryToIntegration( }; } -export function buildAuthorizedGitLabIntegrationQuery(db: WorkerDb, params: GitLabLookupParams) { +export function buildAuthorizedGitLabIntegrationQuery( + db: WorkerDb, + params: GitLabLookupParams, + integrationId?: string +) { return db .select({ id: platform_integrations.id, + integration_type: platform_integrations.integration_type, + platform_account_id: platform_integrations.platform_account_id, + platform_account_login: platform_integrations.platform_account_login, metadata: platform_integrations.metadata, }) .from(platform_integrations) @@ -184,6 +196,7 @@ export function buildAuthorizedGitLabIntegrationQuery(db: WorkerDb, params: GitL and( eq(platform_integrations.platform, 'gitlab'), eq(platform_integrations.integration_status, 'active'), + ...(integrationId !== undefined ? [eq(platform_integrations.id, integrationId)] : []), params.orgId ? and( eq(platform_integrations.owned_by_organization_id, sql`${params.orgId}::uuid`), @@ -197,9 +210,23 @@ export function buildAuthorizedGitLabIntegrationQuery(db: WorkerDb, params: GitL ); } -export class GitLabLookupService { - private db: WorkerDb | null = null; +function parseAuthorizedGitLabIntegration(row: { + id: string; + integration_type: string; + platform_account_id: string | null; + platform_account_login: string | null; + metadata: unknown; +}): AuthorizedGitLabIntegration { + return { + integrationId: row.id, + integrationType: row.integration_type, + accountId: row.platform_account_id, + accountLogin: row.platform_account_login, + metadata: GitLabMetadataSchema.parse(row.metadata ?? {}), + }; +} +export class GitLabLookupService { constructor(private env: CloudflareEnv) {} isConfigured(): boolean { @@ -207,13 +234,10 @@ export class GitLabLookupService { } private getDb(): WorkerDb { - if (!this.db) { - if (!this.env.HYPERDRIVE) { - throw new Error('Hyperdrive not configured'); - } - this.db = getWorkerDb(this.env.HYPERDRIVE.connectionString, { statement_timeout: 10_000 }); + if (!this.env.HYPERDRIVE) { + throw new Error('Hyperdrive not configured'); } - return this.db; + return getWorkerDb(this.env.HYPERDRIVE.connectionString, { statement_timeout: 10_000 }); } private validateLookup(params: GitLabLookupParams): GitLabLookupFailure | undefined { @@ -226,22 +250,27 @@ export class GitLabLookupService { } } - async findGitLabIntegration(params: GitLabLookupParams): Promise { + async findGitLabIntegration( + params: GitLabLookupParams, + integrationId?: string + ): Promise { const validationFailure = this.validateLookup(params); if (validationFailure) { return validationFailure; } - const rows = await buildAuthorizedGitLabIntegrationQuery(this.getDb(), params).limit(1); + const rows = await buildAuthorizedGitLabIntegrationQuery( + this.getDb(), + params, + integrationId + ).limit(1); if (rows.length === 0) { return { success: false, reason: 'no_integration_found' }; } - const row = rows[0]; return { success: true, - integrationId: row.id, - metadata: GitLabMetadataSchema.parse(row.metadata ?? {}), + ...parseAuthorizedGitLabIntegration(rows[0]), }; } @@ -260,10 +289,7 @@ export class GitLabLookupService { return { success: true, - integrations: rows.map(row => ({ - integrationId: row.id, - metadata: GitLabMetadataSchema.parse(row.metadata ?? {}), - })), + integrations: rows.map(parseAuthorizedGitLabIntegration), }; } } diff --git a/services/git-token-service/src/gitlab-runtime-token-resolver.ts b/services/git-token-service/src/gitlab-runtime-token-resolver.ts index fc576e264d..9ca0fe37fe 100644 --- a/services/git-token-service/src/gitlab-runtime-token-resolver.ts +++ b/services/git-token-service/src/gitlab-runtime-token-resolver.ts @@ -5,6 +5,10 @@ import { type GitLabLookupService, type GitLabRepositoryMatch, } from './gitlab-lookup-service.js'; +import { + sha256Digest, + type GitLabCapabilityCredentialSource, +} from './gitlab-session-capability.js'; import type { GitLabTokenService } from './gitlab-token-service.js'; export type GetGitLabTokenParams = { @@ -19,6 +23,8 @@ export type GetGitLabTokenSuccess = { token: string; instanceUrl: string; glabIsOAuth2: boolean; + integrationId: string; + source: GitLabCapabilityCredentialSource; }; export type GetGitLabTokenFailure = { @@ -30,6 +36,7 @@ export type GetGitLabTokenFailure = { | 'no_token' | 'token_refresh_failed' | 'token_expired_no_refresh' + | 'invalid_instance_url' | 'repository_url_required' | 'invalid_repository_url' | 'no_matching_integration' @@ -51,6 +58,8 @@ type GitLabRuntimeTokenDependencies = { type GitLabProjectTokenCandidate = { token: string; instanceUrl: string; + integrationId: string; + projectId: number; }; type GitLabCandidateEvaluation = @@ -112,6 +121,8 @@ async function evaluateGitLabProjectTokenCandidate( candidate: { token: projectToken.token, instanceUrl: match.instanceUrl, + integrationId: match.integrationId, + projectId, }, }; } @@ -134,7 +145,12 @@ export async function resolveGitLabRuntimeToken( return tokenResult; } - return { ...tokenResult, glabIsOAuth2: true }; + return { + ...tokenResult, + glabIsOAuth2: true, + integrationId: integration.integrationId, + source: { type: 'integration' }, + }; } if (!params.repositoryUrl) { @@ -191,5 +207,11 @@ export async function resolveGitLabRuntimeToken( token: candidate.token, instanceUrl: candidate.instanceUrl, glabIsOAuth2: false, + integrationId: candidate.integrationId, + source: { + type: 'project', + projectId: candidate.projectId, + tokenDigest: await sha256Digest(candidate.token), + }, }; } diff --git a/services/git-token-service/src/gitlab-session-capability.test.ts b/services/git-token-service/src/gitlab-session-capability.test.ts new file mode 100644 index 0000000000..232739bd2e --- /dev/null +++ b/services/git-token-service/src/gitlab-session-capability.test.ts @@ -0,0 +1,158 @@ +import { encryptWithSymmetricKey } from '@kilocode/encryption'; +import { describe, expect, it, vi } from 'vitest'; +import { + GitLabSessionCapabilityCodec, + GitLabSessionCapabilityError, + parseGitLabCloneUrl, +} from './gitlab-session-capability.js'; + +const encryptionKey = Buffer.alloc(32, 7).toString('base64'); +const anotherEncryptionKey = Buffer.alloc(32, 8).toString('base64'); +const claims = { + userId: 'user_1', + outboundContainerId: 'outbound-container-1', + orgId: 'ef2eb5c7-27ce-4f43-b6d3-8f282abc145b', + integrationId: 'ef2eb5c7-27ce-4f43-b6d3-8f282abc145c', + instanceOrigin: 'https://gitlab.example.com', + projectPath: 'Acme/platform/widgets', + authType: 'oauth', + identity: { accountId: '42', accountLogin: 'octocat' }, + source: { + type: 'project', + projectId: 42, + tokenDigest: 'f30b0bf364d41460c0119e521d2af8ae7eeacca9745981678d58b07b13c94edf', + }, +} as const; + +describe('GitLabSessionCapabilityCodec', () => { + it('produces an opaque one-hour prefixed capability with GitLab-bound claims', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-31T12:00:00.000Z')); + const codec = new GitLabSessionCapabilityCodec(encryptionKey); + + const capability = codec.issue(claims); + + expect(capability).toMatch(/^kgl2\./); + expect(capability).not.toContain('user_1'); + expect(capability).not.toContain('gitlab.example.com'); + expect(codec.decode(capability)).toEqual({ + purpose: 'gitlab_scm_session', + version: 2, + ...claims, + issuedAt: Date.parse('2026-05-31T12:00:00.000Z'), + expiresAt: Date.parse('2026-05-31T13:00:00.000Z'), + }); + vi.useRealTimers(); + }); + + it('produces and decodes a legacy unbound v1 capability when the container is omitted', () => { + const codec = new GitLabSessionCapabilityCodec(encryptionKey); + const { outboundContainerId: _outboundContainerId, ...legacyClaims } = claims; + + const capability = codec.issue(legacyClaims); + + expect(capability).toMatch(/^kgl1\./); + expect(codec.decode(capability)).toMatchObject({ + version: 1, + userId: 'user_1', + projectPath: 'Acme/platform/widgets', + }); + expect(codec.decode(capability)).not.toHaveProperty('outboundContainerId'); + }); + + it('rejects expiry, tampering, and another encryption key', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-31T12:00:00.000Z')); + const codec = new GitLabSessionCapabilityCodec(encryptionKey); + const capability = codec.issue(claims); + const changedOffset = capability.lastIndexOf('.') + 4; + const changedCharacter = capability[changedOffset] === 'A' ? 'B' : 'A'; + const tampered = `${capability.slice(0, changedOffset)}${changedCharacter}${capability.slice(changedOffset + 1)}`; + + expect(() => codec.decode(tampered)).toThrowError(GitLabSessionCapabilityError); + expect(() => + new GitLabSessionCapabilityCodec(anotherEncryptionKey).decode(capability) + ).toThrowError(expect.objectContaining({ reason: 'invalid_capability' })); + vi.setSystemTime(new Date('2026-05-31T13:00:00.000Z')); + expect(() => codec.decode(capability)).toThrowError( + expect.objectContaining({ reason: 'expired_capability' }) + ); + vi.useRealTimers(); + }); + + it.each([ + ['another purpose', { purpose: 'github_scm_session' }], + [ + 'a malformed project-token digest', + { source: { type: 'project', projectId: 42, tokenDigest: 'not-a-sha256-digest' } }, + ], + ])('rejects encrypted claims with %s', (_description, overriddenClaims) => { + const serializedClaims = JSON.stringify({ + purpose: 'gitlab_scm_session', + version: 2, + ...claims, + ...overriddenClaims, + issuedAt: Date.now(), + expiresAt: Date.now() + 60_000, + }); + const capability = `kgl2.${encryptWithSymmetricKey(serializedClaims, encryptionKey)}`; + + expect(() => new GitLabSessionCapabilityCodec(encryptionKey).decode(capability)).toThrowError( + expect.objectContaining({ reason: 'invalid_capability' }) + ); + }); + + it('rejects a v2 claim under the legacy marker', () => { + const serializedClaims = JSON.stringify({ + purpose: 'gitlab_scm_session', + version: 2, + ...claims, + issuedAt: Date.now(), + expiresAt: Date.now() + 60_000, + }); + const capability = `kgl1.${encryptWithSymmetricKey(serializedClaims, encryptionKey)}`; + + expect(() => new GitLabSessionCapabilityCodec(encryptionKey).decode(capability)).toThrowError( + expect.objectContaining({ reason: 'invalid_capability' }) + ); + }); +}); + +describe('parseGitLabCloneUrl', () => { + it.each([ + [ + 'https://gitlab.com/acme/widgets.git', + undefined, + { + instanceOrigin: 'https://gitlab.com', + instanceHost: 'gitlab.com', + projectPath: 'acme/widgets', + }, + ], + [ + 'https://gitlab.example.com/Acme/platform/widgets.git', + 'https://gitlab.example.com/', + { + instanceOrigin: 'https://gitlab.example.com', + instanceHost: 'gitlab.example.com', + projectPath: 'Acme/platform/widgets', + }, + ], + ])('accepts canonical nested GitLab clone URL %s', (gitUrl, instanceUrl, expected) => { + expect(parseGitLabCloneUrl(gitUrl, instanceUrl)).toEqual({ success: true, ...expected }); + }); + + it.each([ + ['http://gitlab.com/acme/widgets.git', undefined], + ['https://gitlab.example.com:8443/acme/widgets.git', 'https://gitlab.example.com:8443'], + ['https://gitlab.example.com/acme/widgets.git', 'https://gitlab.example.com/gitlab'], + ['https://gitlab.example.com/acme/widgets.git', 'https://other.example.com'], + ['https://gitlab.example.com/acme//widgets.git', 'https://gitlab.example.com'], + ['https://gitlab.example.com/acme/../widgets.git', 'https://gitlab.example.com'], + ['https://gitlab.example.com/acme%2Fwidgets.git', 'https://gitlab.example.com'], + ['https://user:pass@gitlab.example.com/acme/widgets.git', 'https://gitlab.example.com'], + ['https://gitlab.example.com/acme/widgets.git?token=secret', 'https://gitlab.example.com'], + ])('rejects unsafe or unsupported clone URL %s', (gitUrl, instanceUrl) => { + expect(parseGitLabCloneUrl(gitUrl, instanceUrl).success).toBe(false); + }); +}); diff --git a/services/git-token-service/src/gitlab-session-capability.ts b/services/git-token-service/src/gitlab-session-capability.ts new file mode 100644 index 0000000000..9a100c7656 --- /dev/null +++ b/services/git-token-service/src/gitlab-session-capability.ts @@ -0,0 +1,242 @@ +import { decryptWithSymmetricKey, encryptWithSymmetricKey } from '@kilocode/encryption'; +import { z } from 'zod'; +import { hasCanonicalEncryptedValueFormat } from './github-session-capability.js'; + +const LEGACY_CAPABILITY_PREFIX = 'kgl1.'; +const BOUND_CAPABILITY_PREFIX = 'kgl2.'; +const CAPABILITY_PURPOSE = 'gitlab_scm_session'; +const MAX_GITLAB_SCM_SESSION_CAPABILITY_LIFETIME_MS = 60 * 60 * 1000; +const GitLabProjectPathSchema = z + .string() + .min(3) + .refine(path => path.split('/').length >= 2) + .refine(path => path.split('/').every(part => /^[A-Za-z0-9_.-]+$/.test(part))) + .refine(path => path.split('/').every(part => part !== '.' && part !== '..')); +const GitLabSessionIdentitySchema = z + .object({ + accountId: z.string().min(1).nullable(), + accountLogin: z.string().min(1).nullable(), + }) + .strict() + .refine(identity => identity.accountId !== null || identity.accountLogin !== null); +const GitLabProjectTokenDigestSchema = z.string().regex(/^[a-f0-9]{64}$/); +const GitLabCapabilityCredentialSourceSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('integration') }).strict(), + z + .object({ + type: z.literal('project'), + projectId: z.number().int().positive(), + tokenDigest: GitLabProjectTokenDigestSchema, + }) + .strict(), +]); +const GitLabSessionCapabilityClaimsBaseSchema = z.object({ + purpose: z.literal(CAPABILITY_PURPOSE), + userId: z.string().min(1), + orgId: z.string().uuid().optional(), + integrationId: z.string().uuid(), + instanceOrigin: z.string().url().refine(isCanonicalGitLabInstanceOrigin), + projectPath: GitLabProjectPathSchema, + authType: z.enum(['oauth', 'pat']), + identity: GitLabSessionIdentitySchema, + source: GitLabCapabilityCredentialSourceSchema, + issuedAt: z.number().int().nonnegative(), + expiresAt: z.number().int().positive(), +}); +const GitLabLegacySessionCapabilityClaimsSchema = GitLabSessionCapabilityClaimsBaseSchema.extend({ + version: z.literal(1), +}).strict(); +const GitLabBoundSessionCapabilityClaimsSchema = GitLabSessionCapabilityClaimsBaseSchema.extend({ + version: z.literal(2), + outboundContainerId: z.string().min(1), +}).strict(); +const GitLabSessionCapabilityClaimsSchema = z + .discriminatedUnion('version', [ + GitLabLegacySessionCapabilityClaimsSchema, + GitLabBoundSessionCapabilityClaimsSchema, + ]) + .refine(claims => claims.expiresAt > claims.issuedAt) + .refine( + claims => claims.expiresAt - claims.issuedAt <= MAX_GITLAB_SCM_SESSION_CAPABILITY_LIFETIME_MS + ); + +export type GitLabAuthType = 'oauth' | 'pat'; +export type GitLabSessionIdentity = z.infer; +export type GitLabCapabilityCredentialSource = z.infer< + typeof GitLabCapabilityCredentialSourceSchema +>; +export type GitLabSessionCapabilitySubject = { + userId: string; + outboundContainerId?: string; + orgId?: string; + integrationId: string; + instanceOrigin: string; + projectPath: string; + authType: GitLabAuthType; + identity: GitLabSessionIdentity; + source: GitLabCapabilityCredentialSource; +}; +export type GitLabSessionCapabilityClaims = z.infer; +export type GitLabSessionCapabilityFailureReason = + | 'invalid_capability' + | 'expired_capability' + | 'capability_configuration_error'; +export type GitLabCloneUrlFailureReason = 'invalid_gitlab_url' | 'unsupported_gitlab_instance'; +export type GitLabCloneUrlResult = + | { + success: true; + instanceOrigin: string; + instanceHost: string; + projectPath: string; + } + | { success: false; reason: GitLabCloneUrlFailureReason }; + +export class GitLabSessionCapabilityError extends Error { + constructor(readonly reason: GitLabSessionCapabilityFailureReason) { + super(reason); + this.name = 'GitLabSessionCapabilityError'; + } +} + +export function normalizeGitLabInstanceOrigin(instanceUrl: string): string | null { + let url: URL; + try { + url = new URL(instanceUrl); + } catch { + return null; + } + if ( + url.protocol !== 'https:' || + !url.hostname || + url.port !== '' || + url.username || + url.password || + url.search || + url.hash || + url.pathname !== '/' + ) { + return null; + } + return url.origin; +} + +function isCanonicalGitLabInstanceOrigin(instanceOrigin: string): boolean { + return normalizeGitLabInstanceOrigin(instanceOrigin) === instanceOrigin; +} + +function normalizeProjectPath(pathname: string): string | null { + if (/%2f|%5c/i.test(pathname)) return null; + let decodedPath: string; + try { + decodedPath = decodeURIComponent(pathname); + } catch { + return null; + } + if (decodedPath.includes('\\')) return null; + const rawParts = decodedPath.split('/'); + if (rawParts[0] !== '' || rawParts.some((part, index) => index > 0 && part === '')) return null; + const parts = rawParts.slice(1); + if (parts.length < 2) return null; + const terminal = parts.at(-1); + if (!terminal) return null; + if (terminal.endsWith('.git')) parts[parts.length - 1] = terminal.slice(0, -4); + const projectPath = parts.join('/'); + return GitLabProjectPathSchema.safeParse(projectPath).success ? projectPath : null; +} + +export function parseGitLabCloneUrl(gitUrl: string, instanceUrl?: string): GitLabCloneUrlResult { + if (/\/(?:(?:\.|%2e){1,2})(?:\/|$)/i.test(gitUrl)) { + return { success: false, reason: 'invalid_gitlab_url' }; + } + let url: URL; + try { + url = new URL(gitUrl); + } catch { + return { success: false, reason: 'invalid_gitlab_url' }; + } + if ( + url.protocol !== 'https:' || + !url.hostname || + url.port !== '' || + url.username || + url.password || + url.search || + url.hash + ) { + return { success: false, reason: 'invalid_gitlab_url' }; + } + const instanceOrigin = normalizeGitLabInstanceOrigin(instanceUrl ?? 'https://gitlab.com'); + if (!instanceOrigin || instanceOrigin !== url.origin) { + return { success: false, reason: 'unsupported_gitlab_instance' }; + } + const projectPath = normalizeProjectPath(url.pathname); + if (!projectPath) return { success: false, reason: 'invalid_gitlab_url' }; + return { + success: true, + instanceOrigin, + instanceHost: url.hostname, + projectPath, + }; +} + +export async function sha256Digest(value: string): Promise { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value)); + return Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join(''); +} + +export class GitLabSessionCapabilityCodec { + constructor(private readonly encryptionKey: string) {} + + issue(subject: GitLabSessionCapabilitySubject): string { + const issuedAt = Date.now(); + const bound = subject.outboundContainerId !== undefined; + const parsed = GitLabSessionCapabilityClaimsSchema.safeParse({ + purpose: CAPABILITY_PURPOSE, + version: bound ? 2 : 1, + ...subject, + issuedAt, + expiresAt: issuedAt + MAX_GITLAB_SCM_SESSION_CAPABILITY_LIFETIME_MS, + }); + if (!parsed.success) throw new GitLabSessionCapabilityError('invalid_capability'); + try { + const prefix = bound ? BOUND_CAPABILITY_PREFIX : LEGACY_CAPABILITY_PREFIX; + return `${prefix}${encryptWithSymmetricKey(JSON.stringify(parsed.data), this.encryptionKey)}`; + } catch { + throw new GitLabSessionCapabilityError('capability_configuration_error'); + } + } + + decode(capability: string): GitLabSessionCapabilityClaims { + const format = capability.startsWith(LEGACY_CAPABILITY_PREFIX) + ? { prefix: LEGACY_CAPABILITY_PREFIX, version: 1 as const } + : capability.startsWith(BOUND_CAPABILITY_PREFIX) + ? { prefix: BOUND_CAPABILITY_PREFIX, version: 2 as const } + : null; + if (!format) throw new GitLabSessionCapabilityError('invalid_capability'); + + const encrypted = capability.slice(format.prefix.length); + if (!hasCanonicalEncryptedValueFormat(encrypted)) { + throw new GitLabSessionCapabilityError('invalid_capability'); + } + let serialized: string; + try { + serialized = decryptWithSymmetricKey(encrypted, this.encryptionKey); + } catch { + throw new GitLabSessionCapabilityError('invalid_capability'); + } + let value: unknown; + try { + value = JSON.parse(serialized); + } catch { + throw new GitLabSessionCapabilityError('invalid_capability'); + } + const parsed = GitLabSessionCapabilityClaimsSchema.safeParse(value); + if (!parsed.success || parsed.data.version !== format.version) { + throw new GitLabSessionCapabilityError('invalid_capability'); + } + if (parsed.data.expiresAt <= Date.now()) { + throw new GitLabSessionCapabilityError('expired_capability'); + } + return parsed.data; + } +} diff --git a/services/git-token-service/src/gitlab-token-service.test.ts b/services/git-token-service/src/gitlab-token-service.test.ts new file mode 100644 index 0000000000..4eaad7e1ed --- /dev/null +++ b/services/git-token-service/src/gitlab-token-service.test.ts @@ -0,0 +1,89 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { GitLabTokenService } from './gitlab-token-service.js'; + +describe('GitLabTokenService', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('refreshes OAuth tokens against a safe instance base path', async () => { + const fetch = vi.fn().mockResolvedValue( + Response.json({ + access_token: 'refreshed-access-token', + refresh_token: 'refreshed-refresh-token', + token_type: 'bearer', + expires_in: 3600, + created_at: 1_800_000_000, + scope: 'api', + }) + ); + vi.stubGlobal('fetch', fetch); + const service = new GitLabTokenService({ + GITLAB_CLIENT_ID: 'client-id', + GITLAB_CLIENT_SECRET: 'client-secret', + } as unknown as CloudflareEnv); + vi.spyOn(service as any, 'updateIntegrationMetadata').mockResolvedValue(undefined); + + await expect( + service.getToken('integration_1', { + access_token: 'expired-access-token', + refresh_token: 'refresh-token', + token_expires_at: '2020-01-01T00:00:00.000Z', + auth_type: 'oauth', + gitlab_instance_url: 'https://gitlab.example.com/gitlab/', + }) + ).resolves.toEqual({ + success: true, + token: 'refreshed-access-token', + instanceUrl: 'https://gitlab.example.com/gitlab', + }); + expect(fetch).toHaveBeenCalledWith('https://gitlab.example.com/gitlab/oauth/token', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + client_id: 'client-id', + client_secret: 'client-secret', + refresh_token: 'refresh-token', + grant_type: 'refresh_token', + }), + }); + }); + + it('rejects unsafe refresh targets before sending OAuth credentials', async () => { + const fetch = vi.fn(); + vi.stubGlobal('fetch', fetch); + + await expect( + new GitLabTokenService({} as CloudflareEnv).getToken('integration_1', { + access_token: 'expired-access-token', + refresh_token: 'refresh-token', + token_expires_at: '2020-01-01T00:00:00.000Z', + auth_type: 'oauth', + gitlab_instance_url: + 'https://gitlab.example.com/gitlab?redirect=https://attacker.example.com', + }) + ).resolves.toEqual({ success: false, reason: 'invalid_instance_url' }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('does not log provider response bodies when refresh fails', async () => { + const text = vi.fn().mockResolvedValue('body includes token secret'); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 502, text })); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + await expect( + new GitLabTokenService({ + GITLAB_CLIENT_ID: 'client-id', + GITLAB_CLIENT_SECRET: 'client-secret', + } as unknown as CloudflareEnv).getToken('integration_1', { + access_token: 'expired-access-token', + refresh_token: 'refresh-token', + token_expires_at: '2020-01-01T00:00:00.000Z', + auth_type: 'oauth', + }) + ).resolves.toEqual({ success: false, reason: 'token_refresh_failed' }); + expect(JSON.stringify(consoleError.mock.calls)).not.toContain('body includes token secret'); + expect(text).not.toHaveBeenCalled(); + }); +}); diff --git a/services/git-token-service/src/gitlab-token-service.ts b/services/git-token-service/src/gitlab-token-service.ts index de4dd60e02..7b19c8e79d 100644 --- a/services/git-token-service/src/gitlab-token-service.ts +++ b/services/git-token-service/src/gitlab-token-service.ts @@ -3,7 +3,10 @@ import { platform_integrations } from '@kilocode/db/schema'; import { eq } from 'drizzle-orm'; import * as z from 'zod'; import { DEFAULT_GITLAB_INSTANCE_URL } from './gitlab-constants.js'; -import type { GitLabIntegrationMetadata } from './gitlab-lookup-service.js'; +import { + normalizeGitLabInstanceUrl, + type GitLabIntegrationMetadata, +} from './gitlab-lookup-service.js'; const GitLabOAuthTokenResponseSchema = z.object({ access_token: z.string(), @@ -24,11 +27,16 @@ export type GitLabTokenSuccess = { export type GitLabTokenFailure = { success: false; - reason: 'no_token' | 'token_refresh_failed' | 'token_expired_no_refresh'; + reason: 'no_token' | 'token_refresh_failed' | 'token_expired_no_refresh' | 'invalid_instance_url'; }; export type GitLabTokenResult = GitLabTokenSuccess | GitLabTokenFailure; +type GitLabTokenEnv = CloudflareEnv & { + GITLAB_CLIENT_ID?: string; + GITLAB_CLIENT_SECRET?: string; +}; + function isTokenExpired(expiresAt: string | null | undefined): boolean { if (!expiresAt) return true; const expiryTime = new Date(expiresAt).getTime(); @@ -42,15 +50,16 @@ function calculateTokenExpiry(createdAt: number, expiresIn: number): string { } export class GitLabTokenService { - private db: WorkerDb | null = null; - - constructor(private env: CloudflareEnv) {} + constructor(private env: GitLabTokenEnv) {} async getToken( integrationId: string, metadata: GitLabIntegrationMetadata ): Promise { - const instanceUrl = metadata.gitlab_instance_url || DEFAULT_GITLAB_INSTANCE_URL; + const instanceUrl = normalizeGitLabInstanceUrl( + metadata.gitlab_instance_url || DEFAULT_GITLAB_INSTANCE_URL + ); + if (!instanceUrl) return { success: false, reason: 'invalid_instance_url' }; if (!metadata.access_token) { return { success: false, reason: 'no_token' }; @@ -111,19 +120,18 @@ export class GitLabTokenService { }); if (!response.ok) { - const error = await response.text(); - console.error('GitLab OAuth token refresh failed:', { status: response.status, error }); + console.error('GitLab OAuth token refresh failed:', { status: response.status }); return null; } const parsed = GitLabOAuthTokenResponseSchema.safeParse(await response.json()); if (!parsed.success) { - console.error('Unexpected GitLab token response shape:', parsed.error); + console.error('Unexpected GitLab token response shape'); return null; } return parsed.data; - } catch (error) { - console.error('GitLab OAuth token refresh error:', error); + } catch { + console.error('GitLab OAuth token refresh request failed'); return null; } } @@ -151,12 +159,9 @@ export class GitLabTokenService { } private getDb(): WorkerDb { - if (!this.db) { - if (!this.env.HYPERDRIVE) { - throw new Error('Hyperdrive not configured'); - } - this.db = getWorkerDb(this.env.HYPERDRIVE.connectionString, { statement_timeout: 10_000 }); + if (!this.env.HYPERDRIVE) { + throw new Error('Hyperdrive not configured'); } - return this.db; + return getWorkerDb(this.env.HYPERDRIVE.connectionString, { statement_timeout: 10_000 }); } } diff --git a/services/git-token-service/src/index.test.ts b/services/git-token-service/src/index.test.ts index 8c04263c47..dac14ccdaf 100644 --- a/services/git-token-service/src/index.test.ts +++ b/services/git-token-service/src/index.test.ts @@ -1,19 +1,79 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type * as GitLabLookupServiceModule from './gitlab-lookup-service.js'; + +const serviceMocks = vi.hoisted(() => ({ + findInstallationId: vi.fn(), + findManagedInstallationForRepo: vi.fn(), + findRefreshCandidates: vi.fn(), + updateAccountLogin: vi.fn(), + getToken: vi.fn(), + getTokenForRepo: vi.fn(), + refreshInstallationAccountLoginIfDue: vi.fn(), + selectUserAuthorization: vi.fn(), + findGitLabIntegration: vi.fn(), + findAuthorizedGitLabIntegrations: vi.fn(), + getGitLabToken: vi.fn(), +})); vi.mock('cloudflare:workers', () => ({ WorkerEntrypoint: class WorkerEntrypoint { - constructor(_ctx: unknown, _env: unknown) {} + env: unknown; + + constructor(_ctx: unknown, env: unknown) { + this.env = env; + } + }, +})); + +vi.mock('./github-token-service.js', () => ({ + GitHubTokenService: class GitHubTokenService { + getToken = serviceMocks.getToken; + getTokenForRepo = serviceMocks.getTokenForRepo; + refreshInstallationAccountLoginIfDue = serviceMocks.refreshInstallationAccountLoginIfDue; + }, +})); + +vi.mock('./installation-lookup-service.js', () => ({ + InstallationLookupService: class InstallationLookupService { + findInstallationId = serviceMocks.findInstallationId; + findManagedInstallationForRepo = serviceMocks.findManagedInstallationForRepo; + findRefreshCandidates = serviceMocks.findRefreshCandidates; + updateAccountLogin = serviceMocks.updateAccountLogin; + }, +})); + +vi.mock('./github-user-authorization-service.js', () => ({ + GitHubUserAuthorizationService: class GitHubUserAuthorizationService { + selectUserAuthorization = serviceMocks.selectUserAuthorization; + }, +})); + +vi.mock('./gitlab-lookup-service.js', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + GitLabLookupService: class GitLabLookupService { + findGitLabIntegration = serviceMocks.findGitLabIntegration; + findAuthorizedGitLabIntegrations = serviceMocks.findAuthorizedGitLabIntegrations; + }, + }; +}); + +vi.mock('./gitlab-token-service.js', () => ({ + GitLabTokenService: class GitLabTokenService { + getToken = serviceMocks.getGitLabToken; }, })); -import { GitHubTokenService } from './github-token-service.js'; import type { AuthorizedGitLabIntegration } from './gitlab-lookup-service.js'; import { resolveGitLabRuntimeToken } from './gitlab-runtime-token-resolver.js'; import { GitTokenRPCEntrypoint } from './index.js'; -import { InstallationLookupService } from './installation-lookup-service.js'; const integration: AuthorizedGitLabIntegration = { integrationId: '123e4567-e89b-12d3-a456-426614174011', + integrationType: 'oauth', + accountId: '42', + accountLogin: 'octocat', metadata: { access_token: 'human-integration-token', gitlab_instance_url: 'https://gitlab.example.com/gitlab', @@ -39,6 +99,17 @@ function createDependencies(options: { integrations?: AuthorizedGitLabIntegratio return { lookupService, tokenService }; } +function createService(): GitTokenRPCEntrypoint { + return new GitTokenRPCEntrypoint( + {} as ExecutionContext, + { + GITHUB_APP_SLUG: 'kiloconnect', + GITHUB_APP_BOT_USER_ID: '240665456', + SCM_SESSION_CAPABILITY_ENCRYPTION_KEY: Buffer.alloc(32, 7).toString('base64'), + } as unknown as CloudflareEnv + ); +} + afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); @@ -52,6 +123,8 @@ describe('resolveGitLabRuntimeToken', () => { success: true, token: 'human-integration-token', instanceUrl: 'https://gitlab.example.com/gitlab', + integrationId: integration.integrationId, + source: { type: 'integration' }, glabIsOAuth2: true, }); expect(dependencies.lookupService.findGitLabIntegration).toHaveBeenCalledWith({ @@ -79,6 +152,12 @@ describe('resolveGitLabRuntimeToken', () => { success: true, token: 'project-bot-token', instanceUrl: 'https://gitlab.example.com/gitlab', + integrationId: integration.integrationId, + source: { + type: 'project', + projectId: 42, + tokenDigest: '3f4dff81e5f3e75d64343bfe237db23397715d8fbccbb1e035fb20a6d15f4603', + }, glabIsOAuth2: false, }); expect(dependencies.tokenService.getToken).toHaveBeenCalledWith( @@ -164,6 +243,12 @@ describe('resolveGitLabRuntimeToken', () => { success: true, token: 'project-bot-token', instanceUrl: 'https://gitlab.example.com/gitlab', + integrationId: integration.integrationId, + source: { + type: 'project', + projectId: 42, + tokenDigest: '3f4dff81e5f3e75d64343bfe237db23397715d8fbccbb1e035fb20a6d15f4603', + }, glabIsOAuth2: false, }); expect(dependencies.tokenService.getToken).toHaveBeenCalledTimes(2); @@ -209,6 +294,12 @@ describe('resolveGitLabRuntimeToken', () => { success: true, token: 'project-bot-token', instanceUrl: 'https://gitlab.example.com/gitlab', + integrationId: integration.integrationId, + source: { + type: 'project', + projectId: 42, + tokenDigest: '3f4dff81e5f3e75d64343bfe237db23397715d8fbccbb1e035fb20a6d15f4603', + }, glabIsOAuth2: false, }); }); @@ -241,6 +332,12 @@ describe('resolveGitLabRuntimeToken', () => { success: true, token: 'project-bot-token', instanceUrl: 'https://gitlab.example.com/gitlab', + integrationId: integration.integrationId, + source: { + type: 'project', + projectId: 42, + tokenDigest: '3f4dff81e5f3e75d64343bfe237db23397715d8fbccbb1e035fb20a6d15f4603', + }, glabIsOAuth2: false, }); expect(dependencies.tokenService.getToken).toHaveBeenCalledOnce(); @@ -300,23 +397,22 @@ describe('resolveGitLabRuntimeToken', () => { }); }); -describe('GitTokenRPCEntrypoint', () => { +describe('GitTokenRPCEntrypoint.getTokenForRepo', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it('mints repository-scoped tokens after resolving an authorized installation', async () => { - vi.spyOn(InstallationLookupService.prototype, 'findInstallationId').mockResolvedValue({ + serviceMocks.findInstallationId.mockResolvedValue({ success: true, installationId: '123', accountLogin: 'old-owner', githubAppType: 'lite', }); - const getTokenForRepo = vi - .spyOn(GitHubTokenService.prototype, 'getTokenForRepo') - .mockResolvedValue('scoped-token'); - const getToken = vi - .spyOn(GitHubTokenService.prototype, 'getToken') - .mockResolvedValue('installation-wide-token'); - const rpc = new GitTokenRPCEntrypoint({} as ExecutionContext, {} as CloudflareEnv); + serviceMocks.getTokenForRepo.mockResolvedValue('scoped-token'); + serviceMocks.getToken.mockResolvedValue('installation-wide-token'); - const result = await rpc.getTokenForRepo({ + const result = await createService().getTokenForRepo({ githubRepo: 'renamed-owner/repository', userId: 'user-1', }); @@ -328,13 +424,12 @@ describe('GitTokenRPCEntrypoint', () => { accountLogin: 'old-owner', appType: 'lite', }); - expect(getTokenForRepo).toHaveBeenCalledWith('123', 'repository', 'lite'); - expect(getToken).not.toHaveBeenCalled(); + expect(serviceMocks.getTokenForRepo).toHaveBeenCalledWith('123', 'repository', 'lite'); + expect(serviceMocks.getToken).not.toHaveBeenCalled(); }); it('repairs stale login metadata after a lookup miss before minting a token', async () => { - const findInstallationId = vi - .spyOn(InstallationLookupService.prototype, 'findInstallationId') + serviceMocks.findInstallationId .mockResolvedValueOnce({ success: false, reason: 'no_installation_found' }) .mockResolvedValueOnce({ success: true, @@ -342,7 +437,7 @@ describe('GitTokenRPCEntrypoint', () => { accountLogin: 'renamed-owner', githubAppType: 'standard', }); - vi.spyOn(InstallationLookupService.prototype, 'findRefreshCandidates').mockResolvedValue({ + serviceMocks.findRefreshCandidates.mockResolvedValue({ success: true, candidates: [ { @@ -353,26 +448,18 @@ describe('GitTokenRPCEntrypoint', () => { }, ], }); - const updateAccountLogin = vi - .spyOn(InstallationLookupService.prototype, 'updateAccountLogin') - .mockResolvedValue(true); + serviceMocks.updateAccountLogin.mockResolvedValue(true); + serviceMocks.refreshInstallationAccountLoginIfDue.mockResolvedValue('renamed-owner'); + serviceMocks.getTokenForRepo.mockResolvedValue('scoped-token'); const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {}); - vi.spyOn( - GitHubTokenService.prototype, - 'refreshInstallationAccountLoginIfDue' - ).mockResolvedValue('renamed-owner'); - const getTokenForRepo = vi - .spyOn(GitHubTokenService.prototype, 'getTokenForRepo') - .mockResolvedValue('scoped-token'); - const rpc = new GitTokenRPCEntrypoint({} as ExecutionContext, {} as CloudflareEnv); - - const result = await rpc.getTokenForRepo({ + + const result = await createService().getTokenForRepo({ githubRepo: 'renamed-owner/repository', userId: 'user-1', }); expect(result).toMatchObject({ success: true, token: 'scoped-token' }); - expect(updateAccountLogin).toHaveBeenCalledWith('integration-1', 'renamed-owner'); + expect(serviceMocks.updateAccountLogin).toHaveBeenCalledWith('integration-1', 'renamed-owner'); expect(consoleLog).toHaveBeenCalledWith( JSON.stringify({ message: 'Repaired GitHub installation account login after token lookup miss', @@ -383,147 +470,1137 @@ describe('GitTokenRPCEntrypoint', () => { ); expect(JSON.stringify(consoleLog.mock.calls)).not.toContain('old-owner'); expect(JSON.stringify(consoleLog.mock.calls)).not.toContain('renamed-owner'); - expect(findInstallationId).toHaveBeenCalledTimes(2); - expect(getTokenForRepo).toHaveBeenCalledWith('123', 'repository', 'standard'); + expect(serviceMocks.findInstallationId).toHaveBeenCalledTimes(2); + expect(serviceMocks.getTokenForRepo).toHaveBeenCalledWith('123', 'repository', 'standard'); }); +}); - it('warns instead of reporting success when a repaired integration no longer exists', async () => { - vi.spyOn(InstallationLookupService.prototype, 'findInstallationId').mockResolvedValue({ - success: false, - reason: 'no_installation_found', +const outboundContainerId = 'outbound-container-1'; + +describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { + beforeEach(() => { + vi.clearAllMocks(); + serviceMocks.findManagedInstallationForRepo.mockResolvedValue({ + success: true, + installationId: '123', + accountLogin: 'acme', + githubAppType: 'standard', + repoName: 'repo', + permissions: { contents: 'write', pull_requests: 'write' }, }); - vi.spyOn(InstallationLookupService.prototype, 'findRefreshCandidates').mockResolvedValue({ + serviceMocks.getTokenForRepo.mockResolvedValue('installation-token'); + serviceMocks.selectUserAuthorization.mockResolvedValue({ + selected: true, + token: 'user-token', + gitAuthor: { name: 'octocat', email: '1+octocat@users.noreply.github.com' }, + }); + }); + + it('issues an opaque GitHub capability while preserving non-secret attribution metadata', async () => { + const result = await createService().issueGitHubSessionCapability({ + githubRepo: 'Acme/Repo', + userId: 'user_1', + outboundContainerId, + allowUserAuthorization: true, + }); + + expect(result).toMatchObject({ success: true, - candidates: [ - { - integrationId: 'integration-1', - installationId: '123', - accountLogin: 'old-owner', - githubAppType: 'standard', - }, - ], + source: 'user', + installationId: '123', + accountLogin: 'acme', + appType: 'standard', + gitAuthor: { name: 'octocat' }, }); - vi.spyOn(InstallationLookupService.prototype, 'updateAccountLogin').mockResolvedValue(false); - vi.spyOn( - GitHubTokenService.prototype, - 'refreshInstallationAccountLoginIfDue' - ).mockResolvedValue('renamed-owner'); - const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {}); - const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const rpc = new GitTokenRPCEntrypoint({} as ExecutionContext, {} as CloudflareEnv); + if (!result.success) throw new Error('Expected successful issuance'); + expect(result.capability).toMatch(/^kgh2\./); + expect(JSON.stringify(result)).not.toContain('user-token'); + expect(result).not.toHaveProperty('githubToken'); + }); - const result = await rpc.getTokenForRepo({ - githubRepo: 'renamed-owner/repository', - userId: 'user-1', + it('does not expose an installation token in an installation-source issuance result', async () => { + const result = await createService().issueGitHubSessionCapability({ + githubRepo: 'acme/repo', + userId: 'user_1', + outboundContainerId, }); - expect(result).toEqual({ success: false, reason: 'no_installation_found' }); - expect(consoleLog).not.toHaveBeenCalled(); - expect(consoleWarn).toHaveBeenCalledWith( - JSON.stringify({ - message: 'GitHub installation login repair found no integration row to update', - integrationId: 'integration-1', - installationId: '123', - appType: 'standard', + expect(result).toMatchObject({ success: true, source: 'installation' }); + if (!result.success) throw new Error('Expected successful issuance'); + expect(JSON.stringify(result)).not.toContain('installation-token'); + expect(result.capability).not.toContain('installation-token'); + expect(result).not.toHaveProperty('githubToken'); + expect(result).not.toHaveProperty('token'); + }); + + it('returns a sanitized declared failure when capability key configuration is invalid', async () => { + const service = new GitTokenRPCEntrypoint( + {} as ExecutionContext, + { + GITHUB_APP_SLUG: 'kiloconnect', + GITHUB_APP_BOT_USER_ID: '240665456', + SCM_SESSION_CAPABILITY_ENCRYPTION_KEY: 'not-a-valid-key', + } as unknown as CloudflareEnv + ); + + await expect( + service.issueGitHubSessionCapability({ + githubRepo: 'acme/repo', + userId: 'user_1', + outboundContainerId, + }) + ).resolves.toEqual({ success: false, reason: 'capability_configuration_error' }); + }); + + it('does not redeem a capability from another outbound container or resolve authorization', async () => { + const service = createService(); + const issued = await service.issueGitHubSessionCapability({ + githubRepo: 'acme/repo', + userId: 'user_1', + outboundContainerId, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.findManagedInstallationForRepo.mockClear(); + serviceMocks.getTokenForRepo.mockClear(); + + await expect( + service.redeemGitHubSessionCapability({ + capability: issued.capability, + outboundContainerId: 'another-outbound-container', + requestMethod: 'GET', + requestUrl: 'https://github.com/acme/repo.git/info/refs?service=git-upload-pack', }) + ).resolves.toEqual({ success: false, reason: 'container_mismatch' }); + expect(serviceMocks.findManagedInstallationForRepo).not.toHaveBeenCalled(); + expect(serviceMocks.getTokenForRepo).not.toHaveBeenCalled(); + }); + + it('does not redeem a bound capability without an outbound container or resolve authorization', async () => { + const service = createService(); + const issued = await service.issueGitHubSessionCapability({ + githubRepo: 'acme/repo', + userId: 'user_1', + outboundContainerId, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.findManagedInstallationForRepo.mockClear(); + serviceMocks.getTokenForRepo.mockClear(); + + await expect( + service.redeemGitHubSessionCapability({ + capability: issued.capability, + requestMethod: 'GET', + requestUrl: 'https://github.com/acme/repo.git/info/refs?service=git-upload-pack', + }) + ).resolves.toEqual({ success: false, reason: 'container_mismatch' }); + expect(serviceMocks.findManagedInstallationForRepo).not.toHaveBeenCalled(); + expect(serviceMocks.getTokenForRepo).not.toHaveBeenCalled(); + }); + + it('temporarily issues and redeems a legacy unbound GitHub capability for an old caller', async () => { + 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: 'GET', + requestUrl: 'https://github.com/acme/repo.git/info/refs?service=git-upload-pack', + }) + ).resolves.toEqual({ + success: true, + authorization: `Basic ${Buffer.from('x-access-token:installation-token').toString('base64')}`, + }); + expect(serviceMocks.getTokenForRepo).toHaveBeenCalledOnce(); + }); + + it('rejects tampered capabilities before resolving any upstream authorization', async () => { + const service = createService(); + const issued = await service.issueGitHubSessionCapability({ + githubRepo: 'acme/repo', + userId: 'user_1', + outboundContainerId, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.findManagedInstallationForRepo.mockClear(); + serviceMocks.getTokenForRepo.mockClear(); + + const changedOffset = issued.capability.lastIndexOf('.') + 4; + const changedCharacter = issued.capability[changedOffset] === 'A' ? 'B' : 'A'; + const tamperedCapability = `${issued.capability.slice(0, changedOffset)}${changedCharacter}${issued.capability.slice(changedOffset + 1)}`; + await expect( + service.redeemGitHubSessionCapability({ + capability: tamperedCapability, + outboundContainerId, + requestMethod: 'GET', + requestUrl: 'https://github.com/acme/repo.git/info/refs?service=git-upload-pack', + }) + ).resolves.toEqual({ success: false, reason: 'invalid_capability' }); + expect(serviceMocks.findManagedInstallationForRepo).not.toHaveBeenCalled(); + expect(serviceMocks.getTokenForRepo).not.toHaveBeenCalled(); + }); + + it.each([ + ['GET', 'https://github.com/Acme/Repo.git/info/refs?service=git-upload-pack'], + ['GET', 'https://github.com/acme/repo.git/info/refs?service=git-receive-pack'], + ['POST', 'https://github.com/acme/repo.git/git-upload-pack'], + ['POST', 'https://github.com/acme/repo.git/git-receive-pack'], + ] as const)( + 'redeems an installation-pinned capability for %s Git URL %s', + async (requestMethod, requestUrl) => { + const service = createService(); + const issued = await service.issueGitHubSessionCapability({ + githubRepo: 'Acme/Repo', + userId: 'user_1', + outboundContainerId, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.getTokenForRepo.mockClear(); + + const redemption = await service.redeemGitHubSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod, + requestUrl, + }); + + expect(redemption).toEqual({ + success: true, + authorization: `Basic ${Buffer.from('x-access-token:installation-token').toString('base64')}`, + }); + expect(serviceMocks.selectUserAuthorization).not.toHaveBeenCalled(); + expect(serviceMocks.getTokenForRepo).toHaveBeenCalledOnce(); + } + ); + + it('returns a sanitized failure when installation token generation fails during redemption', async () => { + const service = createService(); + const issued = await service.issueGitHubSessionCapability({ + githubRepo: 'acme/repo', + userId: 'user_1', + outboundContainerId, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.getTokenForRepo.mockRejectedValueOnce( + new Error('provider rejected installation token: raw-provider-detail') ); - expect(JSON.stringify(consoleWarn.mock.calls)).not.toContain('old-owner'); - expect(JSON.stringify(consoleWarn.mock.calls)).not.toContain('renamed-owner'); + + const redemption = await service.redeemGitHubSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod: 'GET', + requestUrl: 'https://github.com/acme/repo.git/info/refs?service=git-upload-pack', + }); + + expect(redemption).toEqual({ success: false, reason: 'source_unavailable' }); + expect(JSON.stringify(redemption)).not.toContain('raw-provider-detail'); + expect(JSON.stringify(redemption)).not.toContain('provider rejected'); }); - it('does not mint when refreshed metadata identifies a different repository owner', async () => { - vi.spyOn(InstallationLookupService.prototype, 'findInstallationId').mockResolvedValue({ - success: false, - reason: 'no_installation_found', + it.each([ + 'https://github.com/acme/repo.git/info/lfs/objects/batch', + 'https://github.com/acme/repo.git/info/lfs/locks/verify', + ])('redeems an installation-pinned capability for exact LFS control URL %s', async requestUrl => { + const service = createService(); + const issued = await service.issueGitHubSessionCapability({ + githubRepo: 'acme/repo', + userId: 'user_1', + outboundContainerId, }); - vi.spyOn(InstallationLookupService.prototype, 'findRefreshCandidates').mockResolvedValue({ + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.getTokenForRepo.mockClear(); + + const redemption = await service.redeemGitHubSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod: 'POST', + requestUrl, + }); + + expect(redemption).toEqual({ success: true, - candidates: [ + authorization: `Basic ${Buffer.from('x-access-token:installation-token').toString('base64')}`, + }); + expect(serviceMocks.getTokenForRepo).toHaveBeenCalledOnce(); + }); + + 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'], + ] as const)( + 'rejects unsupported LFS control request %s %s', + async (requestMethod, requestUrl, reason) => { + const service = createService(); + const issued = await service.issueGitHubSessionCapability({ + githubRepo: 'acme/repo', + userId: 'user_1', + outboundContainerId, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.getTokenForRepo.mockClear(); + + await expect( + service.redeemGitHubSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod, + requestUrl, + }) + ).resolves.toEqual({ success: false, reason }); + expect(serviceMocks.getTokenForRepo).not.toHaveBeenCalled(); + } + ); + + it('redeems a user-pinned capability for api.github.com to preserve broad gh compatibility', 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'); + serviceMocks.selectUserAuthorization.mockClear(); + serviceMocks.selectUserAuthorization.mockResolvedValueOnce({ + selected: true, + token: 'refreshed-user-token', + gitAuthor: { name: 'octocat', email: '1+octocat@users.noreply.github.com' }, + }); + + const redemption = await service.redeemGitHubSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod: 'GET', + requestUrl: 'https://api.github.com/user/repos', + }); + + expect(redemption).toEqual({ success: true, authorization: 'Bearer refreshed-user-token' }); + expect(serviceMocks.selectUserAuthorization).toHaveBeenCalledOnce(); + }); + + it.each([ + ['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', + async (requestMethod, 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'); + expect(issued.source).toBe('user'); + serviceMocks.selectUserAuthorization.mockClear(); + + await expect( + service.redeemGitHubSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod, + requestUrl, + }) + ).resolves.toEqual({ success: false, reason: 'repository_mismatch' }); + expect(serviceMocks.selectUserAuthorization).not.toHaveBeenCalled(); + } + ); + + it.each([ + [ + 'GET', + 'http://github.com/acme/repo.git/info/refs?service=git-upload-pack', + 'invalid_upstream_url', + ], + [ + 'GET', + 'https://attacker@github.com/acme/repo.git/info/refs?service=git-upload-pack', + 'invalid_upstream_url', + ], + [ + 'GET', + 'https://github.com.evil.example/acme/repo.git/info/refs?service=git-upload-pack', + 'upstream_host_not_allowed', + ], + [ + 'GET', + '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'], + ] as const)( + 'rejects unsafe upstream request %s %s without forwarding authorization', + async (requestMethod, requestUrl, reason) => { + const service = createService(); + const issued = await service.issueGitHubSessionCapability({ + githubRepo: 'acme/repo', + userId: 'user_1', + outboundContainerId, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.getTokenForRepo.mockClear(); + + await expect( + service.redeemGitHubSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod, + requestUrl, + }) + ).resolves.toEqual({ success: false, reason }); + expect(serviceMocks.getTokenForRepo).not.toHaveBeenCalled(); + } + ); + + it('rejects user-source redemption rather than falling back to installation 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'); + serviceMocks.selectUserAuthorization.mockResolvedValueOnce({ + selected: false, + reason: 'no_user_authorization', + }); + serviceMocks.getTokenForRepo.mockClear(); + + await expect( + service.redeemGitHubSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod: 'GET', + requestUrl: 'https://api.github.com/repos/acme/repo', + }) + ).resolves.toEqual({ success: false, reason: 'source_unavailable' }); + expect(serviceMocks.getTokenForRepo).not.toHaveBeenCalled(); + }); + + it('rejects a user capability if selected attribution identity changes before redemption', 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'); + serviceMocks.selectUserAuthorization.mockResolvedValueOnce({ + selected: true, + token: 'refreshed-other-user-token', + gitAuthor: { name: 'another-user', email: '2+another-user@users.noreply.github.com' }, + }); + + await expect( + service.redeemGitHubSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod: 'GET', + requestUrl: 'https://api.github.com/user/repos', + }) + ).resolves.toEqual({ success: false, reason: 'identity_mismatch' }); + }); + + it('rejects an installation capability if the resolved installation identity changes', async () => { + const service = createService(); + const issued = await service.issueGitHubSessionCapability({ + githubRepo: 'acme/repo', + userId: 'user_1', + outboundContainerId, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.findManagedInstallationForRepo.mockResolvedValueOnce({ + success: true, + installationId: '456', + accountLogin: 'acme', + githubAppType: 'standard', + repoName: 'repo', + permissions: { contents: 'write', pull_requests: 'write' }, + }); + + await expect( + service.redeemGitHubSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod: 'GET', + requestUrl: 'https://github.com/acme/repo.git/info/refs?service=git-upload-pack', + }) + ).resolves.toEqual({ success: false, reason: 'identity_mismatch' }); + }); + + it('requires the outbound handler to redeem redirected requests again before forwarding auth', async () => { + const service = createService(); + const issued = await service.issueGitHubSessionCapability({ + githubRepo: 'acme/repo', + userId: 'user_1', + outboundContainerId, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + + await expect( + service.redeemGitHubSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod: 'GET', + requestUrl: 'https://redirect.example.com/acme/repo.git/info/refs?service=git-upload-pack', + }) + ).resolves.toEqual({ success: false, reason: 'upstream_host_not_allowed' }); + }); +}); + +describe('GitTokenRPCEntrypoint GitLab session capability RPCs', () => { + beforeEach(() => { + vi.clearAllMocks(); + serviceMocks.findGitLabIntegration.mockResolvedValue({ + success: true, + integrationId: 'ef2eb5c7-27ce-4f43-b6d3-8f282abc145c', + integrationType: 'oauth', + accountId: '42', + accountLogin: 'octocat', + metadata: { + access_token: 'gitlab-oauth-token', + auth_type: 'oauth', + }, + }); + serviceMocks.getGitLabToken.mockResolvedValue({ + success: true, + token: 'gitlab-oauth-token', + instanceUrl: 'https://gitlab.com', + }); + }); + + it.each([ + ['https://gitlab.com/acme/widgets.git', 'https://gitlab.com', 'gitlab.com', 'acme/widgets'], + [ + 'https://gitlab.example.com/acme/platform/widgets.git', + 'https://gitlab.example.com', + 'gitlab.example.com', + 'acme/platform/widgets', + ], + ])( + 'issues an opaque GitLab capability for %s', + async (gitUrl, instanceUrl, instanceHost, projectPath) => { + serviceMocks.findGitLabIntegration.mockResolvedValueOnce({ + success: true, + integrationId: 'ef2eb5c7-27ce-4f43-b6d3-8f282abc145c', + integrationType: 'oauth', + accountId: '42', + accountLogin: 'octocat', + metadata: { + access_token: 'gitlab-oauth-token', + auth_type: 'oauth', + ...(instanceUrl !== 'https://gitlab.com' ? { gitlab_instance_url: instanceUrl } : {}), + }, + }); + serviceMocks.getGitLabToken.mockResolvedValueOnce({ + success: true, + token: 'gitlab-oauth-token', + instanceUrl, + }); + + const result = await createService().issueGitLabSessionCapability({ + gitUrl, + userId: 'user_1', + outboundContainerId, + }); + + expect(result).toMatchObject({ + success: true, + instanceOrigin: instanceUrl, + instanceHost, + projectPath, + integrationId: 'ef2eb5c7-27ce-4f43-b6d3-8f282abc145c', + authType: 'oauth', + identity: { accountId: '42', accountLogin: 'octocat' }, + }); + if (!result.success) throw new Error('Expected successful issuance'); + expect(result.capability).toMatch(/^kgl2\./); + expect(JSON.stringify(result)).not.toContain('gitlab-oauth-token'); + expect(result).not.toHaveProperty('token'); + } + ); + + it('does not redeem a capability from another outbound container or resolve its source', async () => { + const service = createService(); + const issued = await service.issueGitLabSessionCapability({ + gitUrl: 'https://gitlab.com/acme/widgets.git', + userId: 'user_1', + outboundContainerId, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.findGitLabIntegration.mockClear(); + serviceMocks.getGitLabToken.mockClear(); + + await expect( + service.redeemGitLabSessionCapability({ + capability: issued.capability, + outboundContainerId: 'another-outbound-container', + requestMethod: 'GET', + requestUrl: 'https://gitlab.com/api/v4/projects', + }) + ).resolves.toEqual({ success: false, reason: 'container_mismatch' }); + expect(serviceMocks.findGitLabIntegration).not.toHaveBeenCalled(); + expect(serviceMocks.getGitLabToken).not.toHaveBeenCalled(); + }); + + it('does not redeem a bound capability without an outbound container or resolve its source', async () => { + const service = createService(); + const issued = await service.issueGitLabSessionCapability({ + gitUrl: 'https://gitlab.com/acme/widgets.git', + userId: 'user_1', + outboundContainerId, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.findGitLabIntegration.mockClear(); + serviceMocks.getGitLabToken.mockClear(); + + await expect( + service.redeemGitLabSessionCapability({ + capability: issued.capability, + requestMethod: 'GET', + requestUrl: 'https://gitlab.com/api/v4/projects', + }) + ).resolves.toEqual({ success: false, reason: 'container_mismatch' }); + expect(serviceMocks.findGitLabIntegration).not.toHaveBeenCalled(); + expect(serviceMocks.getGitLabToken).not.toHaveBeenCalled(); + }); + + it('temporarily issues and redeems a legacy unbound GitLab capability for an old caller', async () => { + 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.mockResolvedValueOnce({ + success: true, + token: 'refreshed-gitlab-token', + instanceUrl: 'https://gitlab.com', + }); + + await expect( + service.redeemGitLabSessionCapability({ + capability: issued.capability, + requestMethod: 'GET', + requestUrl: 'https://gitlab.com/api/v4/projects', + }) + ).resolves.toEqual({ + success: true, + headers: { authorization: 'Bearer refreshed-gitlab-token' }, + }); + expect(serviceMocks.findGitLabIntegration).toHaveBeenCalledOnce(); + expect(serviceMocks.getGitLabToken).toHaveBeenCalledTimes(2); + }); + + 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({ + success: true, + integrations: [ { - integrationId: 'integration-1', - installationId: '123', - accountLogin: 'old-owner', - githubAppType: 'standard', + integrationId: 'ef2eb5c7-27ce-4f43-b6d3-8f282abc145c', + metadata: { + access_token: 'gitlab-oauth-token', + auth_type: 'oauth', + project_tokens: { '42': { token: 'project-access-token' } }, + }, }, ], }); - const updateAccountLogin = vi - .spyOn(InstallationLookupService.prototype, 'updateAccountLogin') - .mockResolvedValue(true); - vi.spyOn(console, 'log').mockImplementation(() => {}); - vi.spyOn( - GitHubTokenService.prototype, - 'refreshInstallationAccountLoginIfDue' - ).mockResolvedValue('different-owner'); - const getTokenForRepo = vi.spyOn(GitHubTokenService.prototype, 'getTokenForRepo'); - const rpc = new GitTokenRPCEntrypoint({} as ExecutionContext, {} as CloudflareEnv); - - const result = await rpc.getTokenForRepo({ - githubRepo: 'requested-owner/repository', - userId: 'user-1', + serviceMocks.findGitLabIntegration.mockResolvedValueOnce({ + success: true, + integrationId: 'ef2eb5c7-27ce-4f43-b6d3-8f282abc145c', + integrationType: 'oauth', + accountId: '42', + accountLogin: 'octocat', + metadata: { + access_token: 'gitlab-oauth-token', + auth_type: 'oauth', + project_tokens: { '42': { token: 'project-access-token' } }, + }, }); - expect(result).toEqual({ success: false, reason: 'no_installation_found' }); - expect(updateAccountLogin).toHaveBeenCalledWith('integration-1', 'different-owner'); - expect(getTokenForRepo).not.toHaveBeenCalled(); + const result = await createService().issueGitLabSessionCapability({ + gitUrl: 'https://gitlab.com/acme/widgets.git', + userId: 'user_1', + outboundContainerId, + createdOnPlatform: 'code-review', + }); + + expect(result).toMatchObject({ + success: true, + source: { + type: 'project', + projectId: 42, + tokenDigest: 'f30b0bf364d41460c0119e521d2af8ae7eeacca9745981678d58b07b13c94edf', + }, + glabIsOAuth2: false, + }); + if (!result.success) throw new Error('Expected successful issuance'); + expect(result.capability).toMatch(/^kgl2\./); + expect(JSON.stringify(result)).not.toContain('project-access-token'); + expect(result).not.toHaveProperty('token'); }); - it('fails closed without metadata repair when exact owner selection is ambiguous', async () => { - vi.spyOn(InstallationLookupService.prototype, 'findInstallationId').mockResolvedValue({ - success: false, - reason: 'ambiguous_installation', + it.each([ + [ + 'GET', + 'https://gitlab.com/api/v4/projects/42/issues', + { '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')}` }, + ], + ] as const)( + 'redeems a project-source capability server-side for %s %s', + async (requestMethod, requestUrl, headers) => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(Response.json({ id: 42 }))); + const projectIntegration = { + success: true as const, + integrationId: 'ef2eb5c7-27ce-4f43-b6d3-8f282abc145c', + integrationType: 'oauth', + accountId: '42', + accountLogin: 'octocat', + metadata: { + access_token: 'gitlab-oauth-token', + auth_type: 'oauth' as const, + project_tokens: { '42': { token: 'project-access-token' } }, + }, + }; + serviceMocks.findAuthorizedGitLabIntegrations.mockResolvedValueOnce({ + success: true, + integrations: [projectIntegration], + }); + serviceMocks.findGitLabIntegration.mockResolvedValue(projectIntegration); + const service = createService(); + const issued = await service.issueGitLabSessionCapability({ + gitUrl: 'https://gitlab.com/acme/widgets.git', + userId: 'user_1', + outboundContainerId, + createdOnPlatform: 'code-review', + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.getGitLabToken.mockClear(); + + await expect( + service.redeemGitLabSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod, + requestUrl, + }) + ).resolves.toEqual({ success: true, headers }); + expect(serviceMocks.getGitLabToken).not.toHaveBeenCalled(); + } + ); + + it('fails closed when a project-source capability token is rotated', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(Response.json({ id: 42 }))); + const projectIntegration = { + success: true as const, + integrationId: 'ef2eb5c7-27ce-4f43-b6d3-8f282abc145c', + integrationType: 'oauth', + accountId: '42', + accountLogin: 'octocat', + metadata: { + access_token: 'gitlab-oauth-token', + auth_type: 'oauth' as const, + project_tokens: { '42': { token: 'project-access-token' } }, + }, + }; + serviceMocks.findAuthorizedGitLabIntegrations.mockResolvedValueOnce({ + success: true, + integrations: [projectIntegration], + }); + serviceMocks.findGitLabIntegration.mockResolvedValueOnce(projectIntegration); + const service = createService(); + const issued = await service.issueGitLabSessionCapability({ + gitUrl: 'https://gitlab.com/acme/widgets.git', + userId: 'user_1', + outboundContainerId, + createdOnPlatform: 'code-review', + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.findGitLabIntegration.mockResolvedValueOnce({ + ...projectIntegration, + metadata: { + ...projectIntegration.metadata, + project_tokens: { '42': { token: 'rotated-project-access-token' } }, + }, }); - const findRefreshCandidates = vi.spyOn( - InstallationLookupService.prototype, - 'findRefreshCandidates' - ); - const getTokenForRepo = vi.spyOn(GitHubTokenService.prototype, 'getTokenForRepo'); - const rpc = new GitTokenRPCEntrypoint({} as ExecutionContext, {} as CloudflareEnv); - const result = await rpc.getTokenForRepo({ - githubRepo: 'requested-owner/repository', - userId: 'user-1', + await expect( + service.redeemGitLabSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod: 'GET', + requestUrl: 'https://gitlab.com/api/v4/projects/42/issues', + }) + ).resolves.toEqual({ success: false, reason: 'source_unavailable' }); + }); + + it.each([ + [ + 'GET', + 'https://gitlab.com/acme/widgets.git/info/refs?service=git-upload-pack', + { authorization: `Basic ${Buffer.from('oauth2:refreshed-gitlab-pat').toString('base64')}` }, + ], + [ + 'GET', + 'https://gitlab.com/api/v4/projects?membership=true', + { authorization: 'Bearer refreshed-gitlab-pat' }, + ], + ] as const)( + 'redeems an ordinary PAT-source capability server-side for %s %s', + async (requestMethod, requestUrl, headers) => { + serviceMocks.findGitLabIntegration.mockResolvedValue({ + success: true, + integrationId: 'ef2eb5c7-27ce-4f43-b6d3-8f282abc145c', + integrationType: 'pat', + accountId: '42', + accountLogin: 'octocat', + metadata: { access_token: 'gitlab-pat-token', auth_type: 'pat' }, + }); + serviceMocks.getGitLabToken.mockResolvedValueOnce({ + success: true, + token: 'gitlab-pat-token', + instanceUrl: 'https://gitlab.com', + }); + const service = createService(); + const issued = await service.issueGitLabSessionCapability({ + gitUrl: 'https://gitlab.com/acme/widgets.git', + userId: 'user_1', + outboundContainerId, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.getGitLabToken.mockResolvedValueOnce({ + success: true, + token: 'refreshed-gitlab-pat', + instanceUrl: 'https://gitlab.com', + }); + + await expect( + service.redeemGitLabSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod, + requestUrl, + }) + ).resolves.toEqual({ success: true, headers }); + } + ); + + it.each([ + [ + 'GET', + 'https://gitlab.example.com/acme/platform/widgets.git/info/refs?service=git-upload-pack', + { + authorization: `Basic ${Buffer.from('oauth2:refreshed-self-managed-token').toString('base64')}`, + }, + ], + [ + 'GET', + 'https://gitlab.example.com/api/v4/projects?membership=true', + { authorization: 'Bearer refreshed-self-managed-token' }, + ], + ] as const)( + 'issues and redeems a nested self-managed GitLab capability for %s %s', + async (requestMethod, requestUrl, headers) => { + serviceMocks.findGitLabIntegration.mockResolvedValue({ + success: true, + integrationId: 'ef2eb5c7-27ce-4f43-b6d3-8f282abc145c', + integrationType: 'oauth', + accountId: '42', + accountLogin: 'octocat', + metadata: { + access_token: 'self-managed-token', + auth_type: 'oauth', + gitlab_instance_url: 'https://gitlab.example.com', + }, + }); + serviceMocks.getGitLabToken.mockResolvedValueOnce({ + success: true, + token: 'self-managed-token', + instanceUrl: 'https://gitlab.example.com', + }); + const service = createService(); + const issued = await service.issueGitLabSessionCapability({ + gitUrl: 'https://gitlab.example.com/acme/platform/widgets.git', + userId: 'user_1', + outboundContainerId, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.getGitLabToken.mockResolvedValueOnce({ + success: true, + token: 'refreshed-self-managed-token', + instanceUrl: 'https://gitlab.example.com', + }); + + await expect( + service.redeemGitLabSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod, + requestUrl, + }) + ).resolves.toEqual({ success: true, headers }); + } + ); + + 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) => { + serviceMocks.findGitLabIntegration.mockResolvedValue({ + success: true, + integrationId: 'ef2eb5c7-27ce-4f43-b6d3-8f282abc145c', + integrationType: 'oauth', + accountId: '42', + accountLogin: 'octocat', + metadata: { + access_token: 'self-managed-token', + auth_type: 'oauth', + gitlab_instance_url: 'https://gitlab.example.com', + }, }); + serviceMocks.getGitLabToken.mockResolvedValueOnce({ + success: true, + token: 'self-managed-token', + instanceUrl: 'https://gitlab.example.com', + }); + const service = createService(); + const issued = await service.issueGitLabSessionCapability({ + gitUrl: 'https://gitlab.example.com/acme/platform/widgets.git', + userId: 'user_1', + outboundContainerId, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.findGitLabIntegration.mockClear(); - expect(result).toEqual({ success: false, reason: 'no_installation_found' }); - expect(findRefreshCandidates).not.toHaveBeenCalled(); - expect(getTokenForRepo).not.toHaveBeenCalled(); + await expect( + service.redeemGitLabSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod: 'GET', + requestUrl, + }) + ).resolves.toEqual({ success: false, reason }); + expect(serviceMocks.findGitLabIntegration).not.toHaveBeenCalled(); }); - it('does not mint a token for an invalid repository path', async () => { - const getTokenForRepo = vi.spyOn(GitHubTokenService.prototype, 'getTokenForRepo'); - const rpc = new GitTokenRPCEntrypoint( + it('returns a sanitized declared failure when the capability key is invalid', async () => { + const service = new GitTokenRPCEntrypoint( {} as ExecutionContext, { - HYPERDRIVE: { connectionString: 'postgres://test' }, - } as CloudflareEnv + SCM_SESSION_CAPABILITY_ENCRYPTION_KEY: 'not-a-valid-key', + } as unknown as CloudflareEnv ); - const result = await rpc.getTokenForRepo({ - githubRepo: 'owner/repository/extra', - userId: 'user-1', + await expect( + service.issueGitLabSessionCapability({ + gitUrl: 'https://gitlab.com/acme/widgets.git', + userId: 'user_1', + outboundContainerId, + }) + ).resolves.toEqual({ success: false, reason: 'capability_configuration_error' }); + }); + + it('does not expose a PAT during issuance', async () => { + serviceMocks.findGitLabIntegration.mockResolvedValue({ + success: true, + integrationId: 'ef2eb5c7-27ce-4f43-b6d3-8f282abc145c', + integrationType: 'pat', + accountId: '42', + accountLogin: 'octocat', + metadata: { access_token: 'gitlab-pat-token', auth_type: 'pat' }, + }); + serviceMocks.getGitLabToken.mockResolvedValueOnce({ + success: true, + token: 'gitlab-pat-token', + instanceUrl: 'https://gitlab.com', }); - expect(result).toEqual({ success: false, reason: 'invalid_repo_format' }); - expect(getTokenForRepo).not.toHaveBeenCalled(); + const result = await createService().issueGitLabSessionCapability({ + gitUrl: 'https://gitlab.com/acme/widgets.git', + userId: 'user_1', + outboundContainerId, + }); + + expect(result).toMatchObject({ success: true, authType: 'pat' }); + expect(JSON.stringify(result)).not.toContain('gitlab-pat-token'); }); - it('does not fall back to an installation-wide token when scoped minting fails', async () => { - vi.spyOn(InstallationLookupService.prototype, 'findInstallationId').mockResolvedValue({ + it.each([ + ['GET', 'https://gitlab.com/acme/widgets.git/info/refs?service=git-upload-pack', 'Basic'], + ['GET', 'https://gitlab.com/acme/widgets.git/info/refs?service=git-receive-pack', 'Basic'], + ['POST', 'https://gitlab.com/acme/widgets.git/git-upload-pack', 'Basic'], + ['POST', 'https://gitlab.com/acme/widgets.git/git-receive-pack', 'Basic'], + ['POST', 'https://gitlab.com/acme/widgets.git/info/lfs/objects/batch', 'Basic'], + ['POST', 'https://gitlab.com/acme/widgets.git/info/lfs/locks/verify', 'Basic'], + ['GET', 'https://gitlab.com/api/v4/projects?membership=true', 'Bearer'], + ['POST', 'https://gitlab.com/api/graphql', 'Bearer'], + ] as const)('redeems allowed GitLab request %s %s', async (requestMethod, requestUrl, scheme) => { + const service = createService(); + const issued = await service.issueGitLabSessionCapability({ + gitUrl: 'https://gitlab.com/acme/widgets.git', + userId: 'user_1', + outboundContainerId, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.findGitLabIntegration.mockClear(); + serviceMocks.getGitLabToken.mockResolvedValueOnce({ success: true, - installationId: '123', - accountLogin: 'old-owner', - githubAppType: 'standard', + token: 'refreshed-gitlab-token', + instanceUrl: 'https://gitlab.com', }); - vi.spyOn(GitHubTokenService.prototype, 'getTokenForRepo').mockRejectedValue( - new Error('repository not accessible') + + const result = await service.redeemGitLabSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod, + requestUrl, + }); + + const authorization = + scheme === 'Basic' + ? `Basic ${Buffer.from('oauth2:refreshed-gitlab-token').toString('base64')}` + : 'Bearer refreshed-gitlab-token'; + expect(result).toEqual({ success: true, headers: { authorization } }); + expect(serviceMocks.findGitLabIntegration).toHaveBeenCalledWith( + { userId: 'user_1' }, + 'ef2eb5c7-27ce-4f43-b6d3-8f282abc145c' ); - const getToken = vi.spyOn(GitHubTokenService.prototype, 'getToken'); - const rpc = new GitTokenRPCEntrypoint({} as ExecutionContext, {} as CloudflareEnv); + }); + + it.each([ + [ + 'GET', + 'https://other.example.com/acme/widgets.git/info/refs?service=git-upload-pack', + 'upstream_origin_not_allowed', + ], + [ + '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', + 'invalid_upstream_url', + ], + ['CONNECT', 'https://gitlab.com/api/v4/projects', 'invalid_upstream_request'], + ] as const)('rejects unsafe GitLab request %s %s', async (requestMethod, requestUrl, reason) => { + const service = createService(); + const issued = await service.issueGitLabSessionCapability({ + gitUrl: 'https://gitlab.com/acme/widgets.git', + userId: 'user_1', + outboundContainerId, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.findGitLabIntegration.mockClear(); + + await expect( + service.redeemGitLabSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod, + requestUrl, + }) + ).resolves.toEqual({ success: false, reason }); + expect(serviceMocks.findGitLabIntegration).not.toHaveBeenCalled(); + }); + + it('fails closed if the pinned GitLab integration disappears', async () => { + const service = createService(); + const issued = await service.issueGitLabSessionCapability({ + gitUrl: 'https://gitlab.com/acme/widgets.git', + userId: 'user_1', + outboundContainerId, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.findGitLabIntegration.mockResolvedValueOnce({ + success: false, + reason: 'no_integration_found', + }); + serviceMocks.getGitLabToken.mockClear(); await expect( - rpc.getTokenForRepo({ githubRepo: 'renamed-owner/repository', userId: 'user-1' }) - ).rejects.toThrow('repository not accessible'); - expect(getToken).not.toHaveBeenCalled(); + service.redeemGitLabSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod: 'GET', + requestUrl: 'https://gitlab.com/api/v4/projects', + }) + ).resolves.toEqual({ success: false, reason: 'source_unavailable' }); + expect(serviceMocks.getGitLabToken).not.toHaveBeenCalled(); + }); + + it('fails closed if the pinned GitLab integration source identity drifts', async () => { + const service = createService(); + const issued = await service.issueGitLabSessionCapability({ + gitUrl: 'https://gitlab.com/acme/widgets.git', + userId: 'user_1', + outboundContainerId, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.findGitLabIntegration.mockResolvedValueOnce({ + success: true, + integrationId: 'ef2eb5c7-27ce-4f43-b6d3-8f282abc145c', + integrationType: 'pat', + accountId: '42', + accountLogin: 'octocat', + metadata: { access_token: 'gitlab-pat-token', auth_type: 'pat' }, + }); + + await expect( + service.redeemGitLabSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod: 'GET', + requestUrl: 'https://gitlab.com/api/v4/projects', + }) + ).resolves.toEqual({ success: false, reason: 'identity_mismatch' }); + expect(serviceMocks.getGitLabToken).toHaveBeenCalledOnce(); }); }); diff --git a/services/git-token-service/src/index.ts b/services/git-token-service/src/index.ts index ac0e53fc0f..babced11b3 100644 --- a/services/git-token-service/src/index.ts +++ b/services/git-token-service/src/index.ts @@ -1,14 +1,35 @@ +import { timingSafeEqual } from '@kilocode/encryption'; import { extractBearerToken, verifyKiloToken } from '@kilocode/worker-utils'; import { WorkerEntrypoint } from 'cloudflare:workers'; import { GitHubTokenService, type GitHubAppType } from './github-token-service.js'; -import { GitLabLookupService } from './gitlab-lookup-service.js'; +import { GitLabLookupService, type GitLabLookupSuccess } from './gitlab-lookup-service.js'; import { resolveGitLabRuntimeToken, type GetGitLabTokenParams, + type GetGitLabTokenFailure, type GetGitLabTokenResult, } from './gitlab-runtime-token-resolver.js'; +import { + GitLabSessionCapabilityCodec, + GitLabSessionCapabilityError, + normalizeGitLabInstanceOrigin, + parseGitLabCloneUrl, + sha256Digest, + type GitLabAuthType, + type GitLabCapabilityCredentialSource, + type GitLabCloneUrlFailureReason, + type GitLabSessionCapabilityFailureReason, + type GitLabSessionIdentity, +} from './gitlab-session-capability.js'; import { GitLabTokenService } from './gitlab-token-service.js'; import { InstallationLookupService } from './installation-lookup-service.js'; +import { + GitHubSessionCapabilityCodec, + GitHubSessionCapabilityError, + normalizeGitHubRepository, + type GitHubSessionCapabilityFailureReason, + type GitHubSessionIdentity, +} from './github-session-capability.js'; import { GitHubUserAuthorizationService, type GitAuthorConfig, @@ -69,16 +90,210 @@ export type GetCloudAgentAuthForRepoResult = | GetCloudAgentAuthForRepoSuccess | GetTokenForRepoFailure; +export type IssueGitHubSessionCapabilityParams = GetCloudAgentAuthForRepoParams & { + outboundContainerId?: string; +}; +export type IssueGitHubSessionCapabilitySuccess = Omit< + GetCloudAgentAuthForRepoSuccess, + 'githubToken' +> & { + capability: string; +}; +export type IssueGitHubSessionCapabilityResult = + | IssueGitHubSessionCapabilitySuccess + | GetTokenForRepoFailure + | { success: false; reason: 'capability_configuration_error' }; + +export type RedeemGitHubSessionCapabilityParams = { + capability: string; + outboundContainerId?: string; + requestMethod: string; + requestUrl: string; +}; +export type RedeemGitHubSessionCapabilitySuccess = { + success: true; + authorization: string; +}; +export type RedeemGitHubSessionCapabilityFailureReason = + | GitHubSessionCapabilityFailureReason + | 'container_mismatch' + | 'invalid_upstream_url' + | 'upstream_host_not_allowed' + | 'repository_mismatch' + | 'invalid_upstream_request' + | 'source_unavailable' + | 'identity_mismatch'; +export type RedeemGitHubSessionCapabilityResult = + | RedeemGitHubSessionCapabilitySuccess + | { success: false; reason: RedeemGitHubSessionCapabilityFailureReason }; + +export type IssueGitLabSessionCapabilityParams = GetGitLabTokenParams & { + gitUrl: string; + outboundContainerId?: string; +}; +export type IssueGitLabSessionCapabilitySuccess = { + success: true; + capability: string; + instanceOrigin: string; + instanceHost: string; + projectPath: string; + integrationId: string; + authType: GitLabAuthType; + identity: GitLabSessionIdentity; + source: GitLabCapabilityCredentialSource; + glabIsOAuth2: boolean; +}; +export type IssueGitLabSessionCapabilityResult = + | IssueGitLabSessionCapabilitySuccess + | GetGitLabTokenFailure + | { success: false; reason: GitLabCloneUrlFailureReason | 'capability_configuration_error' }; +export type RedeemGitLabSessionCapabilityParams = { + capability: string; + outboundContainerId?: string; + requestMethod: string; + requestUrl: string; +}; +export type RedeemGitLabSessionCapabilityFailureReason = + | GitLabSessionCapabilityFailureReason + | 'container_mismatch' + | 'invalid_upstream_url' + | 'upstream_origin_not_allowed' + | 'repository_mismatch' + | 'invalid_upstream_request' + | 'source_unavailable' + | 'identity_mismatch'; +export type RedeemGitLabSessionCapabilityResult = + | { + success: true; + headers: + | { authorization: string; 'PRIVATE-TOKEN'?: never } + | { authorization?: never; 'PRIVATE-TOKEN': string }; + } + | { success: false; reason: RedeemGitLabSessionCapabilityFailureReason }; + const DISCONNECT_PATH = '/internal/github-user-authorizations/disconnect'; type DisconnectEnv = CloudflareEnv & { NEXTAUTH_SECRET: SecretsStoreSecret | string; }; -async function resolveJwtSecret(secret: SecretsStoreSecret | string): Promise { +async function resolveSecret(secret: SecretsStoreSecret | string): Promise { return typeof secret === 'string' ? secret : secret.get(); } +function validateGitHubCapabilityUpstream( + requestMethod: string, + requestUrl: string, + repository: { owner: string; repo: string } +): RedeemGitHubSessionCapabilityFailureReason | null { + let url: URL; + try { + url = new URL(requestUrl); + } catch { + return 'invalid_upstream_url'; + } + if (url.protocol !== 'https:') return 'invalid_upstream_url'; + if (url.username || url.password) return 'invalid_upstream_url'; + const method = requestMethod.toUpperCase(); + if (url.hostname === 'api.github.com' && url.port === '') { + return ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'].includes(method) + ? null + : 'invalid_upstream_request'; + } + if (url.hostname !== 'github.com' || url.port !== '') return 'upstream_host_not_allowed'; + + const repositoryPath = `/${repository.owner}/${repository.repo}.git`; + const path = url.pathname.toLowerCase(); + if (!path.startsWith(`/${repository.owner}/${repository.repo}`)) return 'repository_mismatch'; + + if (method === 'GET' && path === `${repositoryPath}/info/refs`) { + const entries = [...url.searchParams.entries()]; + const service = url.searchParams.get('service'); + if (entries.length === 1 && (service === 'git-upload-pack' || service === 'git-receive-pack')) { + return null; + } + } + if ( + method === 'POST' && + url.search === '' && + (path === `${repositoryPath}/git-upload-pack` || + path === `${repositoryPath}/git-receive-pack` || + path === `${repositoryPath}/info/lfs/objects/batch` || + path === `${repositoryPath}/info/lfs/locks/verify`) + ) { + return null; + } + return 'invalid_upstream_request'; +} + +function validateGitLabCapabilityUpstream( + requestMethod: string, + requestUrl: string, + session: { instanceOrigin: string; projectPath: string } +): { failure: RedeemGitLabSessionCapabilityFailureReason | null; authSurface: 'git' | 'api' } { + if (/%2f|%5c/i.test(requestUrl) || /\/(?:(?:\.|%2e){1,2})(?:\/|$)/i.test(requestUrl)) { + return { failure: 'invalid_upstream_url', authSurface: 'git' }; + } + let url: URL; + try { + url = new URL(requestUrl); + } catch { + return { failure: 'invalid_upstream_url', authSurface: 'git' }; + } + if ( + url.protocol !== 'https:' || + url.username || + url.password || + url.port !== '' || + url.hash || + url.origin !== session.instanceOrigin + ) { + return { + failure: + url.origin !== session.instanceOrigin + ? 'upstream_origin_not_allowed' + : 'invalid_upstream_url', + authSurface: 'git', + }; + } + const method = requestMethod.toUpperCase(); + if (url.pathname === '/api/graphql' || url.pathname.startsWith('/api/v4/')) { + return { + failure: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'].includes(method) + ? null + : 'invalid_upstream_request', + authSurface: 'api', + }; + } + + const repositoryPath = `/${session.projectPath}.git`; + if (method === 'GET' && url.pathname === `${repositoryPath}/info/refs`) { + const entries = [...url.searchParams.entries()]; + const service = url.searchParams.get('service'); + if (entries.length === 1 && (service === 'git-upload-pack' || service === 'git-receive-pack')) { + return { failure: null, authSurface: 'git' }; + } + } + if ( + method === 'POST' && + url.search === '' && + (url.pathname === `${repositoryPath}/git-upload-pack` || + url.pathname === `${repositoryPath}/git-receive-pack` || + url.pathname === `${repositoryPath}/info/lfs/objects/batch` || + url.pathname === `${repositoryPath}/info/lfs/locks/verify`) + ) { + return { failure: null, authSurface: 'git' }; + } + const repositoryPrefix = `/${session.projectPath}`; + return { + failure: + url.pathname.startsWith(repositoryPrefix) || !url.pathname.includes('.git/') + ? 'invalid_upstream_request' + : 'repository_mismatch', + authSurface: 'git', + }; +} + export class GitTokenRPCEntrypoint extends WorkerEntrypoint { private githubService: GitHubTokenService; private installationLookupService: InstallationLookupService; @@ -252,6 +467,148 @@ export class GitTokenRPCEntrypoint extends WorkerEntrypoint { }; } + async issueGitHubSessionCapability( + params: IssueGitHubSessionCapabilityParams + ): Promise { + const repository = normalizeGitHubRepository(params.githubRepo); + if (!repository) return { success: false, reason: 'invalid_repo_format' }; + + const auth = await this.getCloudAgentAuthForRepo({ + ...params, + githubRepo: `${repository.owner}/${repository.repo}`, + }); + if (!auth.success) return auth; + + let capability: string; + try { + const encryptionKey = await resolveSecret(this.env.SCM_SESSION_CAPABILITY_ENCRYPTION_KEY); + capability = new GitHubSessionCapabilityCodec(encryptionKey).issue({ + userId: params.userId, + ...(params.outboundContainerId !== undefined + ? { outboundContainerId: params.outboundContainerId } + : {}), + ...(params.orgId !== undefined ? { orgId: params.orgId } : {}), + ...repository, + source: auth.source, + identity: this.getSessionIdentity(auth), + }); + } catch { + return { success: false, reason: 'capability_configuration_error' }; + } + return { + success: true, + capability, + installationId: auth.installationId, + accountLogin: auth.accountLogin, + appType: auth.appType, + source: auth.source, + gitAuthor: auth.gitAuthor, + ...(auth.commitCoAuthor !== undefined ? { commitCoAuthor: auth.commitCoAuthor } : {}), + ...(auth.fallbackReason !== undefined ? { fallbackReason: auth.fallbackReason } : {}), + }; + } + + async redeemGitHubSessionCapability( + params: RedeemGitHubSessionCapabilityParams + ): Promise { + let claims; + try { + const encryptionKey = await resolveSecret(this.env.SCM_SESSION_CAPABILITY_ENCRYPTION_KEY); + claims = new GitHubSessionCapabilityCodec(encryptionKey).decode(params.capability); + } catch (error) { + if (error instanceof GitHubSessionCapabilityError) { + return { success: false, reason: error.reason }; + } + return { success: false, reason: 'capability_configuration_error' }; + } + + if (claims.version === 2 && claims.outboundContainerId !== params.outboundContainerId) { + return { success: false, reason: 'container_mismatch' }; + } + + const upstreamFailure = validateGitHubCapabilityUpstream( + params.requestMethod, + params.requestUrl, + claims + ); + if (upstreamFailure) return { success: false, reason: upstreamFailure }; + + const authParams = { + userId: claims.userId, + ...(claims.orgId !== undefined ? { orgId: claims.orgId } : {}), + githubRepo: `${claims.owner}/${claims.repo}`, + }; + let auth: GetCloudAgentAuthForRepoResult | null; + if (claims.source === 'user') { + auth = await this.redeemPinnedUserAuthorization(authParams); + } else { + try { + auth = await this.getCloudAgentAuthForRepo(authParams); + } catch { + return { success: false, reason: 'source_unavailable' }; + } + } + if (!auth || !auth.success || auth.source !== claims.source) { + return { success: false, reason: 'source_unavailable' }; + } + if (!this.matchesSessionIdentity(claims.identity, auth)) { + return { success: false, reason: 'identity_mismatch' }; + } + return { + success: true, + authorization: this.formatUpstreamAuthorization(params.requestUrl, auth.githubToken), + }; + } + + private getSessionIdentity(auth: GetCloudAgentAuthForRepoSuccess): GitHubSessionIdentity { + return { + installationId: auth.installationId, + accountLogin: auth.accountLogin, + appType: auth.appType, + gitAuthor: auth.gitAuthor, + ...(auth.commitCoAuthor !== undefined ? { commitCoAuthor: auth.commitCoAuthor } : {}), + }; + } + + private matchesSessionIdentity( + issuedIdentity: GitHubSessionIdentity, + auth: GetCloudAgentAuthForRepoSuccess + ): boolean { + return JSON.stringify(issuedIdentity) === JSON.stringify(this.getSessionIdentity(auth)); + } + + private formatUpstreamAuthorization(requestUrl: string, token: string): string { + return new URL(requestUrl).hostname === 'github.com' + ? `Basic ${Buffer.from(`x-access-token:${token}`).toString('base64')}` + : `Bearer ${token}`; + } + + private async redeemPinnedUserAuthorization( + params: GetTokenForRepoParams + ): Promise { + const installation = + await this.installationLookupService.findManagedInstallationForRepo(params); + if (!installation.success || installation.githubAppType === 'lite') return null; + if ( + installation.permissions?.contents !== 'write' || + installation.permissions?.pull_requests !== 'write' + ) { + return null; + } + const selection = await this.githubUserAuthorizationService.selectUserAuthorization(params); + if (!selection.selected) return null; + return { + success: true, + githubToken: selection.token, + installationId: installation.installationId, + accountLogin: installation.accountLogin, + appType: installation.githubAppType, + source: 'user', + gitAuthor: selection.gitAuthor, + commitCoAuthor: this.getInstallationAuthor(installation.githubAppType), + }; + } + private getInstallationAuthor(appType: GitHubAppType): GitAuthorConfig { const slug = appType === 'lite' @@ -295,6 +652,157 @@ export class GitTokenRPCEntrypoint extends WorkerEntrypoint { tokenService: this.gitlabTokenService, }); } + + async issueGitLabSessionCapability( + params: IssueGitLabSessionCapabilityParams + ): Promise { + const runtimeToken = await resolveGitLabRuntimeToken( + { ...params, repositoryUrl: params.gitUrl }, + { + lookupService: this.gitlabLookupService, + tokenService: this.gitlabTokenService, + } + ); + if (!runtimeToken.success) return runtimeToken; + + const integration = await this.gitlabLookupService.findGitLabIntegration( + params, + runtimeToken.integrationId + ); + if (!integration.success) return integration; + const authType = this.getGitLabAuthType(integration); + if (!authType) return { success: false, reason: 'no_token' }; + const instanceOrigin = normalizeGitLabInstanceOrigin(runtimeToken.instanceUrl); + if (!instanceOrigin) return { success: false, reason: 'unsupported_gitlab_instance' }; + const repository = parseGitLabCloneUrl(params.gitUrl, instanceOrigin); + if (!repository.success) return repository; + const identity = this.getGitLabSessionIdentity(integration); + if (!identity) return { success: false, reason: 'no_token' }; + + let capability: string; + try { + const encryptionKey = await resolveSecret(this.env.SCM_SESSION_CAPABILITY_ENCRYPTION_KEY); + capability = new GitLabSessionCapabilityCodec(encryptionKey).issue({ + userId: params.userId, + ...(params.outboundContainerId !== undefined + ? { outboundContainerId: params.outboundContainerId } + : {}), + ...(params.orgId !== undefined ? { orgId: params.orgId } : {}), + integrationId: integration.integrationId, + instanceOrigin: repository.instanceOrigin, + projectPath: repository.projectPath, + authType, + identity, + source: runtimeToken.source, + }); + } catch { + return { success: false, reason: 'capability_configuration_error' }; + } + return { + success: true, + capability, + instanceOrigin: repository.instanceOrigin, + instanceHost: repository.instanceHost, + projectPath: repository.projectPath, + integrationId: integration.integrationId, + authType, + identity, + source: runtimeToken.source, + glabIsOAuth2: runtimeToken.glabIsOAuth2, + }; + } + + async redeemGitLabSessionCapability( + params: RedeemGitLabSessionCapabilityParams + ): Promise { + let claims; + try { + const encryptionKey = await resolveSecret(this.env.SCM_SESSION_CAPABILITY_ENCRYPTION_KEY); + claims = new GitLabSessionCapabilityCodec(encryptionKey).decode(params.capability); + } catch (error) { + if (error instanceof GitLabSessionCapabilityError) { + return { success: false, reason: error.reason }; + } + return { success: false, reason: 'capability_configuration_error' }; + } + + if (claims.version === 2 && claims.outboundContainerId !== params.outboundContainerId) { + return { success: false, reason: 'container_mismatch' }; + } + + const upstream = validateGitLabCapabilityUpstream( + params.requestMethod, + params.requestUrl, + claims + ); + if (upstream.failure) return { success: false, reason: upstream.failure }; + const context = { + userId: claims.userId, + ...(claims.orgId !== undefined ? { orgId: claims.orgId } : {}), + }; + const integration = await this.gitlabLookupService.findGitLabIntegration( + context, + claims.integrationId + ); + if (!integration.success) return { success: false, reason: 'source_unavailable' }; + const authType = this.getGitLabAuthType(integration); + const identity = this.getGitLabSessionIdentity(integration); + if ( + authType !== claims.authType || + !identity || + JSON.stringify(identity) !== JSON.stringify(claims.identity) + ) { + return { success: false, reason: 'identity_mismatch' }; + } + const currentInstanceOrigin = normalizeGitLabInstanceOrigin( + integration.metadata.gitlab_instance_url ?? 'https://gitlab.com' + ); + if (currentInstanceOrigin !== claims.instanceOrigin) { + return { success: false, reason: 'identity_mismatch' }; + } + + let token: string; + if (claims.source.type === 'integration') { + const integrationToken = await this.gitlabTokenService.getToken( + integration.integrationId, + integration.metadata + ); + if (!integrationToken.success) return { success: false, reason: 'source_unavailable' }; + token = integrationToken.token; + } else { + const projectToken = integration.metadata.project_tokens?.[String(claims.source.projectId)]; + if (!projectToken) return { success: false, reason: 'source_unavailable' }; + const currentTokenDigest = await sha256Digest(projectToken.token); + if (!timingSafeEqual(currentTokenDigest, claims.source.tokenDigest)) { + return { success: false, reason: 'source_unavailable' }; + } + token = projectToken.token; + } + + if (upstream.authSurface === 'git') { + return { + success: true, + headers: { authorization: `Basic ${Buffer.from(`oauth2:${token}`).toString('base64')}` }, + }; + } + if (claims.source.type === 'project') { + return { success: true, headers: { 'PRIVATE-TOKEN': token } }; + } + return { success: true, headers: { authorization: `Bearer ${token}` } }; + } + + private getGitLabAuthType(integration: GitLabLookupSuccess): GitLabAuthType | null { + if (integration.metadata.auth_type) return integration.metadata.auth_type; + if (integration.integrationType === 'oauth' || integration.integrationType === 'pat') { + return integration.integrationType; + } + return null; + } + + private getGitLabSessionIdentity(integration: GitLabLookupSuccess): GitLabSessionIdentity | null { + if (integration.accountId === null && integration.accountLogin === null) return null; + return { accountId: integration.accountId, accountLogin: integration.accountLogin }; + } } export default { @@ -308,7 +816,7 @@ export default { let secret: string; try { - secret = await resolveJwtSecret(env.NEXTAUTH_SECRET); + secret = await resolveSecret(env.NEXTAUTH_SECRET); } catch { return Response.json({ error: 'authentication_unavailable' }, { status: 503 }); } diff --git a/services/git-token-service/worker-configuration.d.ts b/services/git-token-service/worker-configuration.d.ts index c0740b945a..e896f59041 100644 --- a/services/git-token-service/worker-configuration.d.ts +++ b/services/git-token-service/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types --env-interface CloudflareEnv worker-configuration.d.ts` (hash: 17f2708d42b72c79fdef7a53b8c646bf) +// Generated by Wrangler by running `wrangler types --env-interface CloudflareEnv worker-configuration.d.ts` (hash: 7b326c638697d3d959ac21c69136b7f6) // Runtime types generated with workerd@1.20260508.1 2025-09-15 nodejs_compat declare namespace Cloudflare { interface GlobalProps { @@ -9,6 +9,7 @@ declare namespace Cloudflare { TOKEN_CACHE: KVNamespace; HYPERDRIVE: Hyperdrive; NEXTAUTH_SECRET: SecretsStoreSecret; + SCM_SESSION_CAPABILITY_ENCRYPTION_KEY: SecretsStoreSecret; GITHUB_APP_SLUG: "kiloconnect-development"; GITHUB_APP_BOT_USER_ID: "242397087"; GITHUB_LITE_APP_SLUG: ""; @@ -17,19 +18,12 @@ declare namespace Cloudflare { GITHUB_APP_PRIVATE_KEY: string; GITHUB_LITE_APP_ID: string; GITHUB_LITE_APP_PRIVATE_KEY: string; - GITLAB_CLIENT_ID: string; - GITLAB_CLIENT_SECRET: string; - GITHUB_APP_CLIENT_ID: string; - GITHUB_APP_CLIENT_SECRET: string; - USER_GITHUB_APP_TOKEN_ACTIVE_KEY_ID: string; - USER_GITHUB_APP_TOKEN_ACTIVE_PUBLIC_KEY: string; - USER_GITHUB_APP_TOKEN_ACTIVE_PRIVATE_KEY: string; - NEXTAUTH_SECRET: string; } interface Env { TOKEN_CACHE: KVNamespace; HYPERDRIVE: Hyperdrive; - NEXTAUTH_SECRET: SecretsStoreSecret | string; + NEXTAUTH_SECRET: SecretsStoreSecret; + SCM_SESSION_CAPABILITY_ENCRYPTION_KEY: SecretsStoreSecret; GITHUB_APP_SLUG: "kiloconnect-development" | "kiloconnect"; GITHUB_APP_BOT_USER_ID: "242397087" | "240665456"; GITHUB_LITE_APP_SLUG: "" | "kiloconnect-lite"; @@ -38,13 +32,6 @@ declare namespace Cloudflare { GITHUB_APP_PRIVATE_KEY: string; GITHUB_LITE_APP_ID: string; GITHUB_LITE_APP_PRIVATE_KEY: string; - GITLAB_CLIENT_ID: string; - GITLAB_CLIENT_SECRET: string; - GITHUB_APP_CLIENT_ID: string; - GITHUB_APP_CLIENT_SECRET: string; - USER_GITHUB_APP_TOKEN_ACTIVE_KEY_ID: string; - USER_GITHUB_APP_TOKEN_ACTIVE_PUBLIC_KEY: string; - USER_GITHUB_APP_TOKEN_ACTIVE_PRIVATE_KEY: string; } } interface CloudflareEnv extends Cloudflare.Env {} @@ -52,7 +39,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> {} } // Begin runtime types diff --git a/services/git-token-service/wrangler.jsonc b/services/git-token-service/wrangler.jsonc index 40c87e5176..2a4cef6e44 100644 --- a/services/git-token-service/wrangler.jsonc +++ b/services/git-token-service/wrangler.jsonc @@ -37,6 +37,11 @@ "store_id": "342a86d9e3a94da698e82d0c6e2a36f0", "secret_name": "NEXTAUTH_SECRET_PROD", }, + { + "binding": "SCM_SESSION_CAPABILITY_ENCRYPTION_KEY", + "store_id": "342a86d9e3a94da698e82d0c6e2a36f0", + "secret_name": "SCM_SESSION_CAPABILITY_ENCRYPTION_KEY_PROD", + }, ], "dev": { "port": 8802, @@ -69,6 +74,11 @@ "store_id": "342a86d9e3a94da698e82d0c6e2a36f0", "secret_name": "NEXTAUTH_SECRET_DEV", }, + { + "binding": "SCM_SESSION_CAPABILITY_ENCRYPTION_KEY", + "store_id": "342a86d9e3a94da698e82d0c6e2a36f0", + "secret_name": "SCM_SESSION_CAPABILITY_ENCRYPTION_KEY_DEV", + }, ], }, },