diff --git a/services/cloud-agent-next/src/sandbox-outbound.test.ts b/services/cloud-agent-next/src/sandbox-outbound.test.ts index b9f3a2c0dd6..f07f6e04a99 100644 --- a/services/cloud-agent-next/src/sandbox-outbound.test.ts +++ b/services/cloud-agent-next/src/sandbox-outbound.test.ts @@ -377,6 +377,35 @@ describe('handleManagedScmOutbound', () => { expect(logs).not.toContain('upstream-token'); }); + it('forwards a request that needs no credential', async () => { + const redeemGitHubSessionCapability = vi.fn().mockResolvedValue({ + success: true, + authorization: null, + installationId: 'installation-123', + source: 'user', + appType: 'standard', + }); + const fetchMock = vi.fn().mockResolvedValue(new Response('Not Found', { status: 404 })); + vi.stubGlobal('fetch', fetchMock); + + const response = await handleOutbound( + new Request('https://github.com/other/project.git/info/refs?service=git-upload-pack', { + headers: { + Authorization: basicCredential(CAPABILITY), + 'User-Agent': 'git/2.43.0', + }, + }), + createEnv(redeemGitHubSessionCapability) + ); + + expect(response.status).toBe(404); + expect(redeemGitHubSessionCapability).toHaveBeenCalledOnce(); + const forwarded = fetchMock.mock.calls[0]?.[0]; + if (!(forwarded instanceof Request)) throw new Error('Expected a forwarded request'); + expect(forwarded.headers.get('Authorization')).toBeNull(); + expect(forwarded.headers.get('User-Agent')).toBe('git/2.43.0'); + }); + it('keeps forwarding and logs null correlation fields for an older redemption response', async () => { const redeemGitHubSessionCapability = vi.fn().mockResolvedValue({ success: true, diff --git a/services/cloud-agent-next/src/sandbox-outbound.ts b/services/cloud-agent-next/src/sandbox-outbound.ts index 659018cf254..0cb8a22487b 100644 --- a/services/cloud-agent-next/src/sandbox-outbound.ts +++ b/services/cloud-agent-next/src/sandbox-outbound.ts @@ -572,7 +572,7 @@ async function handleManagedGitHubOutbound( return new Response('GitHub authorization unavailable', { status: 502 }); } - const headersToApply = { authorization: result.authorization }; + const headersToApply = { authorization: result.authorization ?? undefined }; let response: Response; try { if (!isGitHubGitUploadRetryableRequest(request)) { diff --git a/services/cloud-agent-next/src/types.ts b/services/cloud-agent-next/src/types.ts index cfaa915b36f..244c070ccd2 100644 --- a/services/cloud-agent-next/src/types.ts +++ b/services/cloud-agent-next/src/types.ts @@ -267,7 +267,8 @@ type IssueGitHubSessionCapabilityResult = type RedeemGitHubSessionCapabilityResult = | { success: true; - authorization: string; + /** Null means the request is forwarded anonymously. */ + authorization: string | null; installationId?: string; source?: 'user' | 'installation'; appType?: 'standard' | 'lite'; diff --git a/services/git-token-service/src/index.test.ts b/services/git-token-service/src/index.test.ts index 9a7c84af7b4..800784abc3f 100644 --- a/services/git-token-service/src/index.test.ts +++ b/services/git-token-service/src/index.test.ts @@ -1782,6 +1782,8 @@ describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { it.each([ 'https://github.com/acme/repo.git/info/lfs/objects/batch', 'https://github.com/acme/repo.git/info/lfs/locks/verify', + 'https://github.com/acme/repo.git/info/lfs/locks', + 'https://github.com/acme/repo.git/info/lfs/locks/42/unlock', ])('redeems an installation-pinned capability for exact LFS control URL %s', async requestUrl => { const service = createService(); const issued = await service.issueGitHubSessionCapability({ @@ -1810,16 +1812,21 @@ describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { }); it.each([ - ['POST', 'https://api.github.com/graphql'], - ['GET', 'https://api.github.com/user/repos'], - ['DELETE', 'https://api.github.com/repos/acme/other/issues/42/comments/1'], + ['GET', 'https://api.github.com/repos/acme/repo/pulls/42'], + ['POST', 'https://api.github.com/repos/acme/repo/issues/42/comments'], + ['PUT', 'https://api.github.com/repos/acme/repo/pulls/42/merge'], + ['DELETE', 'https://api.github.com/repos/acme/repo/issues/42/labels/bug'], ['GET', 'https://api.github.com/repos/acme/repo/contents/src%2Findex.ts'], + ['GET', 'https://api.github.com/user'], + ['POST', 'https://api.github.com/graphql'], + ['POST', 'https://uploads.github.com/repos/acme/repo/releases/1/assets?name=asset.zip'], ['GET', 'https://npm.pkg.github.com/@acme%2fdesign'], ['GET', 'https://npm.pkg.github.com/download/@acme/design/1.0.0/package.tgz'], - ['POST', 'https://uploads.github.com/repos/acme/other/releases/1/assets?name=asset.zip'], - ['POST', 'https://github.com/acme/other.git/info/lfs/objects/batch'], + ['GET', 'https://github.com/acme/repo.git/info/refs?service=git-upload-pack'], + ['GET', 'https://github.com/acme/repo/info/refs?service=git-upload-pack'], + ['POST', 'https://github.com/acme/repo.git/git-receive-pack'], ] as const)( - 'redeems an installation-pinned capability for unrestricted GitHub request %s %s', + 'redeems an installation-pinned capability for its own repository request %s %s', async (requestMethod, requestUrl) => { const service = createService(); const issued = await service.issueGitHubSessionCapability({ @@ -1850,6 +1857,45 @@ describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { } ); + it.each([ + ['GET', 'https://api.github.com/repos/acme/other/pulls/42', 'repository_mismatch'], + ['GET', 'https://api.github.com/user/repos', 'repository_mismatch'], + ['GET', 'https://api.github.com/graphql', 'invalid_upstream_request'], + ['OPTIONS', 'https://api.github.com/repos/acme/repo/pulls/42', 'invalid_upstream_request'], + [ + 'POST', + 'https://uploads.github.com/repos/acme/other/releases/1/assets?name=asset.zip', + 'repository_mismatch', + ], + ['GET', 'https://npm.pkg.github.com/@other%2fdesign', 'repository_mismatch'], + ['POST', 'https://npm.pkg.github.com/@acme%2fdesign', 'invalid_upstream_request'], + ['GET', 'https://github.com/acme/other/issues', 'repository_mismatch'], + ['GET', 'https://github.com/acme/other.git/archive/main.tar.gz', 'repository_mismatch'], + ['GET', 'https://api.github.com/repos/acme/repo/files/..%2f..%2fother', 'invalid_upstream_url'], + ] as const)( + 'applies the recorded repository scope to installation-pinned request %s %s', + async (requestMethod, requestUrl, reason) => { + const service = createService(); + const issued = await service.issueGitHubSessionCapability({ + githubRepo: 'acme/repo', + userId: 'user_1', + outboundContainerId, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.getTokenForRepo.mockClear(); + + await expect( + service.redeemGitHubSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod, + requestUrl, + }) + ).resolves.toEqual({ success: false, reason }); + expect(serviceMocks.getTokenForRepo).not.toHaveBeenCalled(); + } + ); + it.each([ ['POST', 'https://api.github.com/repos/acme/repo/issues/42/comments'], ['PATCH', 'https://api.github.com/repos/acme/repo/issues/comments/123'], @@ -1921,16 +1967,21 @@ describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { }); it.each([ - ['POST', 'https://api.github.com/graphql'], - ['GET', 'https://api.github.com/user/repos'], - ['DELETE', 'https://api.github.com/repos/acme/other/issues/42/comments/1'], + ['GET', 'https://api.github.com/repos/acme/repo/pulls/42'], + ['PATCH', 'https://api.github.com/repos/acme/repo/pulls/42'], + ['DELETE', 'https://api.github.com/repos/acme/repo/issues/42/labels/bug'], ['GET', 'https://api.github.com/repos/acme/repo/contents/src%2Findex.ts'], + ['GET', 'https://api.github.com/user'], + ['POST', 'https://api.github.com/graphql'], + ['POST', 'https://uploads.github.com/repos/acme/repo/releases/1/assets?name=asset.zip'], ['GET', 'https://npm.pkg.github.com/@acme%2fdesign'], ['GET', 'https://npm.pkg.github.com/download/@acme/design/1.0.0/package.tgz'], - ['POST', 'https://uploads.github.com/repos/acme/other/releases/1/assets?name=asset.zip'], - ['GET', 'https://github.com/acme/other.git/info/refs?service=git-upload-pack'], + ['GET', 'https://github.com/acme/repo/info/refs?service=git-upload-pack'], + ['POST', 'https://github.com/acme/repo.git/info/lfs/objects/batch'], + ['GET', 'https://github.com/acme/repo.git/info/lfs/locks?path=asset.bin&limit=100'], + ['POST', 'https://github.com/acme/repo.git/info/lfs/locks'], ] as const)( - 'redeems a selected-user capability for unrestricted GitHub request %s %s', + 'redeems a selected-user capability for its own repository request %s %s', async (requestMethod, requestUrl) => { const service = createService(); const issued = await service.issueGitHubSessionCapability({ @@ -1963,6 +2014,194 @@ describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { } ); + it('redeems GraphQL for a selected-user capability with the user token', async () => { + const service = createService(); + const issued = await service.issueGitHubSessionCapability({ + githubRepo: 'acme/repo', + userId: 'user_1', + outboundContainerId, + allowUserAuthorization: true, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.selectUserAuthorization.mockClear(); + serviceMocks.getTokenForRepo.mockClear(); + + await expect( + service.redeemGitHubSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod: 'POST', + requestUrl: 'https://api.github.com/graphql', + }) + ).resolves.toEqual({ + success: true, + authorization: 'Bearer user-token', + installationId: '123', + source: 'user', + appType: 'standard', + }); + expect(serviceMocks.selectUserAuthorization).toHaveBeenCalledOnce(); + expect(serviceMocks.getTokenForRepo).not.toHaveBeenCalled(); + }); + + it('redeems GraphQL for an installation-source capability with the installation token', 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'); + expect(issued.source).toBe('installation'); + serviceMocks.selectUserAuthorization.mockClear(); + serviceMocks.getTokenForRepo.mockClear(); + + await expect( + service.redeemGitHubSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod: 'POST', + requestUrl: 'https://api.github.com/graphql', + }) + ).resolves.toEqual({ + success: true, + authorization: 'Bearer installation-token', + installationId: '123', + source: 'installation', + appType: 'standard', + }); + expect(serviceMocks.getTokenForRepo).toHaveBeenCalledOnce(); + expect(serviceMocks.selectUserAuthorization).not.toHaveBeenCalled(); + }); + + it('refuses GraphQL for a selected-user capability whose pin no longer resolves', async () => { + const service = createService(); + const issued = await service.issueGitHubSessionCapability({ + githubRepo: 'acme/repo', + userId: 'user_1', + outboundContainerId, + allowUserAuthorization: true, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.selectUserAuthorization.mockClear(); + serviceMocks.selectUserAuthorization.mockResolvedValueOnce({ + selected: false, + reason: 'no_user_authorization', + }); + serviceMocks.getTokenForRepo.mockClear(); + + await expect( + service.redeemGitHubSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod: 'POST', + requestUrl: 'https://api.github.com/graphql', + }) + ).resolves.toEqual({ success: false, reason: 'source_unavailable' }); + expect(serviceMocks.selectUserAuthorization).toHaveBeenCalledOnce(); + expect(serviceMocks.getTokenForRepo).not.toHaveBeenCalled(); + }); + + it('refuses GraphQL for a selected-user capability when the installation integration no longer matches', async () => { + const service = createService(); + const issued = await service.issueGitHubSessionCapability({ + githubRepo: 'acme/repo', + userId: 'user_1', + outboundContainerId, + allowUserAuthorization: true, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.findManagedInstallationForRepo.mockResolvedValueOnce({ + success: false, + reason: 'integration_mismatch', + }); + serviceMocks.findRefreshCandidates.mockResolvedValueOnce({ success: true, candidates: [] }); + serviceMocks.getTokenForRepo.mockClear(); + + await expect( + service.redeemGitHubSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod: 'POST', + requestUrl: 'https://api.github.com/graphql', + }) + ).resolves.toEqual({ success: false, reason: 'integration_mismatch' }); + expect(serviceMocks.getTokenForRepo).not.toHaveBeenCalled(); + }); + + it('forwards a Git request for another repository anonymously', async () => { + const service = createService(); + const issued = await service.issueGitHubSessionCapability({ + githubRepo: 'acme/repo', + userId: 'user_1', + outboundContainerId, + allowUserAuthorization: true, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.selectUserAuthorization.mockClear(); + serviceMocks.getTokenForRepo.mockClear(); + + await expect( + service.redeemGitHubSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod: 'GET', + requestUrl: 'https://github.com/acme/other.git/info/refs?service=git-upload-pack', + }) + ).resolves.toEqual({ + success: true, + authorization: null, + installationId: '123', + source: 'user', + appType: 'standard', + }); + expect(serviceMocks.selectUserAuthorization).not.toHaveBeenCalled(); + expect(serviceMocks.getTokenForRepo).not.toHaveBeenCalled(); + }); + + it.each([ + ['GET', 'https://api.github.com/repos/acme/other/pulls/42', 'repository_mismatch'], + ['GET', 'https://api.github.com/user/repos', 'repository_mismatch'], + [ + 'DELETE', + 'https://api.github.com/repos/acme/other/issues/42/comments/1', + 'repository_mismatch', + ], + [ + 'POST', + 'https://uploads.github.com/repos/acme/other/releases/1/assets?name=asset.zip', + 'repository_mismatch', + ], + ['GET', 'https://npm.pkg.github.com/@other%2fdesign', 'repository_mismatch'], + ['OPTIONS', 'https://api.github.com/repos/acme/repo/pulls/42', 'invalid_upstream_request'], + ['GET', 'https://github.com/acme/repo2/archive/main.tar.gz', 'repository_mismatch'], + ['GET', 'https://github.com/acme/other/issues', 'repository_mismatch'], + ] as const)( + 'applies the recorded repository scope to selected-user request %s %s', + async (requestMethod, requestUrl, reason) => { + const service = createService(); + const issued = await service.issueGitHubSessionCapability({ + githubRepo: 'acme/repo', + userId: 'user_1', + outboundContainerId, + allowUserAuthorization: true, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + expect(issued.source).toBe('user'); + serviceMocks.selectUserAuthorization.mockClear(); + + await expect( + service.redeemGitHubSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod, + requestUrl, + }) + ).resolves.toEqual({ success: false, reason }); + expect(serviceMocks.selectUserAuthorization).not.toHaveBeenCalled(); + } + ); + it.each([ [ 'GET', diff --git a/services/git-token-service/src/index.ts b/services/git-token-service/src/index.ts index 3c928c55b98..afc24af459b 100644 --- a/services/git-token-service/src/index.ts +++ b/services/git-token-service/src/index.ts @@ -192,7 +192,8 @@ export type RedeemGitHubSessionCapabilityParams = { }; export type RedeemGitHubSessionCapabilitySuccess = { success: true; - authorization: string; + /** Null means the request is forwarded anonymously. */ + authorization: string | null; installationId: string; source: 'user' | 'installation'; appType: GitHubAppType; @@ -397,26 +398,194 @@ async function readBoundedInternalJsonRequest(request: Request): Promise` (metadata, tarball, and download paths). + */ +function isOwnerPackageScopePath(path: string, owner: string): boolean { + const scope = `@${owner}`; + const segments = path.split('/'); + return ( + (segments[1] === scope && segments.length >= 3) || + (segments[1] === 'download' && segments[2] === scope && segments.length >= 4) + ); +} + +function parseGitHubCapabilityRequest( + requestMethod: string, requestUrl: string -): RedeemGitHubSessionCapabilityFailureReason | null { +): + | { failure: RedeemGitHubSessionCapabilityFailureReason; request?: never } + | { + failure: null; + request: { url: URL; method: string; path: string }; + } { let url: URL; try { url = new URL(requestUrl); } catch { - return 'invalid_upstream_url'; + return { failure: 'invalid_upstream_url' }; } - if (url.protocol !== 'https:') return 'invalid_upstream_url'; - if (url.username || url.password || url.hash) return 'invalid_upstream_url'; - if (url.port !== '') return 'upstream_host_not_allowed'; - if ( - !['api.github.com', 'uploads.github.com', 'github.com', 'npm.pkg.github.com'].includes( - url.hostname - ) - ) { - return 'upstream_host_not_allowed'; + if (url.protocol !== 'https:') return { failure: 'invalid_upstream_url' }; + if (url.username || url.password || url.hash) return { failure: 'invalid_upstream_url' }; + if (url.port !== '') return { failure: 'upstream_host_not_allowed' }; + const path = decodePathnameIteratively(url.pathname); + if (path === null || path.includes('\\') || /(?:^|\/)\.{1,2}(?:\/|$)/.test(path)) { + return { failure: 'invalid_upstream_url' }; } - return null; + return { + failure: null, + request: { url, method: requestMethod.toUpperCase(), path: path.toLowerCase() }, + }; +} + +function validateGitHubCapabilityUpstream( + requestMethod: string, + requestUrl: string, + repository: { owner: string; repo: string } +): GitHubCapabilityUpstream { + const parsed = parseGitHubCapabilityRequest(requestMethod, requestUrl); + if (parsed.failure) return { failure: parsed.failure }; + const { url, method, path } = parsed.request; + + if (url.hostname === 'api.github.com') { + if (path === '/user') { + return GITHUB_IDENTITY_METHODS.includes(method) + ? { failure: null, credential: 'pinned' } + : { failure: 'invalid_upstream_request' }; + } + if (path === '/graphql') { + // gh resolves pull-request views and mutations through GraphQL. The endpoint is + // not addressed by repository path, so the capability's own credential answers it: + // the recorded user's token for a user source, the repository-scoped installation + // token for an installation source. + return method === 'POST' + ? { failure: null, credential: 'pinned' } + : { failure: 'invalid_upstream_request' }; + } + if (!isRepositoryApiPath(path, repository)) return { failure: 'repository_mismatch' }; + return GITHUB_REPOSITORY_API_METHODS.includes(method) + ? { failure: null, credential: 'pinned' } + : { failure: 'invalid_upstream_request' }; + } + if (url.hostname === 'uploads.github.com') { + if (!isRepositoryApiPath(path, repository)) return { failure: 'repository_mismatch' }; + return GITHUB_UPLOAD_METHODS.includes(method) + ? { failure: null, credential: 'pinned' } + : { failure: 'invalid_upstream_request' }; + } + if (url.hostname === 'npm.pkg.github.com') { + if (!GITHUB_REGISTRY_METHODS.includes(method)) return { failure: 'invalid_upstream_request' }; + return isOwnerPackageScopePath(path, repository.owner) + ? { failure: null, credential: 'pinned' } + : { failure: 'repository_mismatch' }; + } + if (url.hostname === 'github.com') { + if (matchesRepositoryGitRequest(method, url, path, repository)) { + return { failure: null, credential: 'pinned' }; + } + if (matchesAnonymousGitHubGitRequest(method, url, path)) { + return { failure: null, credential: 'none' }; + } + return isRepositoryRootPath(path, repository) + ? { failure: 'invalid_upstream_request' } + : { failure: 'repository_mismatch' }; + } + return { failure: 'upstream_host_not_allowed' }; } function validateLegacyGitHubCapabilityUpstream( @@ -427,25 +596,19 @@ function validateLegacyGitHubCapabilityUpstream( if (/%2f|%5c/i.test(requestUrl) || /\/(?:(?:\.|%2e){1,2})(?:\/|$)/i.test(requestUrl)) { return 'invalid_upstream_url'; } - 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 || url.hash) return 'invalid_upstream_url'; - const method = requestMethod.toUpperCase(); - if (url.hostname === 'api.github.com' && url.port === '') { + const parsed = parseGitHubCapabilityRequest(requestMethod, requestUrl); + if (parsed.failure) return parsed.failure; + const { url, method, path } = parsed.request; + + if (url.hostname === 'api.github.com') { if (!['GET', 'POST', 'PATCH', 'HEAD'].includes(method)) { return 'invalid_upstream_request'; } - const repositoryApiPath = `/repos/${repository.owner}/${repository.repo}`; - const path = url.pathname.toLowerCase(); - if (path !== repositoryApiPath && !path.startsWith(`${repositoryApiPath}/`)) { + const repositoryApiPrefix = repositoryApiPath(repository); + if (!isRepositoryApiPath(path, repository)) { return 'repository_mismatch'; } - const relativePath = path.slice(repositoryApiPath.length); + const relativePath = path.slice(repositoryApiPrefix.length); if ( ['GET', 'HEAD'].includes(method) && (/^\/pulls\/[1-9]\d*$/.test(relativePath) || @@ -459,30 +622,11 @@ function validateLegacyGitHubCapabilityUpstream( if (method === 'POST' && /^\/pulls\/[1-9]\d*\/reviews$/.test(relativePath)) return null; return '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'; + if (url.hostname !== 'github.com') return 'upstream_host_not_allowed'; + if (!isRepositoryRootPath(path, repository)) return 'repository_mismatch'; + return matchesRepositoryGitRequest(method, url, path, repository) + ? null + : 'invalid_upstream_request'; } function isGitLabGitAuthPath(pathname: string): boolean { @@ -1015,11 +1159,18 @@ export class GitTokenRPCEntrypoint extends WorkerEntrypoint { return { success: false, reason: 'container_mismatch' }; } - const upstreamFailure = + const upstream = claims.version === 2 - ? validateGitHubCapabilityUpstream(params.requestUrl) - : validateLegacyGitHubCapabilityUpstream(params.requestMethod, params.requestUrl, claims); - if (upstreamFailure) return { success: false, reason: upstreamFailure }; + ? validateGitHubCapabilityUpstream(params.requestMethod, params.requestUrl, claims) + : { + failure: validateLegacyGitHubCapabilityUpstream( + params.requestMethod, + params.requestUrl, + claims + ), + credential: 'pinned' as const, + }; + if (upstream.failure) return { success: false, reason: upstream.failure }; const authParams = { userId: claims.userId, @@ -1030,6 +1181,16 @@ export class GitTokenRPCEntrypoint extends WorkerEntrypoint { : {}), githubRepo: `${claims.owner}/${claims.repo}`, }; + if (upstream.credential === 'none') { + return { + success: true, + authorization: null, + installationId: claims.identity.installationId, + source: claims.source, + appType: claims.identity.appType, + }; + } + let auth: GetCloudAgentAuthForRepoResult | null; if (claims.source === 'user') { auth = await this.redeemPinnedUserAuthorization(authParams);