Orbit MCP
+diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index d878d87..59e4b2a 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -252,20 +252,33 @@ jobs: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} ORBIT_LIVE_OAUTH_CLIENT_ID: ${{ secrets.ORBIT_LIVE_OAUTH_CLIENT_ID }} ORBIT_LIVE_OAUTH_CLIENT_SECRET: ${{ secrets.ORBIT_LIVE_OAUTH_CLIENT_SECRET }} + ORBIT_MCP_SERVICE_SECRET_V1: ${{ secrets.ORBIT_MCP_SERVICE_SECRET_V1 }} + ORBIT_MCP_DELEGATION_PEPPER_V1: ${{ secrets.ORBIT_MCP_DELEGATION_PEPPER_V1 }} run: | if [[ -z "$ORBIT_LIVE_OAUTH_CLIENT_ID" || -z "$ORBIT_LIVE_OAUTH_CLIENT_SECRET" ]]; then echo 'Live GitHub OAuth deployment secrets are missing.' >&2 exit 1 fi + if [[ -z "$ORBIT_MCP_SERVICE_SECRET_V1" || -z "$ORBIT_MCP_DELEGATION_PEPPER_V1" ]]; then + echo 'Orbit MCP production secrets are missing.' >&2 + exit 1 + fi oauth_secrets_file="$(mktemp)" trap 'rm -f "$oauth_secrets_file"' EXIT chmod 600 "$oauth_secrets_file" jq -n '{ GITHUB_OAUTH_CLIENT_ID: env.ORBIT_LIVE_OAUTH_CLIENT_ID, - GITHUB_OAUTH_CLIENT_SECRET: env.ORBIT_LIVE_OAUTH_CLIENT_SECRET + GITHUB_OAUTH_CLIENT_SECRET: env.ORBIT_LIVE_OAUTH_CLIENT_SECRET, + ORBIT_MCP_SERVICE_SECRET_V1: env.ORBIT_MCP_SERVICE_SECRET_V1, + ORBIT_MCP_DELEGATION_PEPPER_V1: env.ORBIT_MCP_DELEGATION_PEPPER_V1 }' > "$oauth_secrets_file" unset ORBIT_LIVE_OAUTH_CLIENT_ID ORBIT_LIVE_OAUTH_CLIENT_SECRET + unset ORBIT_MCP_SERVICE_SECRET_V1 ORBIT_MCP_DELEGATION_PEPPER_V1 + + npx wrangler d1 migrations apply DB \ + --remote \ + --config wrangler.production.live.jsonc short_sha="${GITHUB_SHA::12}" npx wrangler deploy \ diff --git a/scripts/orbit-browser-tests.mjs b/scripts/orbit-browser-tests.mjs index a01343f..81941cb 100644 --- a/scripts/orbit-browser-tests.mjs +++ b/scripts/orbit-browser-tests.mjs @@ -119,6 +119,19 @@ const seleneEditorialCount = searchIndex.items.filter((item) => ( normalizeSearchText(item.searchText).includes('selene') && item.topics.includes('editoryal') )).length; const agentTopicRecordCount = searchIndex.items.filter((item) => item.entity === 'record' && item.topics.includes('ajanlar')).length; +const browserMcpTicket = 'orb_mcp_auth_v1.browser-payload.browser-signature'; +const browserMcpAuthorizationRequestId = '550e8400-e29b-41d4-a716-446655440000'; +const browserMcpDelegationCode = 'orb_mcp_v1_browser_selector_browser_secret'; +const browserMcpAuthorizationBodies = []; + +const readRequestJson = (request) => new Promise((resolve, reject) => { + let value = ''; + request.on('data', (chunk) => { value += chunk; }); + request.on('end', () => { + try { resolve(value ? JSON.parse(value) : {}); } catch (error) { reject(error); } + }); + request.on('error', reject); +}); if (errors.length === 0) { const server = http.createServer((request, response) => { @@ -167,10 +180,70 @@ if (errors.length === 0) { 'content-type': 'application/json; charset=utf-8', }); response.end(JSON.stringify(owner - ? { account: { roles: ['platform_owner'] } } + ? { + account: { + roles: ['platform_owner'], + agentQuota: -1, + displayName: 'Orbit Owner', + handle: 'orbit-owner', + avatarUrl: null, + }, + sponsoredAgents: browserAgents, + } : { error: { code: 'authentication_required', message: 'A valid session is required.' } })); return; } + if (request.method === 'POST' && pathname === '/v1/mcp/authorization-tickets/inspect') { + readRequestJson(request).then((body) => { + const valid = request.headers.cookie?.includes('orbit-owner-test=1') + && body.ticket === browserMcpTicket; + response.writeHead(valid ? 200 : 401, { + 'cache-control': 'no-store', + 'content-type': 'application/json; charset=utf-8', + }); + response.end(JSON.stringify(valid ? { + authorizationRequest: { + id: browserMcpAuthorizationRequestId, + oauthClient: { id: 'chatgpt-browser-client', label: 'ChatGPT' }, + scopes: ['feed:read'], + issuedAt: Date.now(), + expiresAt: Date.now() + 10 * 60 * 1000, + }, + manageableAgents: browserAgents.map((agent) => ({ + ...agent, + displayName: agent.handle[0].toUpperCase() + agent.handle.slice(1), + publicationMode: 'direct_publish', + status: 'active', + onboardingState: 'active', + })), + } : { error: { code: 'authentication_required', message: 'A valid session is required.' } })); + }).catch(() => { + response.writeHead(400, { 'content-type': 'application/json; charset=utf-8' }); + response.end(JSON.stringify({ error: { code: 'invalid_json', message: 'Invalid JSON.' } })); + }); + return; + } + if (request.method === 'POST' && pathname === '/v1/mcp/authorizations') { + readRequestJson(request).then((body) => { + browserMcpAuthorizationBodies.push(body); + response.writeHead(201, { + 'cache-control': 'no-store', + 'content-type': 'application/json; charset=utf-8', + }); + response.end(JSON.stringify({ + authorization: { id: 'grant-browser', status: 'active' }, + delegation: { + code: browserMcpDelegationCode, + authorizationRequestId: browserMcpAuthorizationRequestId, + expiresAt: Date.now() + 5 * 60 * 1000, + }, + })); + }).catch(() => { + response.writeHead(400, { 'content-type': 'application/json; charset=utf-8' }); + response.end(JSON.stringify({ error: { code: 'invalid_json', message: 'Invalid JSON.' } })); + }); + return; + } if (request.method === 'POST' && /^\/v1\/manage\/records\/[^/]+\/delete$/u.test(pathname)) { response.writeHead(200, { 'cache-control': 'no-store', 'content-type': 'application/json; charset=utf-8' }); response.end(JSON.stringify({ @@ -569,6 +642,85 @@ if (errors.length === 0) { await context.close(); })); + { + const context = await browser.newContext({ viewport: { width: 390, height: 844 }, colorScheme: 'light' }); + const page = await context.newPage(); + const pageErrors = []; + page.on('pageerror', (error) => pageErrors.push(error.message)); + await page.goto( + `${baseUrl}/dashboard#mcp_authorization=${encodeURIComponent(browserMcpTicket)}`, + { waitUntil: 'load' }, + ); + await page.waitForSelector('#login:not(.hidden)'); + const anonymousConsent = await page.evaluate((storageKey) => ({ + hash: location.hash, + ticket: sessionStorage.getItem(storageKey), + title: document.querySelector('#login-title')?.textContent?.trim(), + consentVisible: !document.querySelector('#mcp-consent')?.classList.contains('hidden'), + }), 'orbit_mcp_authorization_ticket_v1'); + check(anonymousConsent.hash === '', 'MCP anonim girişinde ticket URL fragmentından temizlenmedi.'); + check(anonymousConsent.ticket === browserMcpTicket, 'MCP anonim girişinde ticket sekme oturumunda korunmadı.'); + check(anonymousConsent.title === 'Bağlantıyı onaylamak için giriş yap.', 'MCP anonim giriş açıklaması gösterilmedi.'); + check(anonymousConsent.consentVisible === false, 'MCP consent oturum açılmadan görünür oldu.'); + check(pageErrors.length === 0, `MCP anonim girişinde sayfa hatası: ${pageErrors.join(' | ')}`); + await context.close(); + } + + { + const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, colorScheme: 'light' }); + await context.addCookies([{ + name: 'orbit-owner-test', + value: '1', + url: baseUrl, + }]); + await context.route('https://mcp.orbit.sametbasbug.dev/**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'text/html; charset=utf-8', + body: '