From 8bcff5cdb736586f611e756c441d7e9c93c33dcc Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Mon, 1 Jun 2026 16:23:05 +0200 Subject: [PATCH 1/2] feat(cloud-agent-next): contain managed SCM credentials --- .plans/cloud-agent-commit-as-user.md | 102 ++ services/cloud-agent-next/src/index.ts | 2 +- .../src/kilo/devcontainer.test.ts | 75 +- .../cloud-agent-next/src/kilo/devcontainer.ts | 55 +- .../src/sandbox-outbound.test.ts | 612 +++++++++ .../cloud-agent-next/src/sandbox-outbound.ts | 217 ++++ .../services/git-token-service-client.test.ts | 165 +++ .../src/services/git-token-service-client.ts | 131 ++ .../src/session-service.test.ts | 659 ++++++++-- .../cloud-agent-next/src/session-service.ts | 53 +- services/cloud-agent-next/src/types.ts | 146 ++- .../e2e/outbound-git-rewrite-dind-probe.ts | 318 +++++ ...write-dind-probe.worker-configuration.d.ts | 14 + .../outbound-git-rewrite-dind-probe.worker.ts | 237 ++++ .../test/e2e/outbound-git-rewrite-probe.ts | 250 ++++ ...it-rewrite-probe.worker-configuration.d.ts | 14 + .../e2e/outbound-git-rewrite-probe.worker.ts | 149 +++ .../worker-configuration.d.ts | 12 +- services/cloud-agent-next/wrangler.jsonc | 2 +- ...gler.outbound-git-rewrite-dind-probe.jsonc | 39 + .../wrangler.outbound-git-rewrite-probe.jsonc | 39 + services/git-token-service/.dev.vars.example | 2 + .../src/github-session-capability.test.ts | 113 ++ .../src/github-session-capability.ts | 158 +++ .../src/gitlab-lookup-service.test.ts | 3 + .../src/gitlab-lookup-service.ts | 68 +- .../src/gitlab-runtime-token-resolver.ts | 24 +- .../src/gitlab-session-capability.test.ts | 127 ++ .../src/gitlab-session-capability.ts | 227 ++++ .../src/gitlab-token-service.test.ts | 89 ++ .../src/gitlab-token-service.ts | 39 +- services/git-token-service/src/index.test.ts | 1149 +++++++++++++++-- services/git-token-service/src/index.ts | 493 ++++++- .../worker-configuration.d.ts | 23 +- services/git-token-service/wrangler.jsonc | 10 + 35 files changed, 5469 insertions(+), 347 deletions(-) create mode 100644 .plans/cloud-agent-commit-as-user.md create mode 100644 services/cloud-agent-next/src/sandbox-outbound.test.ts create mode 100644 services/cloud-agent-next/src/sandbox-outbound.ts create mode 100644 services/cloud-agent-next/test/e2e/outbound-git-rewrite-dind-probe.ts create mode 100644 services/cloud-agent-next/test/e2e/outbound-git-rewrite-dind-probe.worker-configuration.d.ts create mode 100644 services/cloud-agent-next/test/e2e/outbound-git-rewrite-dind-probe.worker.ts create mode 100644 services/cloud-agent-next/test/e2e/outbound-git-rewrite-probe.ts create mode 100644 services/cloud-agent-next/test/e2e/outbound-git-rewrite-probe.worker-configuration.d.ts create mode 100644 services/cloud-agent-next/test/e2e/outbound-git-rewrite-probe.worker.ts create mode 100644 services/cloud-agent-next/wrangler.outbound-git-rewrite-dind-probe.jsonc create mode 100644 services/cloud-agent-next/wrangler.outbound-git-rewrite-probe.jsonc create mode 100644 services/git-token-service/src/github-session-capability.test.ts create mode 100644 services/git-token-service/src/github-session-capability.ts create mode 100644 services/git-token-service/src/gitlab-session-capability.test.ts create mode 100644 services/git-token-service/src/gitlab-session-capability.ts create mode 100644 services/git-token-service/src/gitlab-token-service.test.ts diff --git a/.plans/cloud-agent-commit-as-user.md b/.plans/cloud-agent-commit-as-user.md new file mode 100644 index 0000000000..56443663f0 --- /dev/null +++ b/.plans/cloud-agent-commit-as-user.md @@ -0,0 +1,102 @@ +# Cloud Agent SCM credentials: catch-all outbound walking skeleton + +## Goal + +Deliver a reviewed managed-SCM containment walking skeleton for Cloud Agent sandboxes: use one catch-all outbound handler, preserve managed GitHub support with default-HTTPS LFS repository-control validation, add GitLab HTTPS support without host preregistration, and enable DIND only after proving nested routing and propagation of Cloudflare's private runtime HTTPS-interception CA/trusted bundle. + +## Current State + +- The walking skeleton is implemented, independently reviewed task by task, fixed where required, and locally validated. +- The walking skeleton is committed as `ab4fe320e`. The standalone `services/git-session-proxy` foundation is parked on the `quilled-meteoroid` worktree for possible later relay hardening; this active branch removes it and relies on Cloudflare outbound HTTP(S) interception rather than proxy-service wiring. +- Deployment order is mandatory: provision the SCM capability secret, deploy `git-token-service`, then deploy `cloud-agent-next`. + +## Implemented Architecture + +Eligible sandboxes use one HTTP(S) boundary: + +```ts +Sandbox.outbound = handleManagedScmOutbound; +``` + +| Request class | Implemented behavior | +|---|---| +| Unmatched request | Pass through unchanged. | +| Recognized Kilo capability carrier | Redeem server-side; fail closed when invalid, including malformed whitespace/tab carrier cases. | +| Redeemed managed request | Replace sandbox-visible capability auth with redeemed provider auth outside the sandbox. | +| Redirect from redeemed request | Follow manually so managed auth is forwarded only after target validation. | +| Cross-provider or unsupported recognized carrier | Fail closed rather than falling back to raw forwarding. | + +Provider-issued signed LFS action URLs and headers intentionally remain visible to the sandbox in this skeleton. + +## Implemented Provider Coverage + +| Surface | GitHub | GitLab | +|---|---|---| +| Capability | `kgh1.` marker with one-hour encrypted claims. | `kgl1.` marker with one-hour encrypted claims, separate GitLab purpose, and the shared encryption secret. | +| Origin | Existing GitHub origins. | `gitlab.com` and active self-managed standard HTTPS integration origins on port `443`. | +| Repository path | Exact GitHub repository validation. | Exact nested namespace project validation, for example `group/subgroup/project`. | +| Git smart HTTP | Repository-bound. | Repository-bound. | +| LFS control | Repository-bound `POST .../.git/info/lfs/objects/batch` and `POST .../.git/info/lfs/locks/verify`. | Repository-bound batch and lock verification. | +| CLI API | Existing broad `api.github.com` compatibility for `gh`. | Broad `/api/v4/**` and `/api/graphql` compatibility for `glab`. | +| Managed auth rewrite | Redeemed GitHub auth. | Basic, Bearer, and `PRIVATE-TOKEN` rewriting for managed OAuth/PAT auth. | +| Explicit profile token | Outside managed containment. | Pass through unchanged as intentional user-controlled auth. | + +GitLab integration handling uses sanitized refresh logging and per-use database clients. Eligible GitLab session preparation emits a canonical `.git` remote URL, trusted `GITLAB_HOST`, and a capability-backed `GITLAB_TOKEN`; raw managed-auth fallback has been removed. + +## Capability Marker Decision + +| Provider | Capability marker | +|---|---| +| GitHub | `kgh1.` | +| GitLab | `kgl1.` | + +The short prefix routes the provider codec, fails closed for unsupported formats, and versions the marker. It is not the security boundary: authenticated AES-GCM claims remain authoritative. The `kgh1.` / `kgl1.` rollout intentionally invalidates previously issued verbose-marker capabilities. Coordinate rollout in the required order - provision the SCM capability secret, deploy `git-token-service`, then deploy `cloud-agent-next` - or accept up to one hour of transient failures for in-flight old capabilities. + +Fresh capabilities are issued on every dispatched message or command. Remotes and environment are refreshed before prompt delivery, so timer refresh is unnecessary for the skeleton. Only autonomous turns or terminal usage extending beyond one hour remain edge cases. + +## DIND Result + +The nested-DIND real-Git rewrite probe proved that `--network=host` supplies routing to the catch-all boundary and that nested devcontainers require propagation of Cloudflare's private runtime HTTPS-interception CA/trusted bundle. + +`SandboxDIND` catch-all interception is enabled. Managed GitHub and GitLab DIND preparation/wrapper paths use capabilities. Devcontainer setup copies the outer trusted CA bundle to a stable session-home path and injects trust environment variables. This does not imply that provider certificates are the production issue or that the runtime interception certificate is necessarily self-signed. The local missing-bundle negative control empirically returned a TLS rejection matching `server certificate verification failed|SSL certificate problem|certificate verify failed|self-signed certificate in certificate chain`; preserve that as observed probe output rather than a production certificate diagnosis. Probes clean up invocation artifacts. + +## Completion Record + +| Gate | Status | Evidence | +|---|---|---| +| Task 1: catch-all and GitHub LFS | Complete, reviewed, fixed | Catch-all `Sandbox.outbound`; GitHub LFS batch and lock verification; fail-closed recognized carriers including whitespace/tab; signed actions remain sandbox-visible. | +| Task 2: GitLab token service | Complete, reviewed | One-hour GitLab codec/issue/redeem; active self-managed HTTPS origin; nested namespaces; Git/LFS repo control; broad `glab` REST/GraphQL; sanitized refresh logging; per-use DB clients. | +| Task 3: Cloud Agent GitLab | Complete, reviewed, fixed | Capability-backed canonical `.git` remotes, `GITLAB_TOKEN`, trusted `GITLAB_HOST`; Basic/Bearer/`PRIVATE-TOKEN` rewrites; no raw fallback; cross-provider and unsupported recognized carriers fail closed. | +| Capability markers | Approved | GitHub `kgh1.`; GitLab `kgl1.`; short routing/fail-closed/version prefix only; AES-GCM claims remain authoritative; dispatch refresh makes one-hour expiry a long-running edge case. | +| Task 4/4b: DIND | Complete, reviewed | Probe proved host-network routing and nested propagation of Cloudflare's private runtime HTTPS-interception CA/trusted bundle; `SandboxDIND` catch-all enabled; GitHub/GitLab DIND paths use capabilities; devcontainer trust injection and probe cleanup implemented. | +| Final validation | Complete | Token service `102` tests; Cloud Agent `1545` passed, `3` skipped; changed-package typecheck `10/53`; both probes passed; whitespace clean; no review blockers remain. | + +## Validation Caveat + +Full Cloud Agent wrapper validation encountered an unchanged committed baseline timing-sensitive flake in `wrapper/src/lifecycle.test.ts`: `clears aborted state when activity cancels an aborted drain`. Its fixed `50 ms` wait races a real branch subprocess. Marker-focused and package checks pass. Track wrapper test stabilization separately rather than bundling it into the SCM diff. + +## Containment Claims + +| Path | Skeleton claim | +|---|---| +| Managed GitHub, eligible sandbox including DIND | Contained for recognized capability-bearing smart HTTP, broad `gh` API, and repository-bound LFS control requests. | +| Managed GitLab OAuth/PAT, eligible sandbox including DIND | Contained for recognized capability-bearing smart HTTP, broad `glab` API, and repository-bound LFS control requests on claimed standard HTTPS origins. | +| Provider-issued signed LFS actions | Not contained; action URLs and provider headers remain sandbox-visible. | +| Explicit profile tokens | Not contained; intentional pass-through. | + +## Follow-up Discussions + +These are explicit follow-ups, not blockers for this walking skeleton: + +| Area | Follow-up | +|---|---| +| Provider-signed LFS actions | Use the standalone relay parked on `quilled-meteoroid` later if a stronger boundary is required. | +| Self-managed GitLab origins | Add SSRF hardening and admin allowlisting for active-integration approval. | +| GitLab instance shape | Discuss nonstandard ports and subpath-hosted instances. | +| GitLab token semantics | Resolve project-access-token semantics discrepancy. | +| GitLab OAuth | Add refresh concurrency handling and provision default GitLab OAuth client environment values. | +| Capability continuity | Add refresh within long-running autonomous turns or terminal sessions that outlive the one-hour capability lifetime; dispatched messages and commands already refresh remotes and environment before prompt delivery. | +| Wrapper test stability | Stabilize the unchanged baseline timing-sensitive lifecycle test separately from the SCM diff. | +| Capability carriers | Harden query/body carrier handling. | +| Nested trust | Cover propagation of Cloudflare's private runtime HTTPS-interception CA/trusted bundle into Dockerfile build stages, unusual custom images/trust stores, CA rotation, and non-host nested networks. | +| Cleanup behavior | Cover abrupt cleanup. The stale `dev/local` standalone proxy WIP is isolated on `quilled-meteoroid`. | 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/sandbox-outbound.test.ts b/services/cloud-agent-next/src/sandbox-outbound.test.ts new file mode 100644 index 0000000000..e6ac8b0f59 --- /dev/null +++ b/services/cloud-agent-next/src/sandbox-outbound.test.ts @@ -0,0 +1,612 @@ +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 = 'kgh1.opaque'; +const GITLAB_CAPABILITY = 'kgl1.opaque'; +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; +} + +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 handleManagedScmOutbound(request, createEnv(redeemGitHubSessionCapability)); + + expect(redeemGitHubSessionCapability).toHaveBeenCalledWith({ + capability: CAPABILITY, + 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 handleManagedScmOutbound( + 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, + 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 handleManagedScmOutbound( + 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 handleManagedScmOutbound( + 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 handleManagedScmOutbound( + 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, + 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 handleManagedScmOutbound(request, createEnv(redeemGitHubSessionCapability)); + + expect(redeemGitHubSessionCapability).not.toHaveBeenCalled(); + expect(forward).toHaveBeenCalledWith(request); + }); + + 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 handleManagedScmOutbound( + 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 handleManagedScmOutbound( + 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 handleManagedScmOutbound( + new Request('https://example.com/resource', { + headers: { Authorization: `Bearer ${CAPABILITY}` }, + }), + createEnv(redeemGitHubSessionCapability) + ); + + expect(redeemGitHubSessionCapability).toHaveBeenCalledWith({ + capability: CAPABILITY, + 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 handleManagedScmOutbound( + new Request('https://example.com/resource', { headers: { Authorization: authorization } }), + createEnv(redeemGitHubSessionCapability) + ); + + expect(redeemGitHubSessionCapability).toHaveBeenCalledWith({ + capability: CAPABILITY, + 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 handleManagedScmOutbound( + request(), + createEnv(vi.fn().mockResolvedValue({ success: false, reason: 'expired_capability' })) + ); + const thrown = await handleManagedScmOutbound( + 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 handleManagedScmOutbound( + 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, + requestMethod: 'GET', + requestUrl: urls[0], + }); + expect(redeemGitLabSessionCapability).toHaveBeenNthCalledWith(2, { + capability: GITLAB_CAPABILITY, + 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 handleManagedScmOutbound( + 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, + 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 handleManagedScmOutbound( + 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, + 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 handleManagedScmOutbound( + 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 handleManagedScmOutbound( + 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 handleManagedScmOutbound( + 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 handleManagedScmOutbound( + request(), + createEnv( + vi.fn(), + vi.fn().mockResolvedValue({ success: false, reason: 'invalid_capability' }) + ) + ); + const thrown = await handleManagedScmOutbound( + 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 handleManagedScmOutbound(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 handleManagedScmOutbound( + 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, + 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 handleManagedScmOutbound( + 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 handleManagedScmOutbound( + 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..6a07093fc2 --- /dev/null +++ b/services/cloud-agent-next/src/sandbox-outbound.ts @@ -0,0 +1,217 @@ +import { Buffer } from 'node:buffer'; +import { ContainerProxy, Sandbox as StockSandbox } from '@cloudflare/sandbox'; +import type { GitTokenService } from './types.js'; + +const GITHUB_CAPABILITY_PREFIX = 'kgh1.'; +const GITLAB_CAPABILITY_PREFIX = 'kgl1.'; + +type GitHubTokenRedemptionBinding = Pick; +type GitLabTokenRedemptionBinding = Pick; +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 (capability.startsWith(GITHUB_CAPABILITY_PREFIX)) return { provider: 'github', capability }; + if (capability.startsWith(GITLAB_CAPABILITY_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 } +): 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, + 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 } +): 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, + 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): 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) + : handleManagedGitLabOutbound(request, env, capability); +} + +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..469929df78 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,164 @@ describe('resolveManagedGitLabToken', () => { }); }); +describe('issueCloudAgentGitHubSessionCapability', () => { + it('returns an opaque capability and preserves managed identity metadata', async () => { + const issueGitHubSessionCapability = vi.fn().mockResolvedValue({ + success: true, + capability: 'kgh1.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', allowUserAuthorization: true } + ); + + expect(issueGitHubSessionCapability).toHaveBeenCalledWith({ + githubRepo: 'acme/repo', + userId: 'user_1', + allowUserAuthorization: true, + }); + expect(getCloudAgentAuthForRepo).not.toHaveBeenCalled(); + expect(getTokenForRepo).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + success: true, + value: { + capability: 'kgh1.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', 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', 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: 'kgl1.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', + createdOnPlatform: 'code-review', + } + ); + + expect(issueGitLabSessionCapability).toHaveBeenCalledWith({ + gitUrl: 'https://gitlab.example.com/acme/platform/repo.git', + userId: 'user_1', + createdOnPlatform: 'code-review', + }); + expect(getGitLabToken).not.toHaveBeenCalled(); + expect(result).toEqual({ + success: true, + value: { + capability: 'kgl1.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' } + ); + + 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' } + ); + + 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({ 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..67a586c605 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,130 @@ export async function resolveCloudAgentGitHubAuthForRepo( } } +export async function issueCloudAgentGitHubSessionCapability( + env: GitTokenServiceEnv, + params: { + githubRepo: string; + userId: 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; 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..95ec4a703b 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(), })); @@ -198,12 +200,24 @@ function createEnv(metadata?: CloudAgentSessionState | null): PersistenceEnv { source: 'installation', gitAuthor: { name: 'kiloconnect[bot]', email: 'bot@example.com' }, }), + issueGitHubSessionCapability: vi.fn().mockResolvedValue({ + success: true, + capability: 'kgh1.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 +286,30 @@ describe('SessionService.prepareWorkspace', () => { gitAuthor: { name: 'kiloconnect[bot]', email: 'bot@example.com' }, }, }); + tokenMocks.issueCloudAgentGitHubSessionCapability.mockResolvedValue({ + success: true, + value: { + capability: 'kgh1.default', + installationId: '123', + accountLogin: 'acme', + appType: 'standard', + source: 'installation', + gitAuthor: { name: 'kiloconnect[bot]', email: 'bot@example.com' }, + }, + }); + tokenMocks.issueCloudAgentGitLabSessionCapability.mockResolvedValue({ + success: true, + value: { + capability: 'kgl1.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 +348,12 @@ describe('SessionService.prepareWorkspace', () => { session, '/workspace/user/sessions/agent_test', 'https://gitlab.com/acme/repo.git', - 'resolved-gitlab-token', + 'kgl1.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 +368,7 @@ describe('SessionService.prepareWorkspace', () => { sessionHome: '/home/agent_test', branchName: 'main', kiloSessionId: 'kilo-session', - gitToken: 'resolved-gitlab-token', + gitToken: 'kgl1.default', gitlabTokenManaged: true, }); }); @@ -594,7 +634,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,7 +660,7 @@ describe('SessionService.prepareWorkspace', () => { }); expect(workspaceMocks.cloneGitHubRepo).not.toHaveBeenCalled(); - expect(tokenMocks.resolveCloudAgentGitHubAuthForRepo).toHaveBeenCalledWith( + expect(tokenMocks.issueCloudAgentGitHubSessionCapability).toHaveBeenCalledWith( expect.objectContaining({ GIT_TOKEN_SERVICE: expect.any(Object), }), @@ -631,11 +671,12 @@ describe('SessionService.prepareWorkspace', () => { 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' + 'kgh1.default' ); }); @@ -776,7 +817,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 +826,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 +852,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' + 'kgh1.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 +887,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', + 'kgl1.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: 'kgl1.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 +926,26 @@ 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', + 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', + 'kgl1.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 +971,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' + 'kgh1.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', + 'kgh1.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 +1105,30 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { gitAuthor: { name: 'kiloconnect[bot]', email: 'bot@example.com' }, }, }); + tokenMocks.issueCloudAgentGitHubSessionCapability.mockResolvedValue({ + success: true, + value: { + capability: 'kgh1.default', + installationId: '123', + accountLogin: 'acme', + appType: 'standard', + source: 'installation', + gitAuthor: { name: 'kiloconnect[bot]', email: 'bot@example.com' }, + }, + }); + tokenMocks.issueCloudAgentGitLabSessionCapability.mockResolvedValue({ + success: true, + value: { + capability: 'kgl1.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 +1166,7 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { model: 'test-model', }, workspace: { - sandboxId: 'usr-abcdef', + sandboxId: metadata.workspace?.sandboxId ?? 'usr-abcdef', metadata, }, wrapper: { @@ -1044,7 +1209,7 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { model: 'test-model', }, workspace: { - sandboxId: 'dind-abcdef', + sandboxId: metadata.workspace?.sandboxId ?? 'usr-abcdef', metadata, }, wrapper: { @@ -1061,6 +1226,165 @@ 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: 'kgl1.default', + }); + expect(result.readyRequest.materialized.env.GITLAB_TOKEN).toBe('kgl1.default'); + expect(JSON.stringify(result.readyRequest)).not.toContain('resolved-gitlab-token'); + }); + + 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: 'kgl1.default', + platform: 'gitlab', + }); + expect(result.readyRequest.materialized.env.GITLAB_TOKEN).toBe('kgl1.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: 'kgl1.default', + }); + expect(result.readyRequest.materialized.env.GITLAB_TOKEN).toBe('kgl1.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: 'kgl1.default', + }); + expect(result.readyRequest.materialized.env.GITLAB_TOKEN).toBe('kgl1.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: 'kgh1.default', + }); + expect(result.readyRequest.materialized.env.GH_TOKEN).toBe('kgh1.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: 'kgh1.default', + }); + expect(result.readyRequest.materialized.env.GH_TOKEN).toBe('kgh1.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 +1438,18 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { sessionHome: '/home/agent_test', branchName: 'main', kiloSessionId: 'kilo-session', - gitToken: 'resolved-gitlab-token', + gitToken: 'kgl1.default', gitlabTokenManaged: true, }); + expect(tokenMocks.issueCloudAgentGitLabSessionCapability).toHaveBeenCalledWith( + expect.any(Object), + { + gitUrl: 'https://gitlab.com/acme/repo.git', + userId: 'user_test', + orgId: undefined, + } + ); + expect(tokenMocks.resolveManagedGitLabToken).not.toHaveBeenCalled(); expect(result.readyRequest).toMatchObject({ agentSessionId: 'agent_test', userId: 'user_test', @@ -1131,7 +1464,7 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { repo: { kind: 'git', url: 'https://gitlab.com/acme/repo.git', - token: 'resolved-gitlab-token', + token: 'kgl1.default', platform: 'gitlab', }, materialized: { @@ -1145,7 +1478,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('kgl1.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 +1574,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: 'kgh1.selected-user', installationId: '123', accountLogin: 'acme', appType: 'standard', @@ -1262,18 +1630,23 @@ 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', + orgId: undefined, + allowUserAuthorization: true, + } + ); + expect(tokenMocks.resolveCloudAgentGitHubAuthForRepo).not.toHaveBeenCalled(); expect(result.readyRequest.repo).toMatchObject({ kind: 'github', - token: 'selected-user-token', + token: 'kgh1.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('kgh1.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 +1664,19 @@ 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', + 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,7 +1688,7 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { }) ); - expect(tokenMocks.resolveCloudAgentGitHubAuthForRepo).toHaveBeenCalledWith( + expect(tokenMocks.issueCloudAgentGitHubSessionCapability).toHaveBeenCalledWith( expect.any(Object), { githubRepo: 'acme/repo', @@ -1324,15 +1700,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: 'kgh1.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 +1721,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: 'kgh1.installation', gitAuthor: { name: 'kiloconnect-development[bot]', email: '242397087+kiloconnect-development[bot]@users.noreply.github.com', @@ -1358,11 +1734,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: 'kgh1.selected-user', installationId: '123', accountLogin: 'acme', appType: 'standard', @@ -1385,29 +1761,71 @@ 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: 'kgl1.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: 'kgl1.self-managed', gitlabTokenManaged: true, }); + expect(tokenMocks.issueCloudAgentGitLabSessionCapability).toHaveBeenCalledWith( + expect.any(Object), + { + gitUrl: 'https://gitlab.example.com:443/acme/platform/repo', + userId: 'user_test', + 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: 'kgl1.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('kgl1.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: 'kgl1.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 +1836,70 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { ); expect(result.ready).toMatchObject({ - gitToken: 'resolved-gitlab-token', + gitToken: 'kgl1.default', gitlabTokenManaged: true, }); expect(result.readyRequest.repo).toMatchObject({ - token: 'resolved-gitlab-token', + token: 'kgl1.default', platform: 'gitlab', }); - expect(result.readyRequest.materialized.env.GITLAB_TOKEN).toBe('resolved-gitlab-token'); + expect(result.readyRequest.materialized.env.GITLAB_TOKEN).toBe('kgl1.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: 'kgl1.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', + orgId: undefined, + createdOnPlatform: 'code-review', + } + ); + expect(tokenMocks.resolveManagedGitLabToken).not.toHaveBeenCalled(); expect(result.readyRequest.repo).toMatchObject({ kind: 'git', - token: 'resolved-project-token', + token: 'kgl1.project', platform: 'gitlab', refreshRemote: true, }); - expect(result.readyRequest.materialized.env.GITLAB_TOKEN).toBe('resolved-project-token'); + expect(result.readyRequest.materialized.env.GITLAB_TOKEN).toBe('kgl1.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: 'kgl1.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 +1914,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('kgl1.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 +1939,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 +1953,7 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { }, } satisfies CloudAgentSessionState; - tokenMocks.resolveManagedGitLabToken.mockResolvedValueOnce({ + tokenMocks.issueCloudAgentGitLabSessionCapability.mockResolvedValueOnce({ success: false, reason, }); @@ -1518,12 +1961,21 @@ 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', + 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 +1983,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..3720ddfe67 100644 --- a/services/cloud-agent-next/src/session-service.ts +++ b/services/cloud-agent-next/src/session-service.ts @@ -11,8 +11,8 @@ import type { import { generateSandboxId } 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; }; @@ -1298,16 +1299,17 @@ export class SessionService { let githubFallbackReason: ManagedGitHubFallbackReason | undefined; if (github) { - const result = await resolveCloudAgentGitHubAuthForRepo(env, { + const authParams = { githubRepo: github.repo, userId: metadata.identity.userId, 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 +1329,24 @@ 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, 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 +1367,7 @@ export class SessionService { githubCommitCoAuthor, githubFallbackReason, gitToken, + gitlabCapabilityGitUrl, gitlabTokenManaged, glabIsOAuth2, }; @@ -1393,6 +1397,8 @@ export class SessionService { throw ExecutionError.invalidRequest('Missing kiloSessionId in session metadata'); } + const devcontainerRequested = + metadata.workspace?.devcontainerRequested === true || metadata.devcontainer !== undefined; const resolvedTokens = await this.resolveWorkspaceTokens(env, metadata); const workspacePath = getSessionWorkspacePath(orgId, userId, sessionId); const sessionHome = getSessionHomePath(sessionId); @@ -1403,8 +1409,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 +1418,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 +1441,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 +1582,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 @@ -1639,7 +1643,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 +1921,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 +2025,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..beb6073544 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,110 @@ 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' + | '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' + | 'invalid_upstream_url' + | 'upstream_origin_not_allowed' + | 'repository_mismatch' + | 'invalid_upstream_request' + | 'source_unavailable' + | 'identity_mismatch'; }; export type GitTokenService = { @@ -170,18 +264,34 @@ 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 + ): Promise; + redeemGitHubSessionCapability(params: { + capability: string; + requestMethod: string; + requestUrl: string; + }): Promise; getGitLabToken(params: { userId: string; orgId?: string; repositoryUrl?: string; createdOnPlatform?: string; }): Promise; + issueGitLabSessionCapability(params: { + gitUrl: string; + userId: string; + orgId?: string; + createdOnPlatform?: string; + }): Promise; + redeemGitLabSessionCapability(params: { + capability: 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..42f217558c --- /dev/null +++ b/services/git-token-service/src/github-session-capability.test.ts @@ -0,0 +1,113 @@ +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', + 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(/^kgh1\./); + expect(capability).not.toContain('user_1'); + expect(capability).not.toContain('acme'); + expect(decoded).toEqual({ + purpose: 'github_scm_session', + version: 1, + userId: 'user_1', + 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('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([ + { purpose: 'another_use', version: 1 }, + { purpose: 'github_scm_session', version: 2 }, + ])('rejects decrypted claims bound to $purpose purpose and v$version', boundClaims => { + const codec = new GitHubSessionCapabilityCodec(encryptionKey); + const serializedClaims = JSON.stringify({ + ...boundClaims, + userId: 'user_1', + 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 = `kgh1.${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..52efffd666 --- /dev/null +++ b/services/git-token-service/src/github-session-capability.ts @@ -0,0 +1,158 @@ +import { decryptWithSymmetricKey, encryptWithSymmetricKey } from '@kilocode/encryption'; +import { Buffer } from 'node:buffer'; +import { z } from 'zod'; + +const CAPABILITY_PREFIX = 'kgh1.'; +const CAPABILITY_PURPOSE = 'github_scm_session'; +const CAPABILITY_VERSION = 1; +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 GitHubSessionCapabilityClaimsSchema = z + .object({ + purpose: z.literal(CAPABILITY_PURPOSE), + version: z.literal(CAPABILITY_VERSION), + 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(), + }) + .strict() + .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; + 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 parsed = GitHubSessionCapabilityClaimsSchema.safeParse({ + purpose: CAPABILITY_PURPOSE, + version: CAPABILITY_VERSION, + ...subject, + issuedAt, + expiresAt: issuedAt + MAX_GITHUB_SCM_SESSION_CAPABILITY_LIFETIME_MS, + }); + if (!parsed.success) throw new GitHubSessionCapabilityError('invalid_capability'); + + try { + return `${CAPABILITY_PREFIX}${encryptWithSymmetricKey( + JSON.stringify(parsed.data), + this.encryptionKey + )}`; + } catch { + throw new GitHubSessionCapabilityError('capability_configuration_error'); + } + } + + decode(capability: string): GitHubSessionCapabilityClaims { + if (!capability.startsWith(CAPABILITY_PREFIX)) { + throw new GitHubSessionCapabilityError('invalid_capability'); + } + + const encrypted = capability.slice(CAPABILITY_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) 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..8a175a99af --- /dev/null +++ b/services/git-token-service/src/gitlab-session-capability.test.ts @@ -0,0 +1,127 @@ +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', + 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(/^kgl1\./); + expect(capability).not.toContain('user_1'); + expect(capability).not.toContain('gitlab.example.com'); + expect(codec.decode(capability)).toEqual({ + purpose: 'gitlab_scm_session', + version: 1, + ...claims, + issuedAt: Date.parse('2026-05-31T12:00:00.000Z'), + expiresAt: Date.parse('2026-05-31T13:00:00.000Z'), + }); + vi.useRealTimers(); + }); + + 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: 1, + ...claims, + ...overriddenClaims, + 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..a8b202deae --- /dev/null +++ b/services/git-token-service/src/gitlab-session-capability.ts @@ -0,0 +1,227 @@ +import { decryptWithSymmetricKey, encryptWithSymmetricKey } from '@kilocode/encryption'; +import { z } from 'zod'; +import { hasCanonicalEncryptedValueFormat } from './github-session-capability.js'; + +const CAPABILITY_PREFIX = 'kgl1.'; +const CAPABILITY_PURPOSE = 'gitlab_scm_session'; +const CAPABILITY_VERSION = 1; +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 GitLabSessionCapabilityClaimsSchema = z + .object({ + purpose: z.literal(CAPABILITY_PURPOSE), + version: z.literal(CAPABILITY_VERSION), + 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(), + }) + .strict() + .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; + 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 parsed = GitLabSessionCapabilityClaimsSchema.safeParse({ + purpose: CAPABILITY_PURPOSE, + version: CAPABILITY_VERSION, + ...subject, + issuedAt, + expiresAt: issuedAt + MAX_GITLAB_SCM_SESSION_CAPABILITY_LIFETIME_MS, + }); + if (!parsed.success) throw new GitLabSessionCapabilityError('invalid_capability'); + try { + return `${CAPABILITY_PREFIX}${encryptWithSymmetricKey( + JSON.stringify(parsed.data), + this.encryptionKey + )}`; + } catch { + throw new GitLabSessionCapabilityError('capability_configuration_error'); + } + } + + decode(capability: string): GitLabSessionCapabilityClaims { + if (!capability.startsWith(CAPABILITY_PREFIX)) { + throw new GitLabSessionCapabilityError('invalid_capability'); + } + const encrypted = capability.slice(CAPABILITY_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) 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..2f9a1e7c8d 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,941 @@ 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', +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' }, + }); + serviceMocks.getTokenForRepo.mockResolvedValue('installation-token'); + serviceMocks.selectUserAuthorization.mockResolvedValue({ + selected: true, + token: 'user-token', + gitAuthor: { name: 'octocat', email: '1+octocat@users.noreply.github.com' }, }); - vi.spyOn(InstallationLookupService.prototype, 'findRefreshCandidates').mockResolvedValue({ + }); + + it('issues an opaque GitHub capability while preserving non-secret attribution metadata', async () => { + const result = await createService().issueGitHubSessionCapability({ + githubRepo: 'Acme/Repo', + userId: 'user_1', + 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(/^kgh1\./); + 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', }); - 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' }) + ).resolves.toEqual({ success: false, reason: 'capability_configuration_error' }); + }); + + it('rejects tampered capabilities before resolving any upstream authorization', async () => { + const service = createService(); + const issued = await service.issueGitHubSessionCapability({ + githubRepo: 'acme/repo', + userId: 'user_1', + }); + 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, + 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', + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.getTokenForRepo.mockClear(); + + const redemption = await service.redeemGitHubSessionCapability({ + capability: issued.capability, + 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', + }); + 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, + 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', + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.getTokenForRepo.mockClear(); + + const redemption = await service.redeemGitHubSessionCapability({ + capability: issued.capability, + requestMethod: 'POST', + requestUrl, }); - vi.spyOn(InstallationLookupService.prototype, 'findRefreshCandidates').mockResolvedValue({ + + 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', + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.getTokenForRepo.mockClear(); + + await expect( + service.redeemGitHubSessionCapability({ + capability: issued.capability, + 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', + 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, + 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', + 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, + 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', + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.getTokenForRepo.mockClear(); + + await expect( + service.redeemGitHubSessionCapability({ + capability: issued.capability, + 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', + 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, + 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', + 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, + 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', + }); + 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, + 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', + }); + if (!issued.success) throw new Error('Expected successful issuance'); + + await expect( + service.redeemGitHubSessionCapability({ + capability: issued.capability, + 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', + }); + + 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(/^kgl1\./); + expect(JSON.stringify(result)).not.toContain('gitlab-oauth-token'); + expect(result).not.toHaveProperty('token'); + } + ); + + 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' } }, + }, + }); + + const result = await createService().issueGitLabSessionCapability({ + gitUrl: 'https://gitlab.com/acme/widgets.git', + userId: 'user_1', + createdOnPlatform: 'code-review', }); - expect(result).toEqual({ success: false, reason: 'no_installation_found' }); - expect(updateAccountLogin).toHaveBeenCalledWith('integration-1', 'different-owner'); - expect(getTokenForRepo).not.toHaveBeenCalled(); + 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(/^kgl1\./); + 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', + createdOnPlatform: 'code-review', + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.getGitLabToken.mockClear(); + + await expect( + service.redeemGitLabSessionCapability({ + capability: issued.capability, + 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', + 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, + 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', + }); + 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, + 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', + }); + 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, + 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', + }); + 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, + 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', + }) + ).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', + }); + + const result = await createService().issueGitLabSessionCapability({ + gitUrl: 'https://gitlab.com/acme/widgets.git', + userId: 'user_1', }); - expect(result).toEqual({ success: false, reason: 'invalid_repo_format' }); - expect(getTokenForRepo).not.toHaveBeenCalled(); + 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', + }); + 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', + }); + + const result = await service.redeemGitLabSessionCapability({ + capability: issued.capability, + requestMethod, + requestUrl, }); - vi.spyOn(GitHubTokenService.prototype, 'getTokenForRepo').mockRejectedValue( - new Error('repository not accessible') + + 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', + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.findGitLabIntegration.mockClear(); + + await expect( + service.redeemGitLabSessionCapability({ + capability: issued.capability, + 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', + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.findGitLabIntegration.mockResolvedValueOnce({ + success: false, + reason: 'no_integration_found', + }); + serviceMocks.getGitLabToken.mockClear(); + + await expect( + service.redeemGitLabSessionCapability({ + capability: issued.capability, + 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', + }); + 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( - rpc.getTokenForRepo({ githubRepo: 'renamed-owner/repository', userId: 'user-1' }) - ).rejects.toThrow('repository not accessible'); - expect(getToken).not.toHaveBeenCalled(); + service.redeemGitLabSessionCapability({ + capability: issued.capability, + 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..554cfec840 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,203 @@ export type GetCloudAgentAuthForRepoResult = | GetCloudAgentAuthForRepoSuccess | GetTokenForRepoFailure; +export type IssueGitHubSessionCapabilityParams = GetCloudAgentAuthForRepoParams; +export type IssueGitHubSessionCapabilitySuccess = Omit< + GetCloudAgentAuthForRepoSuccess, + 'githubToken' +> & { + capability: string; +}; +export type IssueGitHubSessionCapabilityResult = + | IssueGitHubSessionCapabilitySuccess + | GetTokenForRepoFailure + | { success: false; reason: 'capability_configuration_error' }; + +export type RedeemGitHubSessionCapabilityParams = { + capability: string; + requestMethod: string; + requestUrl: string; +}; +export type RedeemGitHubSessionCapabilitySuccess = { + success: true; + authorization: string; +}; +export type RedeemGitHubSessionCapabilityFailureReason = + | GitHubSessionCapabilityFailureReason + | '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; +}; +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; + requestMethod: string; + requestUrl: string; +}; +export type RedeemGitLabSessionCapabilityFailureReason = + | GitLabSessionCapabilityFailureReason + | '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 +460,141 @@ 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.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' }; + } + + 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 +638,150 @@ 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.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' }; + } + + 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 +795,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", + }, ], }, }, From 410282b2615ffe0dedf3b1f57c0d1e8e66cbba76 Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Tue, 2 Jun 2026 15:34:42 +0200 Subject: [PATCH 2/2] fix(cloud-agent-next): bind SCM capabilities to sandbox containers --- .plans/cloud-agent-commit-as-user.md | 102 --------- .../cloud-agent-next/src/persistence/types.ts | 6 +- .../cloud-agent-next/src/sandbox-id.test.ts | 21 +- services/cloud-agent-next/src/sandbox-id.ts | 11 +- .../src/sandbox-outbound.test.ts | 107 ++++++--- .../cloud-agent-next/src/sandbox-outbound.ts | 31 ++- .../services/git-token-service-client.test.ts | 62 ++++-- .../src/services/git-token-service-client.ts | 9 +- .../src/session-service.test.ts | 133 +++++++----- .../cloud-agent-next/src/session-service.ts | 12 +- services/cloud-agent-next/src/types.ts | 7 +- .../src/github-session-capability.test.ts | 31 ++- .../src/github-session-capability.ts | 64 +++--- .../src/gitlab-session-capability.test.ts | 37 +++- .../src/gitlab-session-capability.ts | 69 +++--- services/git-token-service/src/index.test.ts | 204 +++++++++++++++++- services/git-token-service/src/index.ts | 23 +- 17 files changed, 656 insertions(+), 273 deletions(-) delete mode 100644 .plans/cloud-agent-commit-as-user.md diff --git a/.plans/cloud-agent-commit-as-user.md b/.plans/cloud-agent-commit-as-user.md deleted file mode 100644 index 56443663f0..0000000000 --- a/.plans/cloud-agent-commit-as-user.md +++ /dev/null @@ -1,102 +0,0 @@ -# Cloud Agent SCM credentials: catch-all outbound walking skeleton - -## Goal - -Deliver a reviewed managed-SCM containment walking skeleton for Cloud Agent sandboxes: use one catch-all outbound handler, preserve managed GitHub support with default-HTTPS LFS repository-control validation, add GitLab HTTPS support without host preregistration, and enable DIND only after proving nested routing and propagation of Cloudflare's private runtime HTTPS-interception CA/trusted bundle. - -## Current State - -- The walking skeleton is implemented, independently reviewed task by task, fixed where required, and locally validated. -- The walking skeleton is committed as `ab4fe320e`. The standalone `services/git-session-proxy` foundation is parked on the `quilled-meteoroid` worktree for possible later relay hardening; this active branch removes it and relies on Cloudflare outbound HTTP(S) interception rather than proxy-service wiring. -- Deployment order is mandatory: provision the SCM capability secret, deploy `git-token-service`, then deploy `cloud-agent-next`. - -## Implemented Architecture - -Eligible sandboxes use one HTTP(S) boundary: - -```ts -Sandbox.outbound = handleManagedScmOutbound; -``` - -| Request class | Implemented behavior | -|---|---| -| Unmatched request | Pass through unchanged. | -| Recognized Kilo capability carrier | Redeem server-side; fail closed when invalid, including malformed whitespace/tab carrier cases. | -| Redeemed managed request | Replace sandbox-visible capability auth with redeemed provider auth outside the sandbox. | -| Redirect from redeemed request | Follow manually so managed auth is forwarded only after target validation. | -| Cross-provider or unsupported recognized carrier | Fail closed rather than falling back to raw forwarding. | - -Provider-issued signed LFS action URLs and headers intentionally remain visible to the sandbox in this skeleton. - -## Implemented Provider Coverage - -| Surface | GitHub | GitLab | -|---|---|---| -| Capability | `kgh1.` marker with one-hour encrypted claims. | `kgl1.` marker with one-hour encrypted claims, separate GitLab purpose, and the shared encryption secret. | -| Origin | Existing GitHub origins. | `gitlab.com` and active self-managed standard HTTPS integration origins on port `443`. | -| Repository path | Exact GitHub repository validation. | Exact nested namespace project validation, for example `group/subgroup/project`. | -| Git smart HTTP | Repository-bound. | Repository-bound. | -| LFS control | Repository-bound `POST .../.git/info/lfs/objects/batch` and `POST .../.git/info/lfs/locks/verify`. | Repository-bound batch and lock verification. | -| CLI API | Existing broad `api.github.com` compatibility for `gh`. | Broad `/api/v4/**` and `/api/graphql` compatibility for `glab`. | -| Managed auth rewrite | Redeemed GitHub auth. | Basic, Bearer, and `PRIVATE-TOKEN` rewriting for managed OAuth/PAT auth. | -| Explicit profile token | Outside managed containment. | Pass through unchanged as intentional user-controlled auth. | - -GitLab integration handling uses sanitized refresh logging and per-use database clients. Eligible GitLab session preparation emits a canonical `.git` remote URL, trusted `GITLAB_HOST`, and a capability-backed `GITLAB_TOKEN`; raw managed-auth fallback has been removed. - -## Capability Marker Decision - -| Provider | Capability marker | -|---|---| -| GitHub | `kgh1.` | -| GitLab | `kgl1.` | - -The short prefix routes the provider codec, fails closed for unsupported formats, and versions the marker. It is not the security boundary: authenticated AES-GCM claims remain authoritative. The `kgh1.` / `kgl1.` rollout intentionally invalidates previously issued verbose-marker capabilities. Coordinate rollout in the required order - provision the SCM capability secret, deploy `git-token-service`, then deploy `cloud-agent-next` - or accept up to one hour of transient failures for in-flight old capabilities. - -Fresh capabilities are issued on every dispatched message or command. Remotes and environment are refreshed before prompt delivery, so timer refresh is unnecessary for the skeleton. Only autonomous turns or terminal usage extending beyond one hour remain edge cases. - -## DIND Result - -The nested-DIND real-Git rewrite probe proved that `--network=host` supplies routing to the catch-all boundary and that nested devcontainers require propagation of Cloudflare's private runtime HTTPS-interception CA/trusted bundle. - -`SandboxDIND` catch-all interception is enabled. Managed GitHub and GitLab DIND preparation/wrapper paths use capabilities. Devcontainer setup copies the outer trusted CA bundle to a stable session-home path and injects trust environment variables. This does not imply that provider certificates are the production issue or that the runtime interception certificate is necessarily self-signed. The local missing-bundle negative control empirically returned a TLS rejection matching `server certificate verification failed|SSL certificate problem|certificate verify failed|self-signed certificate in certificate chain`; preserve that as observed probe output rather than a production certificate diagnosis. Probes clean up invocation artifacts. - -## Completion Record - -| Gate | Status | Evidence | -|---|---|---| -| Task 1: catch-all and GitHub LFS | Complete, reviewed, fixed | Catch-all `Sandbox.outbound`; GitHub LFS batch and lock verification; fail-closed recognized carriers including whitespace/tab; signed actions remain sandbox-visible. | -| Task 2: GitLab token service | Complete, reviewed | One-hour GitLab codec/issue/redeem; active self-managed HTTPS origin; nested namespaces; Git/LFS repo control; broad `glab` REST/GraphQL; sanitized refresh logging; per-use DB clients. | -| Task 3: Cloud Agent GitLab | Complete, reviewed, fixed | Capability-backed canonical `.git` remotes, `GITLAB_TOKEN`, trusted `GITLAB_HOST`; Basic/Bearer/`PRIVATE-TOKEN` rewrites; no raw fallback; cross-provider and unsupported recognized carriers fail closed. | -| Capability markers | Approved | GitHub `kgh1.`; GitLab `kgl1.`; short routing/fail-closed/version prefix only; AES-GCM claims remain authoritative; dispatch refresh makes one-hour expiry a long-running edge case. | -| Task 4/4b: DIND | Complete, reviewed | Probe proved host-network routing and nested propagation of Cloudflare's private runtime HTTPS-interception CA/trusted bundle; `SandboxDIND` catch-all enabled; GitHub/GitLab DIND paths use capabilities; devcontainer trust injection and probe cleanup implemented. | -| Final validation | Complete | Token service `102` tests; Cloud Agent `1545` passed, `3` skipped; changed-package typecheck `10/53`; both probes passed; whitespace clean; no review blockers remain. | - -## Validation Caveat - -Full Cloud Agent wrapper validation encountered an unchanged committed baseline timing-sensitive flake in `wrapper/src/lifecycle.test.ts`: `clears aborted state when activity cancels an aborted drain`. Its fixed `50 ms` wait races a real branch subprocess. Marker-focused and package checks pass. Track wrapper test stabilization separately rather than bundling it into the SCM diff. - -## Containment Claims - -| Path | Skeleton claim | -|---|---| -| Managed GitHub, eligible sandbox including DIND | Contained for recognized capability-bearing smart HTTP, broad `gh` API, and repository-bound LFS control requests. | -| Managed GitLab OAuth/PAT, eligible sandbox including DIND | Contained for recognized capability-bearing smart HTTP, broad `glab` API, and repository-bound LFS control requests on claimed standard HTTPS origins. | -| Provider-issued signed LFS actions | Not contained; action URLs and provider headers remain sandbox-visible. | -| Explicit profile tokens | Not contained; intentional pass-through. | - -## Follow-up Discussions - -These are explicit follow-ups, not blockers for this walking skeleton: - -| Area | Follow-up | -|---|---| -| Provider-signed LFS actions | Use the standalone relay parked on `quilled-meteoroid` later if a stronger boundary is required. | -| Self-managed GitLab origins | Add SSRF hardening and admin allowlisting for active-integration approval. | -| GitLab instance shape | Discuss nonstandard ports and subpath-hosted instances. | -| GitLab token semantics | Resolve project-access-token semantics discrepancy. | -| GitLab OAuth | Add refresh concurrency handling and provision default GitLab OAuth client environment values. | -| Capability continuity | Add refresh within long-running autonomous turns or terminal sessions that outlive the one-hour capability lifetime; dispatched messages and commands already refresh remotes and environment before prompt delivery. | -| Wrapper test stability | Stabilize the unchanged baseline timing-sensitive lifecycle test separately from the SCM diff. | -| Capability carriers | Harden query/body carrier handling. | -| Nested trust | Cover propagation of Cloudflare's private runtime HTTPS-interception CA/trusted bundle into Dockerfile build stages, unusual custom images/trust stores, CA rotation, and non-host nested networks. | -| Cleanup behavior | Cover abrupt cleanup. The stale `dev/local` standalone proxy WIP is isolated on `quilled-meteoroid`. | 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 index e6ac8b0f59..3d73ba7a90 100644 --- a/services/cloud-agent-next/src/sandbox-outbound.test.ts +++ b/services/cloud-agent-next/src/sandbox-outbound.test.ts @@ -20,8 +20,11 @@ import { handleManagedScmOutbound, } from './sandbox-outbound.js'; -const CAPABILITY = 'kgh1.opaque'; -const GITLAB_CAPABILITY = 'kgl1.opaque'; +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')}`; @@ -38,6 +41,10 @@ function createEnv( } 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({ @@ -110,10 +117,11 @@ describe('handleManagedScmOutbound', () => { body: 'git-body', }); - await handleManagedScmOutbound(request, createEnv(redeemGitHubSessionCapability)); + 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', }); @@ -132,7 +140,7 @@ describe('handleManagedScmOutbound', () => { const forward = vi.fn(); vi.stubGlobal('fetch', forward); - const response = await handleManagedScmOutbound( + const response = await handleOutbound( new Request('https://github.com/acme/repo.git/info/refs?service=git-upload-pack', { headers: { Authorization: basicCredential(CAPABILITY, 'bAsIc') }, }), @@ -141,6 +149,7 @@ describe('handleManagedScmOutbound', () => { expect(redeemGitHubSessionCapability).toHaveBeenCalledWith({ capability: CAPABILITY, + outboundContainerId: OUTBOUND_CONTEXT.containerId, requestMethod: 'GET', requestUrl: 'https://github.com/acme/repo.git/info/refs?service=git-upload-pack', }); @@ -154,7 +163,7 @@ describe('handleManagedScmOutbound', () => { vi.stubGlobal('fetch', forward); const authorization = basicCredential('explicit-profile-token'); - await handleManagedScmOutbound( + await handleOutbound( new Request('https://github.com/acme/repo.git/info/refs?service=git-upload-pack', { headers: { Authorization: authorization }, }), @@ -166,7 +175,7 @@ describe('handleManagedScmOutbound', () => { expect(forwarded.headers.get('Authorization')).toBe(authorization); expect(forwarded.redirect).toBe('follow'); - await handleManagedScmOutbound( + await handleOutbound( new Request('https://github.com/acme/repo.git/info/refs?service=git-upload-pack', { headers: { Authorization: 'Basic %not-base64%' }, }), @@ -183,7 +192,7 @@ describe('handleManagedScmOutbound', () => { const forward = vi.fn().mockResolvedValue(new Response('forwarded')); vi.stubGlobal('fetch', forward); - await handleManagedScmOutbound( + await handleOutbound( new Request('https://github.com/acme/repo.git/info/lfs/objects/batch', { method: 'POST', headers: { Authorization: basicCredential(CAPABILITY) }, @@ -194,6 +203,7 @@ describe('handleManagedScmOutbound', () => { expect(redeemGitHubSessionCapability).toHaveBeenCalledWith({ capability: CAPABILITY, + outboundContainerId: OUTBOUND_CONTEXT.containerId, requestMethod: 'POST', requestUrl: 'https://github.com/acme/repo.git/info/lfs/objects/batch', }); @@ -211,12 +221,50 @@ describe('handleManagedScmOutbound', () => { headers: { Authorization: 'Bearer explicit-profile-token' }, }); - await handleManagedScmOutbound(request, createEnv(redeemGitHubSessionCapability)); + 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'), @@ -228,7 +276,7 @@ describe('handleManagedScmOutbound', () => { const forward = vi.fn(); vi.stubGlobal('fetch', forward); - const response = await handleManagedScmOutbound( + const response = await handleOutbound( new Request('https://example.com/resource', { headers: { Authorization: authorization } }), createEnv(redeemGitHubSessionCapability, redeemGitLabSessionCapability) ); @@ -246,7 +294,7 @@ describe('handleManagedScmOutbound', () => { const forward = vi.fn(); vi.stubGlobal('fetch', forward); - const response = await handleManagedScmOutbound( + const response = await handleOutbound( new Request('https://example.com/resource', { headers: { 'PRIVATE-TOKEN': ` \t${CAPABILITY}\t ` }, }), @@ -267,7 +315,7 @@ describe('handleManagedScmOutbound', () => { const forward = vi.fn(); vi.stubGlobal('fetch', forward); - const response = await handleManagedScmOutbound( + const response = await handleOutbound( new Request('https://example.com/resource', { headers: { Authorization: `Bearer ${CAPABILITY}` }, }), @@ -276,6 +324,7 @@ describe('handleManagedScmOutbound', () => { expect(redeemGitHubSessionCapability).toHaveBeenCalledWith({ capability: CAPABILITY, + outboundContainerId: OUTBOUND_CONTEXT.containerId, requestMethod: 'GET', requestUrl: 'https://example.com/resource', }); @@ -300,13 +349,14 @@ describe('handleManagedScmOutbound', () => { const forward = vi.fn(); vi.stubGlobal('fetch', forward); - const response = await handleManagedScmOutbound( + 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', }); @@ -322,11 +372,11 @@ describe('handleManagedScmOutbound', () => { new Request('https://github.com/acme/repo.git/info/refs?service=git-upload-pack', { headers: { Authorization: basicCredential(CAPABILITY) }, }); - const rejected = await handleManagedScmOutbound( + const rejected = await handleOutbound( request(), createEnv(vi.fn().mockResolvedValue({ success: false, reason: 'expired_capability' })) ); - const thrown = await handleManagedScmOutbound( + const thrown = await handleOutbound( request(), createEnv(vi.fn().mockRejectedValue(new Error('RPC unavailable'))) ); @@ -355,7 +405,7 @@ describe('handleManagedScmOutbound GitLab authorization', () => { ]; for (const [index, url] of urls.entries()) { - await handleManagedScmOutbound( + await handleOutbound( new Request(url, { method: index === 0 ? 'GET' : 'POST', headers: { Authorization: basicCredential(GITLAB_CAPABILITY, 'bAsIc', 'oauth2') }, @@ -367,11 +417,13 @@ describe('handleManagedScmOutbound GitLab authorization', () => { 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], }); @@ -391,7 +443,7 @@ describe('handleManagedScmOutbound GitLab authorization', () => { const forward = vi.fn().mockResolvedValue(new Response('forwarded')); vi.stubGlobal('fetch', forward); - await handleManagedScmOutbound( + await handleOutbound( new Request('https://gitlab.com/api/v4/projects/1/merge_requests', { method: 'POST', headers: { [name]: value }, @@ -402,6 +454,7 @@ describe('handleManagedScmOutbound GitLab authorization', () => { expect(redeemGitLabSessionCapability).toHaveBeenCalledWith({ capability: GITLAB_CAPABILITY, + outboundContainerId: OUTBOUND_CONTEXT.containerId, requestMethod: 'POST', requestUrl: 'https://gitlab.com/api/v4/projects/1/merge_requests', }); @@ -419,7 +472,7 @@ describe('handleManagedScmOutbound GitLab authorization', () => { const forward = vi.fn().mockResolvedValue(new Response('forwarded')); vi.stubGlobal('fetch', forward); - await handleManagedScmOutbound( + await handleOutbound( new Request('https://gitlab.com/api/v4/projects/42/merge_requests', { method: 'POST', headers: { 'PRIVATE-TOKEN': GITLAB_CAPABILITY }, @@ -430,6 +483,7 @@ describe('handleManagedScmOutbound GitLab authorization', () => { expect(redeemGitLabSessionCapability).toHaveBeenCalledWith({ capability: GITLAB_CAPABILITY, + outboundContainerId: OUTBOUND_CONTEXT.containerId, requestMethod: 'POST', requestUrl: 'https://gitlab.com/api/v4/projects/42/merge_requests', }); @@ -445,7 +499,7 @@ describe('handleManagedScmOutbound GitLab authorization', () => { const forward = vi.fn(); vi.stubGlobal('fetch', forward); - const response = await handleManagedScmOutbound( + const response = await handleOutbound( new Request('https://gitlab.com/api/v4/user', { headers: { Authorization: `Bearer ${GITLAB_CAPABILITY}`, @@ -472,7 +526,7 @@ describe('handleManagedScmOutbound GitLab authorization', () => { const forward = vi.fn(); vi.stubGlobal('fetch', forward); - const response = await handleManagedScmOutbound( + const response = await handleOutbound( new Request('https://example.com/resource', { headers: { Authorization: authorization } }), createEnv(vi.fn(), redeemGitLabSessionCapability) ); @@ -491,7 +545,7 @@ describe('handleManagedScmOutbound GitLab authorization', () => { const forward = vi.fn(); vi.stubGlobal('fetch', forward); - const response = await handleManagedScmOutbound( + const response = await handleOutbound( new Request('https://example.com/resource', { headers: { Authorization: `Bearer ${GITLAB_CAPABILITY}` }, }), @@ -510,14 +564,14 @@ describe('handleManagedScmOutbound GitLab authorization', () => { headers: { Authorization: `Bearer ${GITLAB_CAPABILITY}` }, }); - const rejected = await handleManagedScmOutbound( + const rejected = await handleOutbound( request(), createEnv( vi.fn(), vi.fn().mockResolvedValue({ success: false, reason: 'invalid_capability' }) ) ); - const thrown = await handleManagedScmOutbound( + const thrown = await handleOutbound( request(), createEnv(vi.fn(), vi.fn().mockRejectedValue(new Error('RPC unavailable'))) ); @@ -536,7 +590,7 @@ describe('handleManagedScmOutbound GitLab authorization', () => { vi.stubGlobal('fetch', forward); const request = new Request('https://gitlab.com/api/v4/user', { headers }); - await handleManagedScmOutbound(request, createEnv(vi.fn(), redeemGitLabSessionCapability)); + await handleOutbound(request, createEnv(vi.fn(), redeemGitLabSessionCapability)); expect(redeemGitLabSessionCapability).not.toHaveBeenCalled(); expect(forward).toHaveBeenCalledWith(request); @@ -558,7 +612,7 @@ describe('handleManagedScmOutbound API authorization', () => { const forward = vi.fn().mockResolvedValue(new Response('forwarded')); vi.stubGlobal('fetch', forward); - await handleManagedScmOutbound( + await handleOutbound( new Request('https://api.github.com/repos/acme/repo/issues/1/comments', { method: 'POST', headers: { Authorization: `${scheme} ${CAPABILITY}` }, @@ -569,6 +623,7 @@ describe('handleManagedScmOutbound API authorization', () => { expect(redeemGitHubSessionCapability).toHaveBeenCalledWith({ capability: CAPABILITY, + outboundContainerId: OUTBOUND_CONTEXT.containerId, requestMethod: 'POST', requestUrl: 'https://api.github.com/repos/acme/repo/issues/1/comments', }); @@ -583,7 +638,7 @@ describe('handleManagedScmOutbound API authorization', () => { const forward = vi.fn().mockResolvedValue(new Response('forwarded')); vi.stubGlobal('fetch', forward); - await handleManagedScmOutbound( + await handleOutbound( new Request('https://api.github.com/user', { headers: { Authorization: 'token explicit-profile-token' }, }), @@ -599,7 +654,7 @@ describe('handleManagedScmOutbound API authorization', () => { const forward = vi.fn(); vi.stubGlobal('fetch', forward); - const response = await handleManagedScmOutbound( + const response = await handleOutbound( new Request('https://api.github.com/user', { headers: { Authorization: `Bearer ${CAPABILITY}` }, }), diff --git a/services/cloud-agent-next/src/sandbox-outbound.ts b/services/cloud-agent-next/src/sandbox-outbound.ts index 6a07093fc2..e23d2529a5 100644 --- a/services/cloud-agent-next/src/sandbox-outbound.ts +++ b/services/cloud-agent-next/src/sandbox-outbound.ts @@ -2,11 +2,12 @@ import { Buffer } from 'node:buffer'; import { ContainerProxy, Sandbox as StockSandbox } from '@cloudflare/sandbox'; import type { GitTokenService } from './types.js'; -const GITHUB_CAPABILITY_PREFIX = 'kgh1.'; -const GITLAB_CAPABILITY_PREFIX = 'kgl1.'; +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' } @@ -38,8 +39,12 @@ function supportsGitLabSessionCapabilityRedemption( } function identifyCapability(capability: string): RedeemableAuthorization | null { - if (capability.startsWith(GITHUB_CAPABILITY_PREFIX)) return { provider: 'github', capability }; - if (capability.startsWith(GITLAB_CAPABILITY_PREFIX)) return { provider: 'gitlab', capability }; + 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; } @@ -111,7 +116,8 @@ async function forwardRedeemedRequest( async function handleManagedGitHubOutbound( request: Request, env: Cloudflare.Env, - capability: { capability: string } + capability: { capability: string }, + outboundContainerId: string ): Promise { const tokenService = env.GIT_TOKEN_SERVICE; if (!supportsGitHubSessionCapabilityRedemption(tokenService)) { @@ -120,6 +126,7 @@ async function handleManagedGitHubOutbound( try { const result = await tokenService.redeemGitHubSessionCapability({ capability: capability.capability, + outboundContainerId, requestMethod: request.method, requestUrl: request.url, }); @@ -135,7 +142,8 @@ async function handleManagedGitHubOutbound( async function handleManagedGitLabOutbound( request: Request, env: Cloudflare.Env, - capability: { capability: string } + capability: { capability: string }, + outboundContainerId: string ): Promise { const tokenService = env.GIT_TOKEN_SERVICE; if (!supportsGitLabSessionCapabilityRedemption(tokenService)) { @@ -144,6 +152,7 @@ async function handleManagedGitLabOutbound( try { const result = await tokenService.redeemGitLabSessionCapability({ capability: capability.capability, + outboundContainerId, requestMethod: request.method, requestUrl: request.url, }); @@ -156,7 +165,11 @@ async function handleManagedGitLabOutbound( } } -export function handleManagedScmOutbound(request: Request, env: Cloudflare.Env): Promise { +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); @@ -189,8 +202,8 @@ export function handleManagedScmOutbound(request: Request, env: Cloudflare.Env): const capability = authorizationCapability ?? gitLabPrivateTokenCapability; if (!capability) return fetch(request); return capability.provider === 'github' - ? handleManagedGitHubOutbound(request, env, capability) - : handleManagedGitLabOutbound(request, env, capability); + ? handleManagedGitHubOutbound(request, env, capability, ctx.containerId) + : handleManagedGitLabOutbound(request, env, capability, ctx.containerId); } export class Sandbox extends StockSandbox { 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 469929df78..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 @@ -92,7 +92,7 @@ describe('issueCloudAgentGitHubSessionCapability', () => { it('returns an opaque capability and preserves managed identity metadata', async () => { const issueGitHubSessionCapability = vi.fn().mockResolvedValue({ success: true, - capability: 'kgh1.opaque', + capability: 'kgh2.opaque', installationId: '123', accountLogin: 'acme', appType: 'standard', @@ -105,12 +105,18 @@ describe('issueCloudAgentGitHubSessionCapability', () => { const result = await issueCloudAgentGitHubSessionCapability( createEnv({ issueGitHubSessionCapability, getCloudAgentAuthForRepo, getTokenForRepo }), - { githubRepo: 'acme/repo', userId: 'user_1', allowUserAuthorization: true } + { + 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(); @@ -118,7 +124,7 @@ describe('issueCloudAgentGitHubSessionCapability', () => { expect(result).toMatchObject({ success: true, value: { - capability: 'kgh1.opaque', + capability: 'kgh2.opaque', source: 'user', gitAuthor: { name: 'octocat' }, }, @@ -135,7 +141,12 @@ describe('issueCloudAgentGitHubSessionCapability', () => { const result = await issueCloudAgentGitHubSessionCapability( createEnv({ issueGitHubSessionCapability, getCloudAgentAuthForRepo, getTokenForRepo }), - { githubRepo: 'acme/repo', userId: 'user_1', allowUserAuthorization: false } + { + githubRepo: 'acme/repo', + userId: 'user_1', + outboundContainerId: 'container-test', + allowUserAuthorization: false, + } ); expect(result).toEqual({ @@ -158,7 +169,12 @@ describe('issueCloudAgentGitHubSessionCapability', () => { const result = await issueCloudAgentGitHubSessionCapability( createEnv({ issueGitHubSessionCapability, getCloudAgentAuthForRepo, getTokenForRepo }), - { githubRepo: 'acme/repo', userId: 'user_1', allowUserAuthorization: true } + { + githubRepo: 'acme/repo', + userId: 'user_1', + outboundContainerId: 'container-test', + allowUserAuthorization: true, + } ); expect(result).toMatchObject({ success: false, error: { reason: 'rpc_error' } }); @@ -171,7 +187,7 @@ describe('issueCloudAgentGitLabSessionCapability', () => { it('returns an opaque code-review project capability and preserves CLI mode metadata', async () => { const issueGitLabSessionCapability = vi.fn().mockResolvedValue({ success: true, - capability: 'kgl1.project', + capability: 'kgl2.project', instanceOrigin: 'https://gitlab.example.com', instanceHost: 'gitlab.example.com', projectPath: 'acme/platform/repo', @@ -187,6 +203,7 @@ describe('issueCloudAgentGitLabSessionCapability', () => { { gitUrl: 'https://gitlab.example.com/acme/platform/repo.git', userId: 'user_1', + outboundContainerId: 'container-test', createdOnPlatform: 'code-review', } ); @@ -194,13 +211,14 @@ describe('issueCloudAgentGitLabSessionCapability', () => { 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: 'kgl1.project', + capability: 'kgl2.project', gitUrl: 'https://gitlab.example.com/acme/platform/repo.git', instanceOrigin: 'https://gitlab.example.com', instanceHost: 'gitlab.example.com', @@ -223,7 +241,11 @@ describe('issueCloudAgentGitLabSessionCapability', () => { const result = await issueCloudAgentGitLabSessionCapability( createEnv({ issueGitLabSessionCapability, getGitLabToken }), - { gitUrl: 'https://gitlab.com/acme/repo.git', userId: 'user_1' } + { + gitUrl: 'https://gitlab.com/acme/repo.git', + userId: 'user_1', + outboundContainerId: 'container-test', + } ); expect(result).toEqual({ success: false, reason: 'capability_configuration_error' }); @@ -238,7 +260,11 @@ describe('issueCloudAgentGitLabSessionCapability', () => { const result = await issueCloudAgentGitLabSessionCapability( createEnv({ issueGitLabSessionCapability, getGitLabToken }), - { gitUrl: 'https://gitlab.com/acme/repo.git', userId: 'user_1' } + { + gitUrl: 'https://gitlab.com/acme/repo.git', + userId: 'user_1', + outboundContainerId: 'container-test', + } ); expect(result).toEqual({ success: false, reason: 'rpc_error' }); @@ -262,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({ @@ -292,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(); @@ -324,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 67a586c605..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 @@ -192,6 +192,7 @@ export async function issueCloudAgentGitHubSessionCapability( params: { githubRepo: string; userId: string; + outboundContainerId: string; orgId?: string; allowUserAuthorization: boolean; } @@ -270,7 +271,13 @@ export type ResolveManagedGitLabTokenResult = export async function issueCloudAgentGitLabSessionCapability( env: GitTokenServiceEnv, - params: { gitUrl: string; userId: string; orgId?: string; createdOnPlatform?: string } + params: { + gitUrl: string; + userId: string; + outboundContainerId: string; + orgId?: string; + createdOnPlatform?: string; + } ): Promise< { success: true; value: ResolvedCloudAgentGitLabCapability } | { success: false; reason: string } > { diff --git a/services/cloud-agent-next/src/session-service.test.ts b/services/cloud-agent-next/src/session-service.test.ts index 95ec4a703b..59fc455317 100644 --- a/services/cloud-agent-next/src/session-service.test.ts +++ b/services/cloud-agent-next/src/session-service.test.ts @@ -165,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(() => ({ @@ -202,7 +210,7 @@ function createEnv(metadata?: CloudAgentSessionState | null): PersistenceEnv { }), issueGitHubSessionCapability: vi.fn().mockResolvedValue({ success: true, - capability: 'kgh1.default', + capability: 'kgh2.default', installationId: '123', accountLogin: 'acme', appType: 'standard', @@ -289,7 +297,7 @@ describe('SessionService.prepareWorkspace', () => { tokenMocks.issueCloudAgentGitHubSessionCapability.mockResolvedValue({ success: true, value: { - capability: 'kgh1.default', + capability: 'kgh2.default', installationId: '123', accountLogin: 'acme', appType: 'standard', @@ -300,7 +308,7 @@ describe('SessionService.prepareWorkspace', () => { tokenMocks.issueCloudAgentGitLabSessionCapability.mockResolvedValue({ success: true, value: { - capability: 'kgl1.default', + capability: 'kgl2.default', instanceOrigin: 'https://gitlab.com', instanceHost: 'gitlab.com', projectPath: 'acme/repo', @@ -348,7 +356,7 @@ describe('SessionService.prepareWorkspace', () => { session, '/workspace/user/sessions/agent_test', 'https://gitlab.com/acme/repo.git', - 'kgl1.default', + 'kgl2.default', undefined, { platform: 'gitlab' } ); @@ -368,7 +376,7 @@ describe('SessionService.prepareWorkspace', () => { sessionHome: '/home/agent_test', branchName: 'main', kiloSessionId: 'kilo-session', - gitToken: 'kgl1.default', + gitToken: 'kgl2.default', gitlabTokenManaged: true, }); }); @@ -667,6 +675,7 @@ describe('SessionService.prepareWorkspace', () => { { githubRepo: 'acme/repo', userId: 'user_test', + outboundContainerId: 'sandbox-do-id', orgId: undefined, allowUserAuthorization: false, } @@ -676,7 +685,7 @@ describe('SessionService.prepareWorkspace', () => { session, '/workspace/user/sessions/agent_test', 'https://github.com/acme/repo.git', - 'kgh1.default' + 'kgh2.default' ); }); @@ -858,7 +867,7 @@ describe('SessionService.prepareWorkspace', () => { session, '/workspace/user/sessions/agent_test', 'https://github.com/acme/repo.git', - 'kgh1.default' + 'kgh2.default' ); }); @@ -893,7 +902,7 @@ describe('SessionService.prepareWorkspace', () => { session, '/workspace/user/sessions/agent_test', 'https://gitlab.com/acme/repo.git', - 'kgl1.default', + 'kgl2.default', 'gitlab' ); }); @@ -904,7 +913,7 @@ describe('SessionService.prepareWorkspace', () => { tokenMocks.issueCloudAgentGitLabSessionCapability.mockResolvedValueOnce({ success: true, value: { - capability: 'kgl1.project', + capability: 'kgl2.project', gitUrl: 'https://gitlab.com/acme/repo.git', instanceOrigin: 'https://gitlab.com', instanceHost: 'gitlab.com', @@ -931,6 +940,7 @@ describe('SessionService.prepareWorkspace', () => { { gitUrl: 'https://gitlab.com/acme/repo.git', userId: 'user_test', + outboundContainerId: 'sandbox-do-id', orgId: undefined, createdOnPlatform: 'code-review', } @@ -940,7 +950,7 @@ describe('SessionService.prepareWorkspace', () => { session, '/workspace/user/sessions/agent_test', 'https://gitlab.com/acme/repo.git', - 'kgl1.project', + 'kgl2.project', 'gitlab' ); }); @@ -977,7 +987,7 @@ describe('SessionService.prepareWorkspace', () => { session, '/workspace/user/sessions/agent_test', 'https://github.com/acme/repo.git', - 'kgh1.default' + 'kgh2.default' ); }); @@ -1024,7 +1034,7 @@ describe('SessionService.prepareWorkspace', () => { session, '/workspace/user/sessions/agent_test', 'acme/repo', - 'kgh1.default', + 'kgh2.default', { name: 'kiloconnect[bot]', email: 'bot@example.com' }, undefined ); @@ -1108,7 +1118,7 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { tokenMocks.issueCloudAgentGitHubSessionCapability.mockResolvedValue({ success: true, value: { - capability: 'kgh1.default', + capability: 'kgh2.default', installationId: '123', accountLogin: 'acme', appType: 'standard', @@ -1119,7 +1129,7 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { tokenMocks.issueCloudAgentGitLabSessionCapability.mockResolvedValue({ success: true, value: { - capability: 'kgl1.default', + capability: 'kgl2.default', instanceOrigin: 'https://gitlab.com', instanceHost: 'gitlab.com', projectPath: 'acme/repo', @@ -1235,12 +1245,30 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { expect(tokenMocks.issueCloudAgentGitLabSessionCapability).toHaveBeenCalled(); expect(tokenMocks.resolveManagedGitLabToken).not.toHaveBeenCalled(); expect(result.readyRequest.repo).toMatchObject({ - token: 'kgl1.default', + token: 'kgl2.default', }); - expect(result.readyRequest.materialized.env.GITLAB_TOKEN).toBe('kgl1.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(), @@ -1254,10 +1282,10 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { expect(tokenMocks.resolveManagedGitLabToken).not.toHaveBeenCalled(); expect(result.readyRequest.repo).toMatchObject({ kind: 'git', - token: 'kgl1.default', + token: 'kgl2.default', platform: 'gitlab', }); - expect(result.readyRequest.materialized.env.GITLAB_TOKEN).toBe('kgl1.default'); + expect(result.readyRequest.materialized.env.GITLAB_TOKEN).toBe('kgl2.default'); expect(JSON.stringify(result.readyRequest)).not.toContain('resolved-gitlab-token'); }); @@ -1304,9 +1332,9 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { expect(tokenMocks.issueCloudAgentGitLabSessionCapability).toHaveBeenCalled(); expect(tokenMocks.resolveManagedGitLabToken).not.toHaveBeenCalled(); expect(result.readyRequest.repo).toMatchObject({ - token: 'kgl1.default', + token: 'kgl2.default', }); - expect(result.readyRequest.materialized.env.GITLAB_TOKEN).toBe('kgl1.default'); + expect(result.readyRequest.materialized.env.GITLAB_TOKEN).toBe('kgl2.default'); expect(JSON.stringify(result.readyRequest)).not.toContain('resolved-gitlab-token'); }); @@ -1325,9 +1353,9 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { expect(tokenMocks.issueCloudAgentGitLabSessionCapability).toHaveBeenCalled(); expect(tokenMocks.resolveManagedGitLabToken).not.toHaveBeenCalled(); expect(result.readyRequest.repo).toMatchObject({ - token: 'kgl1.default', + token: 'kgl2.default', }); - expect(result.readyRequest.materialized.env.GITLAB_TOKEN).toBe('kgl1.default'); + expect(result.readyRequest.materialized.env.GITLAB_TOKEN).toBe('kgl2.default'); expect(JSON.stringify(result.readyRequest)).not.toContain('resolved-gitlab-token'); }); @@ -1349,9 +1377,9 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { expect(tokenMocks.resolveCloudAgentGitHubAuthForRepo).not.toHaveBeenCalled(); expect(result.readyRequest.repo).toMatchObject({ kind: 'github', - token: 'kgh1.default', + token: 'kgh2.default', }); - expect(result.readyRequest.materialized.env.GH_TOKEN).toBe('kgh1.default'); + expect(result.readyRequest.materialized.env.GH_TOKEN).toBe('kgh2.default'); expect(JSON.stringify(result.readyRequest)).not.toContain('resolved-gh-token'); }); @@ -1378,9 +1406,9 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { expect(tokenMocks.resolveCloudAgentGitHubAuthForRepo).not.toHaveBeenCalled(); expect(result.readyRequest.repo).toMatchObject({ kind: 'github', - token: 'kgh1.default', + token: 'kgh2.default', }); - expect(result.readyRequest.materialized.env.GH_TOKEN).toBe('kgh1.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 }); }); @@ -1438,7 +1466,7 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { sessionHome: '/home/agent_test', branchName: 'main', kiloSessionId: 'kilo-session', - gitToken: 'kgl1.default', + gitToken: 'kgl2.default', gitlabTokenManaged: true, }); expect(tokenMocks.issueCloudAgentGitLabSessionCapability).toHaveBeenCalledWith( @@ -1446,6 +1474,7 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { { gitUrl: 'https://gitlab.com/acme/repo.git', userId: 'user_test', + outboundContainerId: 'sandbox-do-id', orgId: undefined, } ); @@ -1464,7 +1493,7 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { repo: { kind: 'git', url: 'https://gitlab.com/acme/repo.git', - token: 'kgl1.default', + token: 'kgl2.default', platform: 'gitlab', }, materialized: { @@ -1478,7 +1507,7 @@ 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('kgl1.default'); + 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'); @@ -1612,7 +1641,7 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { tokenMocks.issueCloudAgentGitHubSessionCapability.mockResolvedValueOnce({ success: true, value: { - capability: 'kgh1.selected-user', + capability: 'kgh2.selected-user', installationId: '123', accountLogin: 'acme', appType: 'standard', @@ -1635,6 +1664,7 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { { githubRepo: 'acme/repo', userId: 'user_test', + outboundContainerId: 'sandbox-do-id', orgId: undefined, allowUserAuthorization: true, } @@ -1642,10 +1672,10 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { expect(tokenMocks.resolveCloudAgentGitHubAuthForRepo).not.toHaveBeenCalled(); expect(result.readyRequest.repo).toMatchObject({ kind: 'github', - token: 'kgh1.selected-user', + token: 'kgh2.selected-user', gitAuthor: { name: 'octocat', email: '1+octocat@users.noreply.github.com' }, }); - expect(result.readyRequest.materialized.env.GH_TOKEN).toBe('kgh1.selected-user'); + 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({ @@ -1669,6 +1699,7 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { { githubRepo: 'acme/repo', userId: 'user_test', + outboundContainerId: 'sandbox-do-id', orgId: undefined, allowUserAuthorization: true, } @@ -1693,6 +1724,7 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { { githubRepo: 'acme/repo', userId: 'user_test', + outboundContainerId: 'sandbox-do-id', orgId: undefined, allowUserAuthorization: false, } @@ -1704,7 +1736,7 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { tokenMocks.issueCloudAgentGitHubSessionCapability.mockResolvedValueOnce({ success: true, value: { - capability: 'kgh1.installation', + capability: 'kgh2.installation', installationId: '123', accountLogin: 'acme', appType: 'standard', @@ -1726,7 +1758,7 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { expect(result.readyRequest.repo).toMatchObject({ kind: 'github', - token: 'kgh1.installation', + token: 'kgh2.installation', gitAuthor: { name: 'kiloconnect-development[bot]', email: '242397087+kiloconnect-development[bot]@users.noreply.github.com', @@ -1738,7 +1770,7 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { tokenMocks.issueCloudAgentGitHubSessionCapability.mockResolvedValueOnce({ success: true, value: { - capability: 'kgh1.selected-user', + capability: 'kgh2.selected-user', installationId: '123', accountLogin: 'acme', appType: 'standard', @@ -1765,7 +1797,7 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { tokenMocks.issueCloudAgentGitLabSessionCapability.mockResolvedValueOnce({ success: true, value: { - capability: 'kgl1.self-managed', + capability: 'kgl2.self-managed', gitUrl: 'https://gitlab.example.com/acme/platform/repo.git', instanceOrigin: 'https://gitlab.example.com', instanceHost: 'gitlab.example.com', @@ -1784,7 +1816,7 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { ); expect(result.ready).toMatchObject({ - gitToken: 'kgl1.self-managed', + gitToken: 'kgl2.self-managed', gitlabTokenManaged: true, }); expect(tokenMocks.issueCloudAgentGitLabSessionCapability).toHaveBeenCalledWith( @@ -1792,16 +1824,17 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { { 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/acme/platform/repo.git', - token: 'kgl1.self-managed', + token: 'kgl2.self-managed', platform: 'gitlab', }); - expect(result.readyRequest.materialized.env.GITLAB_TOKEN).toBe('kgl1.self-managed'); + 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'); }); @@ -1818,7 +1851,7 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { ); expect(result.readyRequest.repo).toMatchObject({ - token: 'kgl1.default', + token: 'kgl2.default', platform: 'gitlab', }); expect(result.readyRequest.materialized.env.GITLAB_TOKEN).toBe('explicit-profile-token'); @@ -1836,14 +1869,14 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { ); expect(result.ready).toMatchObject({ - gitToken: 'kgl1.default', + gitToken: 'kgl2.default', gitlabTokenManaged: true, }); expect(result.readyRequest.repo).toMatchObject({ - token: 'kgl1.default', + token: 'kgl2.default', platform: 'gitlab', }); - expect(result.readyRequest.materialized.env.GITLAB_TOKEN).toBe('kgl1.default'); + 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'); }); @@ -1852,7 +1885,7 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { tokenMocks.issueCloudAgentGitLabSessionCapability.mockResolvedValueOnce({ success: true, value: { - capability: 'kgl1.project', + capability: 'kgl2.project', gitUrl: 'https://gitlab.com/acme/repo.git', instanceOrigin: 'https://gitlab.com', instanceHost: 'gitlab.com', @@ -1870,6 +1903,7 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { { gitUrl: 'https://gitlab.com/acme/repo.git', userId: 'user_test', + outboundContainerId: 'sandbox-do-id', orgId: undefined, createdOnPlatform: 'code-review', } @@ -1877,11 +1911,11 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { expect(tokenMocks.resolveManagedGitLabToken).not.toHaveBeenCalled(); expect(result.readyRequest.repo).toMatchObject({ kind: 'git', - token: 'kgl1.project', + token: 'kgl2.project', platform: 'gitlab', refreshRemote: true, }); - expect(result.readyRequest.materialized.env.GITLAB_TOKEN).toBe('kgl1.project'); + 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'); }); @@ -1890,7 +1924,7 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { tokenMocks.issueCloudAgentGitLabSessionCapability.mockResolvedValueOnce({ success: true, value: { - capability: 'kgl1.project', + capability: 'kgl2.project', gitUrl: 'https://gitlab.com/acme/repo.git', instanceOrigin: 'https://gitlab.com', instanceHost: 'gitlab.com', @@ -1915,7 +1949,7 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { const result = await buildPromptWrapperRequests(metadata); expect(tokenMocks.resolveManagedGitLabToken).not.toHaveBeenCalled(); - expect(result.readyRequest.materialized.env.GITLAB_TOKEN).toBe('kgl1.project'); + 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'); @@ -1966,6 +2000,7 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { { gitUrl: 'https://gitlab.com/acme/repo.git', userId: 'user_test', + outboundContainerId: 'sandbox-do-id', orgId: undefined, createdOnPlatform: 'code-review', } diff --git a/services/cloud-agent-next/src/session-service.ts b/services/cloud-agent-next/src/session-service.ts index 3720ddfe67..951bf807ed 100644 --- a/services/cloud-agent-next/src/session-service.ts +++ b/services/cloud-agent-next/src/session-service.ts @@ -8,7 +8,7 @@ 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 { issueCloudAgentGitHubSessionCapability, @@ -1286,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; @@ -1302,6 +1304,7 @@ export class SessionService { const authParams = { githubRepo: github.repo, userId: metadata.identity.userId, + outboundContainerId, orgId: metadata.identity.orgId, allowUserAuthorization: metadata.identity.createdOnPlatform === 'cloud-agent-web' || @@ -1339,6 +1342,7 @@ export class SessionService { const result = await issueCloudAgentGitLabSessionCapability(env, { gitUrl: git.url, userId: metadata.identity.userId, + outboundContainerId, orgId: metadata.identity.orgId, createdOnPlatform: metadata.identity.createdOnPlatform, }); @@ -1399,7 +1403,7 @@ export class SessionService { const devcontainerRequested = metadata.workspace?.devcontainerRequested === true || metadata.devcontainer !== undefined; - const resolvedTokens = await this.resolveWorkspaceTokens(env, metadata); + const resolvedTokens = await this.resolveWorkspaceTokens(env, metadata, sandboxId as SandboxId); const workspacePath = getSessionWorkspacePath(orgId, userId, sessionId); const sessionHome = getSessionHomePath(sessionId); const branchName = @@ -1621,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); diff --git a/services/cloud-agent-next/src/types.ts b/services/cloud-agent-next/src/types.ts index beb6073544..3605ae6c14 100644 --- a/services/cloud-agent-next/src/types.ts +++ b/services/cloud-agent-next/src/types.ts @@ -182,6 +182,7 @@ type RedeemGitHubSessionCapabilityResult = | 'invalid_capability' | 'expired_capability' | 'capability_configuration_error' + | 'container_mismatch' | 'invalid_upstream_url' | 'upstream_host_not_allowed' | 'repository_mismatch' @@ -249,6 +250,7 @@ type RedeemGitLabSessionCapabilityResult = | 'invalid_capability' | 'expired_capability' | 'capability_configuration_error' + | 'container_mismatch' | 'invalid_upstream_url' | 'upstream_origin_not_allowed' | 'repository_mismatch' @@ -268,10 +270,11 @@ export type GitTokenService = { params: ManagedGitHubAuthParams ): Promise; issueGitHubSessionCapability( - params: ManagedGitHubAuthParams + params: ManagedGitHubAuthParams & { outboundContainerId: string } ): Promise; redeemGitHubSessionCapability(params: { capability: string; + outboundContainerId: string; requestMethod: string; requestUrl: string; }): Promise; @@ -284,11 +287,13 @@ export type GitTokenService = { issueGitLabSessionCapability(params: { gitUrl: string; userId: string; + outboundContainerId: string; orgId?: string; createdOnPlatform?: string; }): Promise; redeemGitLabSessionCapability(params: { capability: string; + outboundContainerId: string; requestMethod: string; requestUrl: string; }): Promise; diff --git a/services/git-token-service/src/github-session-capability.test.ts b/services/git-token-service/src/github-session-capability.test.ts index 42f217558c..3d98c6f129 100644 --- a/services/git-token-service/src/github-session-capability.test.ts +++ b/services/git-token-service/src/github-session-capability.test.ts @@ -9,6 +9,7 @@ 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', @@ -34,13 +35,14 @@ describe('GitHubSessionCapabilityCodec', () => { const capability = codec.issue(claims); const decoded = codec.decode(capability); - expect(capability).toMatch(/^kgh1\./); + expect(capability).toMatch(/^kgh2\./); expect(capability).not.toContain('user_1'); expect(capability).not.toContain('acme'); expect(decoded).toEqual({ purpose: 'github_scm_session', - version: 1, + version: 2, userId: 'user_1', + outboundContainerId: claims.outboundContainerId, orgId: claims.orgId, owner: 'acme', repo: 'widgets', @@ -52,6 +54,22 @@ describe('GitHubSessionCapabilityCodec', () => { 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')); @@ -82,13 +100,14 @@ describe('GitHubSessionCapabilityCodec', () => { }); it.each([ - { purpose: 'another_use', version: 1 }, - { purpose: 'github_scm_session', version: 2 }, - ])('rejects decrypted claims bound to $purpose purpose and v$version', boundClaims => { + ['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', @@ -104,7 +123,7 @@ describe('GitHubSessionCapabilityCodec', () => { issuedAt: Date.now(), expiresAt: Date.now() + 60_000, }); - const capability = `kgh1.${encryptWithSymmetricKey(serializedClaims, encryptionKey)}`; + 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 index 52efffd666..83cdeb46ab 100644 --- a/services/git-token-service/src/github-session-capability.ts +++ b/services/git-token-service/src/github-session-capability.ts @@ -2,9 +2,9 @@ import { decryptWithSymmetricKey, encryptWithSymmetricKey } from '@kilocode/encr import { Buffer } from 'node:buffer'; import { z } from 'zod'; -const CAPABILITY_PREFIX = 'kgh1.'; +const LEGACY_CAPABILITY_PREFIX = 'kgh1.'; +const BOUND_CAPABILITY_PREFIX = 'kgh2.'; const CAPABILITY_PURPOSE = 'github_scm_session'; -const CAPABILITY_VERSION = 1; const MAX_GITHUB_SCM_SESSION_CAPABILITY_LIFETIME_MS = 60 * 60 * 1000; const GitHubPathPartSchema = z .string() @@ -29,20 +29,29 @@ const GitHubSessionIdentitySchema = z 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 - .object({ - purpose: z.literal(CAPABILITY_PURPOSE), - version: z.literal(CAPABILITY_VERSION), - 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(), - }) - .strict() + .discriminatedUnion('version', [ + GitHubLegacySessionCapabilityClaimsSchema, + GitHubBoundSessionCapabilityClaimsSchema, + ]) .refine(claims => claims.expiresAt > claims.issuedAt) .refine( claims => claims.expiresAt - claims.issuedAt <= MAX_GITHUB_SCM_SESSION_CAPABILITY_LIFETIME_MS @@ -52,6 +61,7 @@ export type GitHubAuthSource = 'user' | 'installation'; export type GitHubSessionIdentity = z.infer; export type GitHubSessionCapabilitySubject = { userId: string; + outboundContainerId?: string; orgId?: string; owner: string; repo: string; @@ -107,9 +117,10 @@ export class GitHubSessionCapabilityCodec { issue(subject: GitHubSessionCapabilitySubject): string { const issuedAt = Date.now(); + const bound = subject.outboundContainerId !== undefined; const parsed = GitHubSessionCapabilityClaimsSchema.safeParse({ purpose: CAPABILITY_PURPOSE, - version: CAPABILITY_VERSION, + version: bound ? 2 : 1, ...subject, issuedAt, expiresAt: issuedAt + MAX_GITHUB_SCM_SESSION_CAPABILITY_LIFETIME_MS, @@ -117,21 +128,22 @@ export class GitHubSessionCapabilityCodec { if (!parsed.success) throw new GitHubSessionCapabilityError('invalid_capability'); try { - return `${CAPABILITY_PREFIX}${encryptWithSymmetricKey( - JSON.stringify(parsed.data), - this.encryptionKey - )}`; + 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 { - if (!capability.startsWith(CAPABILITY_PREFIX)) { - throw new GitHubSessionCapabilityError('invalid_capability'); - } + 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(CAPABILITY_PREFIX.length); + const encrypted = capability.slice(format.prefix.length); if (!hasCanonicalEncryptedValueFormat(encrypted)) { throw new GitHubSessionCapabilityError('invalid_capability'); } @@ -149,7 +161,9 @@ export class GitHubSessionCapabilityCodec { throw new GitHubSessionCapabilityError('invalid_capability'); } const parsed = GitHubSessionCapabilityClaimsSchema.safeParse(value); - if (!parsed.success) throw new GitHubSessionCapabilityError('invalid_capability'); + if (!parsed.success || parsed.data.version !== format.version) { + throw new GitHubSessionCapabilityError('invalid_capability'); + } if (parsed.data.expiresAt <= Date.now()) { throw new GitHubSessionCapabilityError('expired_capability'); } diff --git a/services/git-token-service/src/gitlab-session-capability.test.ts b/services/git-token-service/src/gitlab-session-capability.test.ts index 8a175a99af..232739bd2e 100644 --- a/services/git-token-service/src/gitlab-session-capability.test.ts +++ b/services/git-token-service/src/gitlab-session-capability.test.ts @@ -10,6 +10,7 @@ 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', @@ -31,12 +32,12 @@ describe('GitLabSessionCapabilityCodec', () => { const capability = codec.issue(claims); - expect(capability).toMatch(/^kgl1\./); + 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: 1, + version: 2, ...claims, issuedAt: Date.parse('2026-05-31T12:00:00.000Z'), expiresAt: Date.parse('2026-05-31T13:00:00.000Z'), @@ -44,6 +45,21 @@ describe('GitLabSessionCapabilityCodec', () => { 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')); @@ -73,12 +89,27 @@ describe('GitLabSessionCapabilityCodec', () => { ])('rejects encrypted claims with %s', (_description, overriddenClaims) => { const serializedClaims = JSON.stringify({ purpose: 'gitlab_scm_session', - version: 1, + 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( diff --git a/services/git-token-service/src/gitlab-session-capability.ts b/services/git-token-service/src/gitlab-session-capability.ts index a8b202deae..9a100c7656 100644 --- a/services/git-token-service/src/gitlab-session-capability.ts +++ b/services/git-token-service/src/gitlab-session-capability.ts @@ -2,9 +2,9 @@ import { decryptWithSymmetricKey, encryptWithSymmetricKey } from '@kilocode/encr import { z } from 'zod'; import { hasCanonicalEncryptedValueFormat } from './github-session-capability.js'; -const CAPABILITY_PREFIX = 'kgl1.'; +const LEGACY_CAPABILITY_PREFIX = 'kgl1.'; +const BOUND_CAPABILITY_PREFIX = 'kgl2.'; const CAPABILITY_PURPOSE = 'gitlab_scm_session'; -const CAPABILITY_VERSION = 1; const MAX_GITLAB_SCM_SESSION_CAPABILITY_LIFETIME_MS = 60 * 60 * 1000; const GitLabProjectPathSchema = z .string() @@ -30,22 +30,31 @@ const GitLabCapabilityCredentialSourceSchema = z.discriminatedUnion('type', [ }) .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 - .object({ - purpose: z.literal(CAPABILITY_PURPOSE), - version: z.literal(CAPABILITY_VERSION), - 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(), - }) - .strict() + .discriminatedUnion('version', [ + GitLabLegacySessionCapabilityClaimsSchema, + GitLabBoundSessionCapabilityClaimsSchema, + ]) .refine(claims => claims.expiresAt > claims.issuedAt) .refine( claims => claims.expiresAt - claims.issuedAt <= MAX_GITLAB_SCM_SESSION_CAPABILITY_LIFETIME_MS @@ -58,6 +67,7 @@ export type GitLabCapabilityCredentialSource = z.infer< >; export type GitLabSessionCapabilitySubject = { userId: string; + outboundContainerId?: string; orgId?: string; integrationId: string; instanceOrigin: string; @@ -179,29 +189,32 @@ export class GitLabSessionCapabilityCodec { issue(subject: GitLabSessionCapabilitySubject): string { const issuedAt = Date.now(); + const bound = subject.outboundContainerId !== undefined; const parsed = GitLabSessionCapabilityClaimsSchema.safeParse({ purpose: CAPABILITY_PURPOSE, - version: CAPABILITY_VERSION, + version: bound ? 2 : 1, ...subject, issuedAt, expiresAt: issuedAt + MAX_GITLAB_SCM_SESSION_CAPABILITY_LIFETIME_MS, }); if (!parsed.success) throw new GitLabSessionCapabilityError('invalid_capability'); try { - return `${CAPABILITY_PREFIX}${encryptWithSymmetricKey( - JSON.stringify(parsed.data), - this.encryptionKey - )}`; + 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 { - if (!capability.startsWith(CAPABILITY_PREFIX)) { - throw new GitLabSessionCapabilityError('invalid_capability'); - } - const encrypted = capability.slice(CAPABILITY_PREFIX.length); + 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'); } @@ -218,7 +231,9 @@ export class GitLabSessionCapabilityCodec { throw new GitLabSessionCapabilityError('invalid_capability'); } const parsed = GitLabSessionCapabilityClaimsSchema.safeParse(value); - if (!parsed.success) throw new GitLabSessionCapabilityError('invalid_capability'); + if (!parsed.success || parsed.data.version !== format.version) { + throw new GitLabSessionCapabilityError('invalid_capability'); + } if (parsed.data.expiresAt <= Date.now()) { throw new GitLabSessionCapabilityError('expired_capability'); } diff --git a/services/git-token-service/src/index.test.ts b/services/git-token-service/src/index.test.ts index 2f9a1e7c8d..dac14ccdaf 100644 --- a/services/git-token-service/src/index.test.ts +++ b/services/git-token-service/src/index.test.ts @@ -475,6 +475,8 @@ describe('GitTokenRPCEntrypoint.getTokenForRepo', () => { }); }); +const outboundContainerId = 'outbound-container-1'; + describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { beforeEach(() => { vi.clearAllMocks(); @@ -498,6 +500,7 @@ describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { const result = await createService().issueGitHubSessionCapability({ githubRepo: 'Acme/Repo', userId: 'user_1', + outboundContainerId, allowUserAuthorization: true, }); @@ -510,7 +513,7 @@ describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { gitAuthor: { name: 'octocat' }, }); if (!result.success) throw new Error('Expected successful issuance'); - expect(result.capability).toMatch(/^kgh1\./); + expect(result.capability).toMatch(/^kgh2\./); expect(JSON.stringify(result)).not.toContain('user-token'); expect(result).not.toHaveProperty('githubToken'); }); @@ -519,6 +522,7 @@ describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { const result = await createService().issueGitHubSessionCapability({ githubRepo: 'acme/repo', userId: 'user_1', + outboundContainerId, }); expect(result).toMatchObject({ success: true, source: 'installation' }); @@ -540,15 +544,88 @@ describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { ); await expect( - service.issueGitHubSessionCapability({ githubRepo: 'acme/repo', userId: 'user_1' }) + 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(); @@ -560,6 +637,7 @@ describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { await expect( service.redeemGitHubSessionCapability({ capability: tamperedCapability, + outboundContainerId, requestMethod: 'GET', requestUrl: 'https://github.com/acme/repo.git/info/refs?service=git-upload-pack', }) @@ -580,12 +658,14 @@ describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { 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, }); @@ -604,6 +684,7 @@ describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { const issued = await service.issueGitHubSessionCapability({ githubRepo: 'acme/repo', userId: 'user_1', + outboundContainerId, }); if (!issued.success) throw new Error('Expected successful issuance'); serviceMocks.getTokenForRepo.mockRejectedValueOnce( @@ -612,6 +693,7 @@ describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { const redemption = await service.redeemGitHubSessionCapability({ capability: issued.capability, + outboundContainerId, requestMethod: 'GET', requestUrl: 'https://github.com/acme/repo.git/info/refs?service=git-upload-pack', }); @@ -629,12 +711,14 @@ describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { 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: 'POST', requestUrl, }); @@ -662,6 +746,7 @@ describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { const issued = await service.issueGitHubSessionCapability({ githubRepo: 'acme/repo', userId: 'user_1', + outboundContainerId, }); if (!issued.success) throw new Error('Expected successful issuance'); serviceMocks.getTokenForRepo.mockClear(); @@ -669,6 +754,7 @@ describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { await expect( service.redeemGitHubSessionCapability({ capability: issued.capability, + outboundContainerId, requestMethod, requestUrl, }) @@ -682,6 +768,7 @@ describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { const issued = await service.issueGitHubSessionCapability({ githubRepo: 'acme/repo', userId: 'user_1', + outboundContainerId, allowUserAuthorization: true, }); if (!issued.success) throw new Error('Expected successful issuance'); @@ -694,6 +781,7 @@ describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { const redemption = await service.redeemGitHubSessionCapability({ capability: issued.capability, + outboundContainerId, requestMethod: 'GET', requestUrl: 'https://api.github.com/user/repos', }); @@ -712,6 +800,7 @@ describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { const issued = await service.issueGitHubSessionCapability({ githubRepo: 'acme/repo', userId: 'user_1', + outboundContainerId, allowUserAuthorization: true, }); if (!issued.success) throw new Error('Expected successful issuance'); @@ -721,6 +810,7 @@ describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { await expect( service.redeemGitHubSessionCapability({ capability: issued.capability, + outboundContainerId, requestMethod, requestUrl, }) @@ -771,6 +861,7 @@ describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { const issued = await service.issueGitHubSessionCapability({ githubRepo: 'acme/repo', userId: 'user_1', + outboundContainerId, }); if (!issued.success) throw new Error('Expected successful issuance'); serviceMocks.getTokenForRepo.mockClear(); @@ -778,6 +869,7 @@ describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { await expect( service.redeemGitHubSessionCapability({ capability: issued.capability, + outboundContainerId, requestMethod, requestUrl, }) @@ -791,6 +883,7 @@ describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { const issued = await service.issueGitHubSessionCapability({ githubRepo: 'acme/repo', userId: 'user_1', + outboundContainerId, allowUserAuthorization: true, }); if (!issued.success) throw new Error('Expected successful issuance'); @@ -803,6 +896,7 @@ describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { await expect( service.redeemGitHubSessionCapability({ capability: issued.capability, + outboundContainerId, requestMethod: 'GET', requestUrl: 'https://api.github.com/repos/acme/repo', }) @@ -815,6 +909,7 @@ describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { const issued = await service.issueGitHubSessionCapability({ githubRepo: 'acme/repo', userId: 'user_1', + outboundContainerId, allowUserAuthorization: true, }); if (!issued.success) throw new Error('Expected successful issuance'); @@ -827,6 +922,7 @@ describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { await expect( service.redeemGitHubSessionCapability({ capability: issued.capability, + outboundContainerId, requestMethod: 'GET', requestUrl: 'https://api.github.com/user/repos', }) @@ -838,6 +934,7 @@ describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { const issued = await service.issueGitHubSessionCapability({ githubRepo: 'acme/repo', userId: 'user_1', + outboundContainerId, }); if (!issued.success) throw new Error('Expected successful issuance'); serviceMocks.findManagedInstallationForRepo.mockResolvedValueOnce({ @@ -852,6 +949,7 @@ describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { await expect( service.redeemGitHubSessionCapability({ capability: issued.capability, + outboundContainerId, requestMethod: 'GET', requestUrl: 'https://github.com/acme/repo.git/info/refs?service=git-upload-pack', }) @@ -863,12 +961,14 @@ describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { 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', }) @@ -929,6 +1029,7 @@ describe('GitTokenRPCEntrypoint GitLab session capability RPCs', () => { const result = await createService().issueGitLabSessionCapability({ gitUrl, userId: 'user_1', + outboundContainerId, }); expect(result).toMatchObject({ @@ -941,12 +1042,86 @@ describe('GitTokenRPCEntrypoint GitLab session capability RPCs', () => { identity: { accountId: '42', accountLogin: 'octocat' }, }); if (!result.success) throw new Error('Expected successful issuance'); - expect(result.capability).toMatch(/^kgl1\./); + 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({ @@ -978,6 +1153,7 @@ describe('GitTokenRPCEntrypoint GitLab session capability RPCs', () => { const result = await createService().issueGitLabSessionCapability({ gitUrl: 'https://gitlab.com/acme/widgets.git', userId: 'user_1', + outboundContainerId, createdOnPlatform: 'code-review', }); @@ -991,7 +1167,7 @@ describe('GitTokenRPCEntrypoint GitLab session capability RPCs', () => { glabIsOAuth2: false, }); if (!result.success) throw new Error('Expected successful issuance'); - expect(result.capability).toMatch(/^kgl1\./); + expect(result.capability).toMatch(/^kgl2\./); expect(JSON.stringify(result)).not.toContain('project-access-token'); expect(result).not.toHaveProperty('token'); }); @@ -1032,6 +1208,7 @@ describe('GitTokenRPCEntrypoint GitLab session capability RPCs', () => { 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'); @@ -1040,6 +1217,7 @@ describe('GitTokenRPCEntrypoint GitLab session capability RPCs', () => { await expect( service.redeemGitLabSessionCapability({ capability: issued.capability, + outboundContainerId, requestMethod, requestUrl, }) @@ -1071,6 +1249,7 @@ describe('GitTokenRPCEntrypoint GitLab session capability RPCs', () => { 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'); @@ -1085,6 +1264,7 @@ describe('GitTokenRPCEntrypoint GitLab session capability RPCs', () => { await expect( service.redeemGitLabSessionCapability({ capability: issued.capability, + outboundContainerId, requestMethod: 'GET', requestUrl: 'https://gitlab.com/api/v4/projects/42/issues', }) @@ -1122,6 +1302,7 @@ describe('GitTokenRPCEntrypoint GitLab session capability RPCs', () => { 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({ @@ -1133,6 +1314,7 @@ describe('GitTokenRPCEntrypoint GitLab session capability RPCs', () => { await expect( service.redeemGitLabSessionCapability({ capability: issued.capability, + outboundContainerId, requestMethod, requestUrl, }) @@ -1177,6 +1359,7 @@ describe('GitTokenRPCEntrypoint GitLab session capability RPCs', () => { 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({ @@ -1188,6 +1371,7 @@ describe('GitTokenRPCEntrypoint GitLab session capability RPCs', () => { await expect( service.redeemGitLabSessionCapability({ capability: issued.capability, + outboundContainerId, requestMethod, requestUrl, }) @@ -1226,6 +1410,7 @@ describe('GitTokenRPCEntrypoint GitLab session capability RPCs', () => { 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(); @@ -1233,6 +1418,7 @@ describe('GitTokenRPCEntrypoint GitLab session capability RPCs', () => { await expect( service.redeemGitLabSessionCapability({ capability: issued.capability, + outboundContainerId, requestMethod: 'GET', requestUrl, }) @@ -1252,6 +1438,7 @@ describe('GitTokenRPCEntrypoint GitLab session capability RPCs', () => { service.issueGitLabSessionCapability({ gitUrl: 'https://gitlab.com/acme/widgets.git', userId: 'user_1', + outboundContainerId, }) ).resolves.toEqual({ success: false, reason: 'capability_configuration_error' }); }); @@ -1274,6 +1461,7 @@ describe('GitTokenRPCEntrypoint GitLab session capability RPCs', () => { const result = await createService().issueGitLabSessionCapability({ gitUrl: 'https://gitlab.com/acme/widgets.git', userId: 'user_1', + outboundContainerId, }); expect(result).toMatchObject({ success: true, authType: 'pat' }); @@ -1294,6 +1482,7 @@ describe('GitTokenRPCEntrypoint GitLab session capability RPCs', () => { 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(); @@ -1305,6 +1494,7 @@ describe('GitTokenRPCEntrypoint GitLab session capability RPCs', () => { const result = await service.redeemGitLabSessionCapability({ capability: issued.capability, + outboundContainerId, requestMethod, requestUrl, }); @@ -1345,6 +1535,7 @@ describe('GitTokenRPCEntrypoint GitLab session capability RPCs', () => { 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(); @@ -1352,6 +1543,7 @@ describe('GitTokenRPCEntrypoint GitLab session capability RPCs', () => { await expect( service.redeemGitLabSessionCapability({ capability: issued.capability, + outboundContainerId, requestMethod, requestUrl, }) @@ -1364,6 +1556,7 @@ describe('GitTokenRPCEntrypoint GitLab session capability RPCs', () => { 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({ @@ -1375,6 +1568,7 @@ describe('GitTokenRPCEntrypoint GitLab session capability RPCs', () => { await expect( service.redeemGitLabSessionCapability({ capability: issued.capability, + outboundContainerId, requestMethod: 'GET', requestUrl: 'https://gitlab.com/api/v4/projects', }) @@ -1387,6 +1581,7 @@ describe('GitTokenRPCEntrypoint GitLab session capability RPCs', () => { 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({ @@ -1401,6 +1596,7 @@ describe('GitTokenRPCEntrypoint GitLab session capability RPCs', () => { await expect( service.redeemGitLabSessionCapability({ capability: issued.capability, + outboundContainerId, requestMethod: 'GET', requestUrl: 'https://gitlab.com/api/v4/projects', }) diff --git a/services/git-token-service/src/index.ts b/services/git-token-service/src/index.ts index 554cfec840..babced11b3 100644 --- a/services/git-token-service/src/index.ts +++ b/services/git-token-service/src/index.ts @@ -90,7 +90,9 @@ export type GetCloudAgentAuthForRepoResult = | GetCloudAgentAuthForRepoSuccess | GetTokenForRepoFailure; -export type IssueGitHubSessionCapabilityParams = GetCloudAgentAuthForRepoParams; +export type IssueGitHubSessionCapabilityParams = GetCloudAgentAuthForRepoParams & { + outboundContainerId?: string; +}; export type IssueGitHubSessionCapabilitySuccess = Omit< GetCloudAgentAuthForRepoSuccess, 'githubToken' @@ -104,6 +106,7 @@ export type IssueGitHubSessionCapabilityResult = export type RedeemGitHubSessionCapabilityParams = { capability: string; + outboundContainerId?: string; requestMethod: string; requestUrl: string; }; @@ -113,6 +116,7 @@ export type RedeemGitHubSessionCapabilitySuccess = { }; export type RedeemGitHubSessionCapabilityFailureReason = | GitHubSessionCapabilityFailureReason + | 'container_mismatch' | 'invalid_upstream_url' | 'upstream_host_not_allowed' | 'repository_mismatch' @@ -125,6 +129,7 @@ export type RedeemGitHubSessionCapabilityResult = export type IssueGitLabSessionCapabilityParams = GetGitLabTokenParams & { gitUrl: string; + outboundContainerId?: string; }; export type IssueGitLabSessionCapabilitySuccess = { success: true; @@ -144,11 +149,13 @@ export type IssueGitLabSessionCapabilityResult = | { 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' @@ -477,6 +484,9 @@ export class GitTokenRPCEntrypoint extends WorkerEntrypoint { 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, @@ -512,6 +522,10 @@ export class GitTokenRPCEntrypoint extends WorkerEntrypoint { 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, @@ -670,6 +684,9 @@ export class GitTokenRPCEntrypoint extends WorkerEntrypoint { 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, @@ -709,6 +726,10 @@ export class GitTokenRPCEntrypoint extends WorkerEntrypoint { 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,