Skip to content

Commit 35c86d2

Browse files
authored
Add MCP-native first-time agent onboarding (#45)
## Summary - let OAuth consent create a private pending Orbit agent when the human has no existing agent to connect - bind the pending shell, primary sponsor membership, MCP grant, delegation code, and audit records atomically - let the connected agent choose its permanent handle and bio on the same immutable agent ID and OAuth grant - create no long-lived agent API credential during ChatGPT Web onboarding - expose only onboarding completion while pending and deny inbox/publication/message mutations until activation - enforce normal sponsor quota, a one-hour onboarding window, lazy expired-shell cleanup, and immediate pending-shell retirement on grant revocation - add HTTP and browser regression coverage for the first-time flow ## Validation - full npm run build passed: 175/175 D1 tests, 63 content assertions, 82 CLI assertions, 8/8 JS reference-client tests, 7/7 Python reference-client tests, 2144 site assertions, 465 browser assertions - final npm run check: 0 errors, 0 warnings, 0 hints - final focused Slice 1 suite: 23/23 passed - production-live Worker dry-run passed - git diff --check passed
1 parent a39b5da commit 35c86d2

9 files changed

Lines changed: 704 additions & 49 deletions

File tree

‎scripts/orbit-browser-tests.mjs‎

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,7 @@ if (errors.length === 0) {
217217
status: 'active',
218218
onboardingState: 'active',
219219
})),
220+
agentCreation: { available: true, onboardingTtlMs: 60 * 60 * 1000 },
220221
} : { error: { code: 'authentication_required', message: 'A valid session is required.' } }));
221222
}).catch(() => {
222223
response.writeHead(400, { 'content-type': 'application/json; charset=utf-8' });
@@ -707,8 +708,12 @@ if (errors.length === 0) {
707708
check(consentState.scopeCard === null, 'MCP consent izin kartını göstermeye devam ediyor.');
708709
check(consentState.scopeSummary === null, 'MCP consent izin özeti alanını göstermeye devam ediyor.');
709710
check(consentState.scopeOptions === null, 'MCP consent izin seçeneklerini göstermeye devam ediyor.');
710-
check(consentState.options.length === browserAgents.length, 'MCP consent yönetilebilir ajan listesini eksik gösteriyor.');
711+
check(consentState.options.length === browserAgents.length + 1, 'MCP consent mevcut ajanlarla yeni ajan seçeneğini eksik gösteriyor.');
711712
check(consentState.options.some((option) => option.value === 'agent-selene'), 'MCP consent Selene seçimini sunmuyor.');
713+
check(
714+
consentState.options.some((option) => option.value === '__create_new_orbit_agent__' && option.label === 'Yeni bir Orbit ajanı kaydet'),
715+
'MCP consent yeni Orbit ajanı oluşturma seçeneğini sunmuyor.',
716+
);
712717
check(consentState.approveDisabled === false, 'MCP consent onay düğmesi kullanılabilir değil.');
713718

714719
await page.locator('#mcp-agent-select').selectOption('agent-selene');
@@ -731,6 +736,42 @@ if (errors.length === 0) {
731736
await context.close();
732737
}
733738

739+
{
740+
const context = await browser.newContext({ viewport: { width: 390, height: 844 }, colorScheme: 'light' });
741+
await context.addCookies([{
742+
name: 'orbit-owner-test',
743+
value: '1',
744+
url: baseUrl,
745+
}]);
746+
await context.route('https://mcp.orbit.sametbasbug.dev/**', async (route) => {
747+
await route.fulfill({
748+
status: 200,
749+
contentType: 'text/html; charset=utf-8',
750+
body: '<!doctype html><title>Orbit MCP callback</title>',
751+
});
752+
});
753+
const page = await context.newPage();
754+
const pageErrors = [];
755+
page.on('pageerror', (error) => pageErrors.push(error.message));
756+
await page.goto(
757+
`${baseUrl}/dashboard#mcp_authorization=${encodeURIComponent(browserMcpTicket)}`,
758+
{ waitUntil: 'load' },
759+
);
760+
await page.waitForSelector('#mcp-consent:not(.hidden)');
761+
await page.locator('#mcp-agent-select').selectOption('__create_new_orbit_agent__');
762+
await page.locator('#mcp-approve').click();
763+
await page.waitForURL((url) => (
764+
url.origin === 'https://mcp.orbit.sametbasbug.dev'
765+
&& url.pathname === '/oauth/orbit/callback'
766+
));
767+
check(browserMcpAuthorizationBodies.length === 2, 'MCP yeni ajan consent isteğini tam bir kez göndermedi.');
768+
check(browserMcpAuthorizationBodies[1]?.createAgent === true, 'MCP yeni ajan seçimini createAgent olarak göndermedi.');
769+
check(!Object.hasOwn(browserMcpAuthorizationBodies[1] ?? {}, 'agentId'), 'MCP yeni ajan seçimi mevcut agentId göndermemeli.');
770+
check(browserMcpAuthorizationBodies[1]?.ticket === browserMcpTicket, 'MCP yeni ajan seçimi imzalı ticketı taşımadı.');
771+
check(pageErrors.length === 0, `MCP yeni ajan consentinde sayfa hatası: ${pageErrors.join(' | ')}`);
772+
await context.close();
773+
}
774+
734775
for (const viewport of [{ width: 390, height: 844 }, { width: 1440, height: 900 }]) {
735776
const label = `${viewport.width}x${viewport.height} owner`;
736777
const context = await browser.newContext({ viewport, colorScheme: 'light' });

‎scripts/orbit-slice1-tests.ts‎

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1408,6 +1408,137 @@ let firstCredentialToken = '';
14081408
assert.equal(delegated.status, 404);
14091409
});
14101410

1411+
test('a first-time MCP connection can create and complete its own Orbit agent without an API credential', async () => {
1412+
const requestId = 'mcp-native-onboarding-request';
1413+
const ticketResponse = await postJson('/v1/mcp/authorization-tickets', {
1414+
authorizationRequestId: requestId,
1415+
oauthClientId: 'chatgpt-native-onboarding-client',
1416+
oauthClientLabel: 'ChatGPT',
1417+
scopes: ['feed:read', 'posts:write', 'replies:write', 'messages:read', 'messages:write'],
1418+
scopeBundleVersion: 2,
1419+
}, { authorization: `Bearer ${MCP_SERVICE_SECRET}` }, NOW + 55);
1420+
assert.equal(ticketResponse.status, 201, await ticketResponse.clone().text());
1421+
const ticket = (await ticketResponse.json() as { ticket: string }).ticket;
1422+
1423+
const inspected = await postJson('/v1/mcp/authorization-tickets/inspect', {
1424+
ticket,
1425+
}, { cookie: cookieHeader(otherSponsorCookies) }, NOW + 55);
1426+
assert.equal(inspected.status, 200, await inspected.clone().text());
1427+
const inspectedBody = await inspected.json() as {
1428+
manageableAgents: Array<{ id: string }>;
1429+
agentCreation: { available: boolean; onboardingTtlMs: number };
1430+
};
1431+
assert.deepEqual(inspectedBody.manageableAgents, []);
1432+
assert.equal(inspectedBody.agentCreation.available, true);
1433+
assert.equal(inspectedBody.agentCreation.onboardingTtlMs, 60 * 60 * 1000);
1434+
1435+
const created = await postJson('/v1/mcp/authorizations', {
1436+
createAgent: true,
1437+
ticket,
1438+
}, authenticatedHeaders(otherSponsorCookies, true), NOW + 56);
1439+
assert.equal(created.status, 201, await created.clone().text());
1440+
const createdBody = await created.json() as {
1441+
authorization: { id: string; agent: { id: string; handle: string }; status: string };
1442+
delegation: { code: string; authorizationRequestId: string };
1443+
};
1444+
assert.equal(createdBody.authorization.status, 'active');
1445+
assert.match(createdBody.authorization.agent.handle, /^mcp-pending-/u);
1446+
const onboardingGrantId = createdBody.authorization.id;
1447+
const onboardingAgentId = createdBody.authorization.agent.id;
1448+
1449+
const redeemed = await postJson('/v1/mcp/delegations/redeem', {
1450+
code: createdBody.delegation.code,
1451+
authorizationRequestId: requestId,
1452+
}, { authorization: `Bearer ${MCP_SERVICE_SECRET}` }, NOW + 57);
1453+
assert.equal(redeemed.status, 200, await redeemed.clone().text());
1454+
1455+
const pendingState = await postJson(
1456+
`/v1/mcp/grants/${encodeURIComponent(onboardingGrantId)}/agent/state`,
1457+
{},
1458+
{ authorization: `Bearer ${MCP_SERVICE_SECRET}` },
1459+
NOW + 58,
1460+
);
1461+
assert.equal(pendingState.status, 200, await pendingState.clone().text());
1462+
const pendingStateBody = await pendingState.json() as {
1463+
authorization: { id: string };
1464+
agent: { id: string; handle: string | null; onboardingState: string; onboardingExpiresAt: number | null };
1465+
};
1466+
assert.equal(pendingStateBody.authorization.id, onboardingGrantId);
1467+
assert.equal(pendingStateBody.agent.id, onboardingAgentId);
1468+
assert.equal(pendingStateBody.agent.handle, null);
1469+
assert.equal(pendingStateBody.agent.onboardingState, 'pending');
1470+
assert.equal(pendingStateBody.agent.onboardingExpiresAt, NOW + 56 + 60 * 60 * 1000);
1471+
1472+
const tooEarly = await postJson(
1473+
`/v1/mcp/grants/${encodeURIComponent(onboardingGrantId)}/records`,
1474+
{ bodyMarkdown: 'Not yet', projectSlug: null, topicSlugs: [], mediaId: null },
1475+
{
1476+
authorization: `Bearer ${MCP_SERVICE_SECRET}`,
1477+
'idempotency-key': 'mcp-onboarding-too-early',
1478+
},
1479+
NOW + 59,
1480+
);
1481+
assert.equal(tooEarly.status, 401);
1482+
const tooEarlyBody = await tooEarly.json() as { error: { code: string } };
1483+
assert.equal(tooEarlyBody.error.code, 'mcp_agent_onboarding_incomplete');
1484+
1485+
const completed = await postJson(
1486+
`/v1/mcp/grants/${encodeURIComponent(onboardingGrantId)}/agent/onboarding/complete`,
1487+
{ handle: 'web-nova', bio: 'ChatGPT Web üzerinden Orbit’e katıldım.' },
1488+
{ authorization: `Bearer ${MCP_SERVICE_SECRET}` },
1489+
NOW + 60,
1490+
);
1491+
assert.equal(completed.status, 200, await completed.clone().text());
1492+
const completedBody = await completed.json() as {
1493+
authorization: { id: string; agent: { id: string; handle: string } };
1494+
agent: { handle: string; onboardingState: string };
1495+
};
1496+
assert.equal(completedBody.authorization.id, onboardingGrantId);
1497+
assert.equal(completedBody.authorization.agent.id, onboardingAgentId);
1498+
assert.equal(completedBody.authorization.agent.handle, 'web-nova');
1499+
assert.equal(completedBody.agent.handle, 'web-nova');
1500+
assert.equal(completedBody.agent.onboardingState, 'active');
1501+
1502+
const replay = await postJson(
1503+
`/v1/mcp/grants/${encodeURIComponent(onboardingGrantId)}/agent/onboarding/complete`,
1504+
{ handle: 'web-nova', bio: 'ChatGPT Web üzerinden Orbit’e katıldım.' },
1505+
{ authorization: `Bearer ${MCP_SERVICE_SECRET}` },
1506+
NOW + 61,
1507+
);
1508+
assert.equal(replay.status, 200, await replay.clone().text());
1509+
1510+
const conflictingReplay = await postJson(
1511+
`/v1/mcp/grants/${encodeURIComponent(onboardingGrantId)}/agent/onboarding/complete`,
1512+
{ handle: 'web-nova-two', bio: 'Different identity.' },
1513+
{ authorization: `Bearer ${MCP_SERVICE_SECRET}` },
1514+
NOW + 62,
1515+
);
1516+
assert.equal(conflictingReplay.status, 409);
1517+
1518+
const activeState = await postJson(
1519+
`/v1/mcp/grants/${encodeURIComponent(onboardingGrantId)}/agent/state`,
1520+
{},
1521+
{ authorization: `Bearer ${MCP_SERVICE_SECRET}` },
1522+
NOW + 63,
1523+
);
1524+
assert.equal(activeState.status, 200, await activeState.clone().text());
1525+
const activeStateBody = await activeState.json() as {
1526+
authorization: { id: string };
1527+
agent: { handle: string; onboardingState: string; onboardingExpiresAt: null };
1528+
};
1529+
assert.equal(activeStateBody.authorization.id, onboardingGrantId);
1530+
assert.equal(activeStateBody.agent.handle, 'web-nova');
1531+
assert.equal(activeStateBody.agent.onboardingState, 'active');
1532+
assert.equal(activeStateBody.agent.onboardingExpiresAt, null);
1533+
1534+
const managed = await request(`/v1/agents/${encodeURIComponent(onboardingAgentId)}/manage`, {
1535+
headers: authenticatedHeaders(otherSponsorCookies),
1536+
}, NOW + 64);
1537+
assert.equal(managed.status, 200, await managed.clone().text());
1538+
const managedBody = await managed.json() as { agent: { activeCredential: unknown } };
1539+
assert.equal(managedBody.agent.activeCredential, null);
1540+
});
1541+
14111542
test('only platform owner can apply all three publication policies', async () => {
14121543
const sponsorAttempt = await patchJson(`/v1/admin/agents/${sponsoredAgentId}/policy`, {
14131544
publicationMode: 'direct_publish',

‎src/pages/dashboard.astro‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,8 @@ import Icon from '../components/Icon.astro';
6464
<span>Bağlanacak ajan</span>
6565
<select id="mcp-agent-select" autocomplete="off"></select>
6666
</label>
67-
<p id="mcp-agent-empty" class="dashboard-notice error hidden">Bağlanabilecek aktif bir ajan bulunamadı.</p>
67+
<p class="section-help">Yeni ajan seçersen Orbit yalnız geçici bir bağlantı kaydı oluşturur; handle ve bio’yu istemciye döndükten sonra ajanın kendisi tamamlar.</p>
68+
<p id="mcp-agent-empty" class="dashboard-notice error hidden">Bağlanabilecek aktif bir ajan yok ve yeni ajan kotan dolu.</p>
6869

6970
<div class="dashboard-row mcp-consent-actions">
7071
<button id="mcp-approve" class="dashboard-button primary" type="button">Bağlantıya izin ver</button>

‎src/scripts/dashboard.js‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
const byId = (id) => document.getElementById(id);
22
const MCP_TICKET_STORAGE_KEY = 'orbit_mcp_authorization_ticket_v1';
33
const MCP_CALLBACK_URL = 'https://mcp.orbit.sametbasbug.dev/oauth/orbit/callback';
4+
const MCP_CREATE_AGENT_VALUE = '__create_new_orbit_agent__';
45
let me = null;
56
let managed = null;
67
let selectedAgentId = null;
@@ -117,6 +118,7 @@ async function loadMcpConsent() {
117118
});
118119
const authorizationRequest = body?.authorizationRequest;
119120
const manageableAgents = Array.isArray(body?.manageableAgents) ? body.manageableAgents : [];
121+
const agentCreationAvailable = body?.agentCreation?.available === true;
120122
if (!authorizationRequest) throw new Error('Orbit MCP bağlantı isteği doğrulanamadı.');
121123

122124
mcpAuthorizationRequest = authorizationRequest;
@@ -133,7 +135,14 @@ async function loadMcpConsent() {
133135
option.textContent = `@${agent.handle}${agent.displayName && agent.displayName !== agent.handle ? ` — ${agent.displayName}` : ''}`;
134136
select.append(option);
135137
}
136-
const empty = manageableAgents.length === 0;
138+
if (agentCreationAvailable) {
139+
const option = document.createElement('option');
140+
option.value = MCP_CREATE_AGENT_VALUE;
141+
option.textContent = 'Yeni bir Orbit ajanı kaydet';
142+
select.append(option);
143+
if (manageableAgents.length === 0) select.value = MCP_CREATE_AGENT_VALUE;
144+
}
145+
const empty = manageableAgents.length === 0 && !agentCreationAvailable;
137146
byId('mcp-agent-empty').classList.toggle('hidden', !empty);
138147
byId('mcp-approve').disabled = empty;
139148
showPrimaryView('mcp-consent');
@@ -149,10 +158,9 @@ async function approveMcpAuthorization() {
149158
approve.disabled = true;
150159
deny.disabled = true;
151160
try {
152-
const { body } = await mutate('/v1/mcp/authorizations', 'POST', {
153-
agentId,
154-
ticket: mcpAuthorizationTicket,
155-
});
161+
const { body } = await mutate('/v1/mcp/authorizations', 'POST', agentId === MCP_CREATE_AGENT_VALUE
162+
? { createAgent: true, ticket: mcpAuthorizationTicket }
163+
: { agentId, ticket: mcpAuthorizationTicket });
156164
const delegation = body?.delegation;
157165
if (
158166
!delegation?.code

0 commit comments

Comments
 (0)