diff --git a/src/main/hosted-sandbox/dev-token-claims-probe.test.ts b/src/main/hosted-sandbox/dev-token-claims-probe.test.ts new file mode 100644 index 00000000000..bec3e60c296 --- /dev/null +++ b/src/main/hosted-sandbox/dev-token-claims-probe.test.ts @@ -0,0 +1,31 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { installDevTokenClaimsProbe, tokenOrgClaims } from './dev-token-claims-probe' + +const encode = (value: unknown): string => Buffer.from(JSON.stringify(value)).toString('base64url') +const jwt = (claims: unknown): string => `${encode({ alg: 'ES256' })}.${encode(claims)}.sig` + +describe('dev token claims probe (GH-LIST-MISSING)', () => { + afterEach(() => { + Reflect.deleteProperty(globalThis, '__orcaCloudTokenClaims') + }) + + it('reads only org, role and user from the payload', () => { + expect(tokenOrgClaims(jwt({ sub: 'u1', org: 'o1', org_role: 'owner', exp: 1 }))).toEqual({ + org: 'o1', + orgRole: 'owner', + sub: 'u1' + }) + expect(tokenOrgClaims(jwt({ sub: 'u1' }))).toEqual({ org: null, orgRole: null, sub: 'u1' }) + expect(tokenOrgClaims('not-a-token')).toEqual({ org: null, orgRole: null, sub: null }) + }) + + it('exists only in dev builds, and never hands back the token', async () => { + expect(installDevTokenClaimsProbe(async () => jwt({ sub: 'u1', org: 'o1' }), true)).toBeNull() + expect('__orcaCloudTokenClaims' in globalThis).toBe(false) + const probe = installDevTokenClaimsProbe(async () => jwt({ sub: 'u1', org: 'o1' }), false) + expect('__orcaCloudTokenClaims' in globalThis).toBe(true) + const answer = await probe?.() + expect(answer).toEqual({ org: 'o1', orgRole: null, sub: 'u1' }) + expect(JSON.stringify(answer)).not.toContain('sig') + }) +}) diff --git a/src/main/hosted-sandbox/dev-token-claims-probe.ts b/src/main/hosted-sandbox/dev-token-claims-probe.ts new file mode 100644 index 00000000000..a665fb8bb20 --- /dev/null +++ b/src/main/hosted-sandbox/dev-token-claims-probe.ts @@ -0,0 +1,37 @@ +// GH-LIST-MISSING diagnostics, dev builds only: which org the Orca Cloud token in use claims, next +// to the team the app shows. A token minted for another team (or none) explains an empty GitHub +// list after an owner → member → owner switch. Never the token itself, and never in packaged builds. + +type ClaimsProbe = () => Promise<{ org: string | null; orgRole: string | null; sub: string | null }> + +/** Reads the claims of a JWT's payload without verifying it; nothing else of the token leaves. */ +export function tokenOrgClaims(token: string): Awaited> { + const payload = token.split('.')[1] + let claims: Record = {} + try { + const parsed: unknown = JSON.parse(Buffer.from(payload ?? '', 'base64url').toString('utf8')) + if (parsed && typeof parsed === 'object') { + claims = { ...parsed } + } + } catch { + // Not a JWT: report nothing rather than guess. + } + const text = (value: unknown): string | null => (typeof value === 'string' ? value : null) + return { org: text(claims.org), orgRole: text(claims.org_role), sub: text(claims.sub) } +} + +/** + * Dev builds: `globalThis.__orcaCloudTokenClaims()` in the main process (the harness's inspector) + * answers the org, role and user the current sandbox token claims. + */ +export function installDevTokenClaimsProbe( + getAccessToken: () => Promise, + packaged: boolean +): ClaimsProbe | null { + if (packaged) { + return null + } + const probe: ClaimsProbe = async () => tokenOrgClaims(await getAccessToken()) + Object.assign(globalThis, { __orcaCloudTokenClaims: probe }) + return probe +} diff --git a/src/main/hosted-sandbox/hosted-sandbox-install.ts b/src/main/hosted-sandbox/hosted-sandbox-install.ts index 1094d252345..3b84e6c6d75 100644 --- a/src/main/hosted-sandbox/hosted-sandbox-install.ts +++ b/src/main/hosted-sandbox/hosted-sandbox-install.ts @@ -1,3 +1,4 @@ +import { installDevTokenClaimsProbe } from './dev-token-claims-probe' import { cloudDevSigningAllowed, withCloudBuildDefaults } from '../cloud-build-defaults' import { findCloudRuntimeBySandboxId, @@ -132,6 +133,7 @@ export function installHostedSandbox( // A cloud list refresh or a not-found status read asks for the prune now (LC-2). setCloudGonePruneRunner(() => accountBackfill.run({ force: true })) installLocalCommands(userDataPath) + installDevTokenClaimsProbe(() => config.getAccessToken(), app.isPackaged) const mcpSync = installCloudMcpSync(userDataPath, { baseUrl: config.controlPlaneUrl, getAccessToken: config.getAccessToken