From f21c6f37df41f82bf6499bb5489a5baf78faa3c2 Mon Sep 17 00:00:00 2001 From: sametbasbug Date: Sat, 8 Aug 2026 21:24:51 +0300 Subject: [PATCH] Allow clearing agent profile roles Treat the optional role field as a trim-normalized 0-80 character string across normal agent and MCP-native profile updates. Align the public ProfilePatch schema with persisted/read profile state and add regression coverage for clearing role while preserving ETag concurrency. --- scripts/orbit-slice1-tests.ts | 31 +++++++++++++++++++++++++++++-- src/data/agentApiContract.ts | 2 +- src/server/http/api.ts | 4 ++-- 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/scripts/orbit-slice1-tests.ts b/scripts/orbit-slice1-tests.ts index 0bca062..50b099f 100644 --- a/scripts/orbit-slice1-tests.ts +++ b/scripts/orbit-slice1-tests.ts @@ -1475,11 +1475,21 @@ let firstCredentialToken = ''; currentVersion: 2, currentEtag: nextEtag, }); - sponsoredAgentEtag = nextEtag; + + const clearedRole = await patchJson('/v1/agent/profile', { + role: ' ', + }, { authorization: `Bearer ${firstCredentialToken}`, 'if-match': nextEtag }, NOW + 50); + assert.equal(clearedRole.status, 200, await clearedRole.clone().text()); + const clearedRoleBody = await clearedRole.json() as { agent: { role: string; version: number } }; + assert.equal(clearedRoleBody.agent.role, ''); + assert.equal(clearedRoleBody.agent.version, 3); + const clearedRoleEtag = clearedRole.headers.get('etag') ?? ''; + assert.match(clearedRoleEtag, /^"agent-.+-v3"$/u); + sponsoredAgentEtag = clearedRoleEtag; const forbidden = await patchJson('/v1/agent/profile', { bio: 'Allowed profile.', handle: 'stolen-handle', - }, { authorization: `Bearer ${firstCredentialToken}`, 'if-match': nextEtag }, NOW + 51); + }, { authorization: `Bearer ${firstCredentialToken}`, 'if-match': clearedRoleEtag }, NOW + 51); assert.equal(forbidden.status, 400); }); @@ -1722,6 +1732,23 @@ let firstCredentialToken = ''; }; assert.equal(staleBody.error.code, 'version_conflict'); assert.equal(staleBody.error.details?.conflict?.currentEtag, updatedBody.etag); + + const clearedRole = await postJson( + `/v1/mcp/grants/${encodeURIComponent(onboardingGrantId)}/agent/profile/update`, + { etag: updatedBody.etag, role: ' ' }, + serviceHeaders, + NOW + 69, + ); + assert.equal(clearedRole.status, 200, await clearedRole.clone().text()); + const clearedRoleBody = await clearedRole.json() as { + etag: string; + profile: { bio: string; role: string; accent: string; pinnedRecordId: string | null }; + }; + assert.notEqual(clearedRoleBody.etag, updatedBody.etag); + assert.equal(clearedRoleBody.profile.role, ''); + assert.equal(clearedRoleBody.profile.bio, 'MCP profile update test.'); + assert.equal(clearedRoleBody.profile.accent, '#12abef'); + assert.equal(clearedRoleBody.profile.pinnedRecordId, null); }); test('only platform owner can apply all three publication policies', async () => { diff --git a/src/data/agentApiContract.ts b/src/data/agentApiContract.ts index 6e5bd0b..8e0aecf 100644 --- a/src/data/agentApiContract.ts +++ b/src/data/agentApiContract.ts @@ -1437,7 +1437,7 @@ export const agentApiContract = { additionalProperties: false, properties: { bio: { type: 'string', minLength: 1, maxLength: 500 }, - role: { type: 'string', minLength: 1, maxLength: 80 }, + role: { type: 'string', maxLength: 80 }, accent: { type: 'string', pattern: '^#[0-9a-fA-F]{6}$' }, pinnedRecordId: { oneOf: [{ $ref: '#/components/schemas/Uuid' }, { type: 'null' }] }, }, diff --git a/src/server/http/api.ts b/src/server/http/api.ts index fd80e1c..f2c206d 100644 --- a/src/server/http/api.ts +++ b/src/server/http/api.ts @@ -1364,7 +1364,7 @@ async function handlePatchOwnAgent( throw versionConflictError(current, now); } const bio = body.bio === undefined ? current.bio : requiredString(body.bio, 'bio', 500); - const role = body.role === undefined ? current.role : requiredString(body.role, 'role', 80); + const role = body.role === undefined ? current.role : requiredString(body.role, 'role', 80, true); let accent = current.accent; if (body.accent !== undefined) { if (typeof body.accent !== 'string' || !/^#[0-9a-f]{6}$/iu.test(body.accent.trim())) { @@ -3963,7 +3963,7 @@ export async function handleApiRequest( throw mcpProfileVersionConflictError(resolved.agent, now); } const bio = body.bio === undefined ? resolved.agent.bio : requiredString(body.bio, 'bio', 500); - const role = body.role === undefined ? resolved.agent.role : requiredString(body.role, 'role', 80); + const role = body.role === undefined ? resolved.agent.role : requiredString(body.role, 'role', 80, true); let accent = resolved.agent.accent; if (body.accent !== undefined) { if (typeof body.accent !== 'string' || !/^#[0-9a-f]{6}$/iu.test(body.accent.trim())) {