From 2c0f40ecdd8654bb365341cedc3b5d50dc73b278 Mon Sep 17 00:00:00 2001 From: Sophie Neumann Date: Tue, 30 Jun 2026 12:56:49 +0200 Subject: [PATCH 1/2] feat(builder): add oauth_providers descriptor + type:oauth wiring for AgentSpec (#371) --- middleware/src/plugins/builder/agentSpec.ts | 34 ++++ middleware/src/plugins/builder/codegen.ts | 9 + .../src/plugins/builder/manifestLinter.ts | 103 +++++++++- middleware/src/plugins/builder/types.ts | 4 + .../builder/agentSpecOAuthProviders.test.ts | 188 ++++++++++++++++++ middleware/test/builder/codegen.test.ts | 145 +++++++++++++- web-ui/app/_lib/builderTypes.ts | 23 ++- 7 files changed, 503 insertions(+), 3 deletions(-) create mode 100644 middleware/test/builder/agentSpecOAuthProviders.test.ts diff --git a/middleware/src/plugins/builder/agentSpec.ts b/middleware/src/plugins/builder/agentSpec.ts index 63265f33..e1ba7afa 100644 --- a/middleware/src/plugins/builder/agentSpec.ts +++ b/middleware/src/plugins/builder/agentSpec.ts @@ -140,6 +140,10 @@ const SetupFieldSchema = z label: z.string().optional(), placeholder: z.string().optional(), help: z.string().optional(), + // Spec 005 (#371) — `type:'oauth'` only. `provider` references an + // `oauth_providers[].id`; `scopes` are requested at flow time. + provider: z.string().optional(), + scopes: z.array(z.string()).optional(), }) .strict(); @@ -435,6 +439,31 @@ export const EXTENDED_PERSONA_AXES = [ 'philosophy', ] as const satisfies ReadonlyArray; +// --- OAuth provider descriptor -------------------------------------------- +// Spec 005 (#371) — inert authorization-code descriptor consumed by the +// kernel OAuth engine; no plugin code touches the flow. Mirrors +// OAuthProviderDescriptor in api/admin-v1.ts + manifestLoader. +// extractOAuthProviders — keep all three in sync (pkce defaults true, +// token_auth_style enum, client_*_field gating). +const OAuthProviderSchema = z + .object({ + id: z.string().regex(/^[a-z][a-z0-9_]*$/), + // URLs may carry `{field}` placeholders interpolated from config at flow + // time (e.g. Microsoft's `{tenant_id}`). + authorize_url: z.string().min(1), + token_url: z.string().min(1), + token_auth_style: z.enum(['body_form', 'body_json', 'basic']), + pkce: z.boolean().default(true), + extra_authorize_params: z.record(z.string(), z.string()).optional(), + // Setup-field keys holding the client id / secret (manifestLinter checks + // both resolve; the secret must be type:'secret'). + client_id_field: z.string().regex(/^[a-z][a-z0-9_]*$/), + client_secret_field: z.string().regex(/^[a-z][a-z0-9_]*$/), + }) + .strict(); + +export type OAuthProvider = z.infer; + // --- AgentSpec ------------------------------------------------------------ export const AgentSpecSchema = z @@ -490,6 +519,11 @@ export const AgentSpecSchema = z // Runtime config setup_fields: z.array(SetupFieldSchema).default([]), + // Spec 005 (#371) — OAuth-provider descriptors a `type:oauth` setup_field + // references by `id`. Codegen emits the top-level `oauth_providers:` block + // when non-empty; default `[]` keeps legacy drafts + non-OAuth agents clean. + oauth_providers: z.array(OAuthProviderSchema).default([]), + // Scheduled background jobs. Codegen writes these into manifest.yaml's // `jobs:` block; kernel auto-registers before activate(). Plugin // discovers ctx.jobs.register(...) as an additive surface for runtime diff --git a/middleware/src/plugins/builder/codegen.ts b/middleware/src/plugins/builder/codegen.ts index 0090086a..f7705365 100644 --- a/middleware/src/plugins/builder/codegen.ts +++ b/middleware/src/plugins/builder/codegen.ts @@ -527,6 +527,12 @@ function reproduceManifestCapabilities( } } + // Spec 005 (#371) — top-level oauth_providers block, verbatim. Emit only + // when non-empty so non-OAuth agents stay clean (absent block = no broker). + if (spec.oauth_providers.length > 0) { + doc.set('oauth_providers', doc.createNode(spec.oauth_providers)); + } + const capsNode = doc.get('capabilities', true); if (!yaml.isSeq(capsNode) || capsNode.items.length === 0) { return doc.toString(); @@ -580,6 +586,9 @@ function mapSetupFieldSpecToManifest( if (field.enum_values && field.enum_values.length > 0) { out['enum'] = field.enum_values.map((value) => ({ value, label: value })); } + // Spec 005 (#371) — forward type:oauth descriptor reference + scopes. + if (field.provider) out['provider'] = field.provider; + if (field.scopes && field.scopes.length > 0) out['scopes'] = field.scopes; return out; } diff --git a/middleware/src/plugins/builder/manifestLinter.ts b/middleware/src/plugins/builder/manifestLinter.ts index 761a719f..45608dc2 100644 --- a/middleware/src/plugins/builder/manifestLinter.ts +++ b/middleware/src/plugins/builder/manifestLinter.ts @@ -45,7 +45,13 @@ export type ViolationKind = | 'ui_route_path_duplicate' | 'ui_route_tab_label_duplicate' | 'multi_instance_justification_missing' - | 'privacy_class_invalid'; + | 'privacy_class_invalid' + // Spec 005 (#371) — OAuth descriptor cross-references. + | 'oauth_provider_id_duplicate' + | 'oauth_field_provider_unresolved' + | 'oauth_provider_client_field_missing' + | 'oauth_provider_unreferenced' + | 'oauth_provider_client_secret_not_secret'; export interface ManifestViolation { kind: ViolationKind; @@ -321,6 +327,101 @@ export function validateSpec( } } + // Spec 005 (#371) — OAuth descriptor cross-references Zod can't express: + // unique ids, field↔descriptor references resolve both ways, client fields + // exist and the secret is type:'secret'. A failure leaves the broker unarmed + // or `ctx.oauthTokens` undefined at runtime. + const oauthProviders = Array.isArray(s.oauth_providers) + ? (s.oauth_providers as Array>) + : []; + const providerIdIndices = new Map(); + oauthProviders.forEach((p, i) => { + const id = p?.['id']; + if (typeof id !== 'string') return; + const list = providerIdIndices.get(id) ?? []; + list.push(i); + providerIdIndices.set(id, list); + }); + for (const [id, indices] of providerIdIndices) { + if (indices.length > 1) { + violations.push({ + kind: 'oauth_provider_id_duplicate', + path: `/oauth_providers/${String(indices[0] ?? 0)}/id`, + message: + `oauth_providers id '${id}' is duplicated at indices ${indices.join(', ')}. ` + + 'Each descriptor id must be unique — a type:oauth field references exactly one.', + }); + } + } + // key → type, for the client-field existence + secret-type checks below. + const setupFieldTypes = new Map(); + for (const f of setupFields) { + if (!f || typeof f !== 'object') continue; + const field = f as { key?: unknown; type?: unknown }; + if (typeof field.key === 'string') setupFieldTypes.set(field.key, field.type); + } + // type:oauth field → must reference a declared descriptor; collect referenced + // ids so orphan descriptors can be flagged below. + const referencedProviderIds = new Set(); + setupFields.forEach((f, i) => { + if (!f || typeof f !== 'object') return; + const field = f as { type?: unknown; provider?: unknown }; + if (field.type !== 'oauth') return; + const provider = field.provider; + if (typeof provider === 'string') referencedProviderIds.add(provider); + if (typeof provider !== 'string' || !providerIdIndices.has(provider)) { + violations.push({ + kind: 'oauth_field_provider_unresolved', + path: `/setup_fields/${i}/provider`, + message: + `setup_fields[${i}] is type:oauth but its provider ` + + `'${typeof provider === 'string' ? provider : ''}' does not match any ` + + 'oauth_providers[].id. Declare the descriptor so the broker can arm.', + }); + } + }); + // Orphan descriptor: declared but unreferenced. Loader still arms the broker + // + acquires_oauth chip from descriptor presence, but ctx.oauthTokens only + // arms from a type:oauth field — so OAuth is advertised with no accessor. + for (const [id, indices] of providerIdIndices) { + if (!referencedProviderIds.has(id)) { + violations.push({ + kind: 'oauth_provider_unreferenced', + path: `/oauth_providers/${String(indices[0] ?? 0)}/id`, + message: + `oauth_providers id '${id}' is declared but no type:oauth setup_field ` + + 'references it via its `provider`. Add the field or drop the descriptor — ' + + 'an orphan descriptor arms the broker but leaves ctx.oauthTokens undefined.', + }); + } + } + // client_id_field / client_secret_field must exist; the secret must be + // type:'secret' so it's vault-stored, not plaintext config. + oauthProviders.forEach((p, i) => { + for (const key of ['client_id_field', 'client_secret_field'] as const) { + const ref = p?.[key]; + if (typeof ref !== 'string') continue; + if (!setupFieldTypes.has(ref)) { + violations.push({ + kind: 'oauth_provider_client_field_missing', + path: `/oauth_providers/${i}/${key}`, + message: + `oauth_providers[${i}].${key} '${ref}' does not reference an existing ` + + 'setup_fields key. Add the credential field or fix the reference.', + }); + } else if (key === 'client_secret_field' && setupFieldTypes.get(ref) !== 'secret') { + violations.push({ + kind: 'oauth_provider_client_secret_not_secret', + path: `/oauth_providers/${i}/client_secret_field`, + message: + `oauth_providers[${i}].client_secret_field '${ref}' must reference a ` + + `type:'secret' setup_field (got type:'${String(setupFieldTypes.get(ref))}'). ` + + 'The OAuth client secret must be vault-stored, not plaintext config.', + }); + } + } + }); + // Multi-orchestrator runtime (US2) — manifest extension validation. // Zod's schema enforces field types + the privacy_class enum + the // multi_instance boolean at parse time; this linter adds the semantic diff --git a/middleware/src/plugins/builder/types.ts b/middleware/src/plugins/builder/types.ts index ff952e78..c87bcb7b 100644 --- a/middleware/src/plugins/builder/types.ts +++ b/middleware/src/plugins/builder/types.ts @@ -59,6 +59,10 @@ export interface AgentSpecSkeleton { tools: unknown[]; skill: { role: string; tonality?: string }; setup_fields: unknown[]; + /** Spec 005 (#371) — OAuth-provider descriptors (shape: `OAuthProvider` from + * agentSpec.ts). Optional in the skeleton because legacy drafts predate the + * field; Zod parse fills the default `[]`. */ + oauth_providers?: unknown[]; playbook: { when_to_use: string; not_for: string[]; diff --git a/middleware/test/builder/agentSpecOAuthProviders.test.ts b/middleware/test/builder/agentSpecOAuthProviders.test.ts new file mode 100644 index 00000000..b9081621 --- /dev/null +++ b/middleware/test/builder/agentSpecOAuthProviders.test.ts @@ -0,0 +1,188 @@ +/** + * Spec 005 (#371) — AgentSpec accepts `type:oauth` + an `oauth_providers` + * descriptor; manifestLinter cross-references the two. Covers the issue's + * repro Zod-fails plus the linter rules that keep the broker wiring honest. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { AgentSpecSchema } from '../../src/plugins/builder/agentSpec.js'; +import { validateSpec } from '../../src/plugins/builder/manifestLinter.js'; +import type { AgentSpecSkeleton } from '../../src/plugins/builder/types.js'; + +const baseSpec = (): Record => ({ + template: 'agent-integration', + id: 'de.byte5.agent.gmailsummary', + name: 'Gmail Summary', + version: '0.1.0', + description: 'summarises Gmail via the kernel OAuth broker', + category: 'communication', + domain: 'gmail', + depends_on: [], + tools: [], + skill: { role: 'helper' }, + setup_fields: [], + jobs: [], + playbook: { when_to_use: 'use it', not_for: [], example_prompts: [] }, + network: { outbound: ['gmail.googleapis.com'] }, + slots: {}, +}); + +const googleProvider = () => ({ + id: 'google', + authorize_url: 'https://accounts.google.com/o/oauth2/v2/auth', + token_url: 'https://oauth2.googleapis.com/token', + token_auth_style: 'body_form', + client_id_field: 'google_client_id', + client_secret_field: 'google_client_secret', +}); + +const credFields = () => [ + { key: 'google_client_id', type: 'string', required: true }, + { key: 'google_client_secret', type: 'secret', required: true }, +]; + +// --- Schema: the three repro Zod-fails from #371 are now accepted ---------- + +test('setup_fields[].type accepts oauth with provider + scopes (#371)', () => { + const spec = AgentSpecSchema.parse({ + ...baseSpec(), + setup_fields: [ + ...credFields(), + { + key: 'gmail_oauth', + type: 'oauth', + required: true, + provider: 'google', + scopes: ['https://www.googleapis.com/auth/gmail.readonly'], + }, + ], + oauth_providers: [googleProvider()], + }); + const field = spec.setup_fields.find((f) => f.key === 'gmail_oauth'); + assert.equal(field?.provider, 'google'); + assert.deepEqual(field?.scopes, [ + 'https://www.googleapis.com/auth/gmail.readonly', + ]); +}); + +test('AgentSpecSchema accepts a top-level oauth_providers descriptor (#371)', () => { + const spec = AgentSpecSchema.parse({ + ...baseSpec(), + oauth_providers: [googleProvider()], + }); + assert.equal(spec.oauth_providers.length, 1); + // pkce defaults to true (mirrors manifestLoader). + assert.equal(spec.oauth_providers[0]?.pkce, true); +}); + +test('AgentSpecSchema defaults oauth_providers to [] for legacy drafts', () => { + const spec = AgentSpecSchema.parse(baseSpec()); + assert.deepEqual(spec.oauth_providers, []); +}); + +test('AgentSpecSchema rejects an unknown token_auth_style', () => { + assert.throws(() => + AgentSpecSchema.parse({ + ...baseSpec(), + oauth_providers: [{ ...googleProvider(), token_auth_style: 'header' }], + }), + ); +}); + +// --- Linter: cross-reference rules keep the broker wiring honest ----------- + +test('manifestLinter accepts a fully-wired OAuth spec', () => { + const skel = { + ...baseSpec(), + setup_fields: [ + ...credFields(), + { key: 'gmail_oauth', type: 'oauth', required: true, provider: 'google' }, + ], + oauth_providers: [googleProvider()], + } as unknown as AgentSpecSkeleton; + const result = validateSpec(skel); + assert.equal(result.ok, true, JSON.stringify(result.violations)); +}); + +test('manifestLinter rejects a type:oauth field whose provider is unresolved', () => { + const skel = { + ...baseSpec(), + setup_fields: [ + ...credFields(), + { key: 'gmail_oauth', type: 'oauth', required: true, provider: 'gmail' }, + ], + oauth_providers: [googleProvider()], // id is 'google', not 'gmail' + } as unknown as AgentSpecSkeleton; + const result = validateSpec(skel); + assert.equal(result.ok, false); + assert.ok( + result.violations.some((v) => v.kind === 'oauth_field_provider_unresolved'), + ); +}); + +test('manifestLinter rejects a descriptor whose client field does not exist', () => { + const skel = { + ...baseSpec(), + setup_fields: [ + { key: 'google_client_id', type: 'string', required: true }, + // google_client_secret intentionally missing + { key: 'gmail_oauth', type: 'oauth', required: true, provider: 'google' }, + ], + oauth_providers: [googleProvider()], + } as unknown as AgentSpecSkeleton; + const result = validateSpec(skel); + assert.equal(result.ok, false); + assert.ok( + result.violations.some( + (v) => v.kind === 'oauth_provider_client_field_missing', + ), + ); +}); + +test('manifestLinter rejects an orphan descriptor no type:oauth field references', () => { + const skel = { + ...baseSpec(), + setup_fields: credFields(), // no type:oauth field references 'google' + oauth_providers: [googleProvider()], + } as unknown as AgentSpecSkeleton; + const result = validateSpec(skel); + assert.equal(result.ok, false); + assert.ok( + result.violations.some((v) => v.kind === 'oauth_provider_unreferenced'), + ); +}); + +test('manifestLinter rejects a client_secret_field that is not type:secret', () => { + const skel = { + ...baseSpec(), + setup_fields: [ + { key: 'google_client_id', type: 'string', required: true }, + // secret declared as plaintext string instead of type:secret + { key: 'google_client_secret', type: 'string', required: true }, + { key: 'gmail_oauth', type: 'oauth', required: true, provider: 'google' }, + ], + oauth_providers: [googleProvider()], + } as unknown as AgentSpecSkeleton; + const result = validateSpec(skel); + assert.equal(result.ok, false); + assert.ok( + result.violations.some( + (v) => v.kind === 'oauth_provider_client_secret_not_secret', + ), + ); +}); + +test('manifestLinter rejects duplicate oauth_providers ids', () => { + const skel = { + ...baseSpec(), + setup_fields: credFields(), + oauth_providers: [googleProvider(), googleProvider()], + } as unknown as AgentSpecSkeleton; + const result = validateSpec(skel); + assert.equal(result.ok, false); + assert.ok( + result.violations.some((v) => v.kind === 'oauth_provider_id_duplicate'), + ); +}); diff --git a/middleware/test/builder/codegen.test.ts b/middleware/test/builder/codegen.test.ts index e38fb4cd..38bd2880 100644 --- a/middleware/test/builder/codegen.test.ts +++ b/middleware/test/builder/codegen.test.ts @@ -1,12 +1,14 @@ import { describe, it, beforeEach } from 'node:test'; import { strict as assert } from 'node:assert'; -import { readFileSync } from 'node:fs'; +import { readFileSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import yaml from 'yaml'; import { generate, CodegenError } from '../../src/plugins/builder/codegen.js'; +import { loadManifestFromPath } from '../../src/plugins/manifestLoader.js'; import { parseAgentSpec, type AgentSpec } from '../../src/plugins/builder/agentSpec.js'; import { _resetCacheForTests } from '../../src/plugins/builder/boilerplateSource.js'; import { @@ -514,6 +516,147 @@ describe('codegen.generate', () => { assert.match(manifest, /outbound:[\s\S]*api\.example\.com/); }); + it('emits oauth_providers + type:oauth field provider/scopes (Spec 005, #371)', async () => { + // Codegen writes both the descriptor block + field provider/scopes; shape + // assertions only (the e2e test below feeds the real loader). + const { slots } = loadFixture(); + const spec = parseAgentSpec({ + id: 'de.byte5.agent.gmailsummary', + name: 'Gmail Summary', + description: 'summarises Gmail via the kernel OAuth broker', + category: 'communication', + depends_on: [], + tools: [{ id: 'do_thing', description: 'x', input: { type: 'object' } }], + skill: { role: 'x' }, + playbook: { when_to_use: 'x', not_for: ['x'], example_prompts: ['x', 'y'] }, + network: { outbound: ['gmail.googleapis.com'] }, + setup_fields: [ + { key: 'google_client_id', type: 'string', required: true }, + { key: 'google_client_secret', type: 'secret', required: true }, + { + key: 'gmail_oauth', + type: 'oauth', + required: true, + provider: 'google', + scopes: ['https://www.googleapis.com/auth/gmail.readonly'], + }, + ], + oauth_providers: [ + { + id: 'google', + authorize_url: 'https://accounts.google.com/o/oauth2/v2/auth', + token_url: 'https://oauth2.googleapis.com/token', + token_auth_style: 'body_form', + client_id_field: 'google_client_id', + client_secret_field: 'google_client_secret', + }, + ], + }); + const out = await generate({ spec, slots }); + const manifestText = out.get('manifest.yaml')!.toString('utf-8'); + const parsed = yaml.parse(manifestText) as { + oauth_providers?: Array>; + setup?: { fields?: Array> }; + }; + // Top-level descriptor block round-trips (incl. pkce default true). + assert.equal(parsed.oauth_providers?.length, 1); + const desc = parsed.oauth_providers![0]!; + assert.equal(desc['id'], 'google'); + assert.equal(desc['token_url'], 'https://oauth2.googleapis.com/token'); + assert.equal(desc['pkce'], true); + assert.equal(desc['client_id_field'], 'google_client_id'); + // Field-level provider/scopes pass through onto the manifest setup field. + const oauthField = parsed.setup?.fields?.find((f) => f['key'] === 'gmail_oauth'); + assert.ok(oauthField, 'gmail_oauth setup field present in manifest'); + assert.equal(oauthField!['provider'], 'google'); + assert.deepEqual(oauthField!['scopes'], [ + 'https://www.googleapis.com/auth/gmail.readonly', + ]); + }); + + it('omits oauth_providers from the manifest when the spec declares none', async () => { + const { slots } = loadFixture(); + const spec = parseAgentSpec({ + id: 'de.byte5.agent.nooauth', + name: 'No OAuth', + description: 'agent without any OAuth', + category: 'other', + depends_on: [], + tools: [{ id: 'do_thing', description: 'x', input: { type: 'object' } }], + skill: { role: 'x' }, + playbook: { when_to_use: 'x', not_for: ['x'], example_prompts: ['x', 'y'] }, + network: { outbound: [] }, + }); + const out = await generate({ spec, slots }); + const manifestText = out.get('manifest.yaml')!.toString('utf-8'); + assert.ok(!/^oauth_providers:/m.test(manifestText)); + }); + + it('generated OAuth manifest arms the real kernel loader end-to-end (Spec 005, #371)', async () => { + // Feed the generated manifest through the REAL manifestLoader so any drift + // between the builder schema and extractOAuthProviders/adaptManifestV1 + // fails here, not at runtime. Asserts the descriptor (broker + chip) and + // the type:oauth field (what arms ctx.oauthTokens) both survive load. + const { slots } = loadFixture(); + const spec = parseAgentSpec({ + id: 'de.byte5.agent.gmaile2e', + name: 'Gmail E2E', + description: 'summarises Gmail via the kernel OAuth broker', + category: 'communication', + depends_on: [], + tools: [{ id: 'do_thing', description: 'x', input: { type: 'object' } }], + skill: { role: 'x' }, + playbook: { when_to_use: 'x', not_for: ['x'], example_prompts: ['x', 'y'] }, + network: { outbound: ['gmail.googleapis.com'] }, + setup_fields: [ + { key: 'google_client_id', type: 'string', required: true }, + { key: 'google_client_secret', type: 'secret', required: true }, + { + key: 'gmail_oauth', + type: 'oauth', + required: true, + provider: 'google', + scopes: ['https://www.googleapis.com/auth/gmail.readonly'], + }, + ], + oauth_providers: [ + { + id: 'google', + authorize_url: 'https://accounts.google.com/o/oauth2/v2/auth', + token_url: 'https://oauth2.googleapis.com/token', + token_auth_style: 'body_form', + client_id_field: 'google_client_id', + client_secret_field: 'google_client_secret', + }, + ], + }); + const out = await generate({ spec, slots }); + const manifestText = out.get('manifest.yaml')!.toString('utf-8'); + + const dir = mkdtempSync(path.join(os.tmpdir(), 'omadia-oauth-e2e-')); + try { + const manifestPath = path.join(dir, 'plugin.manifest.yaml'); + writeFileSync(manifestPath, manifestText, 'utf-8'); + const entry = await loadManifestFromPath(manifestPath); + assert.ok(entry, 'loader accepted the generated manifest'); + const plugin = entry!.plugin; + // (a) descriptor survived the loader's own validation untouched. + assert.equal(plugin.oauth_providers?.length, 1); + assert.equal(plugin.oauth_providers![0]!.id, 'google'); + assert.equal(plugin.oauth_providers![0]!.token_auth_style, 'body_form'); + assert.equal(plugin.oauth_providers![0]!.pkce, true); + // acquires_oauth chip is derived from descriptor presence (loader-side). + assert.equal(plugin.permissions_summary.acquires_oauth, true); + // (b) the type:oauth field is present — this is what arms ctx.oauthTokens. + const oauthField = plugin.setup_fields.find((f) => f.key === 'gmail_oauth'); + assert.ok(oauthField, 'type:oauth setup field survived the loader'); + assert.equal(oauthField!.type, 'oauth'); + assert.equal(oauthField!.provider, 'google'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it('renders depends_on: [] when spec.depends_on is empty (B.6-9.1)', async () => { // Self-contained agent (PAT/OAuth in own setup_fields, no parent // integration plugin) — depends_on must render as an empty YAML list, diff --git a/web-ui/app/_lib/builderTypes.ts b/web-ui/app/_lib/builderTypes.ts index 3910bca7..a4c0054a 100644 --- a/web-ui/app/_lib/builderTypes.ts +++ b/web-ui/app/_lib/builderTypes.ts @@ -124,8 +124,26 @@ export interface SetupField { key: string; label?: string; description?: string; - type?: 'string' | 'secret' | 'url' | 'number' | 'boolean'; + type?: 'string' | 'secret' | 'url' | 'number' | 'boolean' | 'oauth'; required?: boolean; + /** Spec 005 (#371) — for `type: 'oauth'` fields: the oauth_providers[].id + * this field connects through, plus the requested scopes. */ + provider?: string; + scopes?: string[]; +} + +/** Spec 005 (#371) — declarative OAuth-provider descriptor. Mirrors + * middleware OAuthProviderSchema / OAuthProviderDescriptor. A `type:oauth` + * setup_field references one by `id` via its `provider`. */ +export interface OAuthProvider { + id: string; + authorize_url: string; + token_url: string; + token_auth_style: 'body_form' | 'body_json' | 'basic'; + pkce?: boolean; + extra_authorize_params?: Record; + client_id_field: string; + client_secret_field: string; } export interface AgentSpecSkeleton { @@ -147,6 +165,9 @@ export interface AgentSpecSkeleton { tools: ToolSpec[]; skill: { role: string; tonality?: string }; setup_fields: SetupField[]; + /** Spec 005 (#371) — declarative OAuth-provider descriptors. Optional; + * defaults to [] in spec. Mirrors middleware AgentSpecSkeleton. */ + oauth_providers?: OAuthProvider[]; playbook: { when_to_use: string; not_for: string[]; From 5dda400673b65a7c90658584e4b1febb397c1f8e Mon Sep 17 00:00:00 2001 From: Sophie Neumann Date: Wed, 1 Jul 2026 10:39:14 +0200 Subject: [PATCH 2/2] feat(builder): wire type:oauth UI + gate provider/scopes --- middleware/src/plugins/builder/codegen.ts | 13 ++- .../src/plugins/builder/manifestLinter.ts | 26 ++++- .../builder/agentSpecOAuthProviders.test.ts | 66 +++++++++++++ middleware/test/builder/codegen.test.ts | 89 +++++++++++++++++ web-ui/app/_lib/builderTypes.ts | 5 +- .../[id]/_components/SetupFieldsEditor.tsx | 98 ++++++++++++++++++- .../builder/[id]/_components/SpecEditor.tsx | 6 +- web-ui/messages/de.json | 7 ++ web-ui/messages/en.json | 7 ++ 9 files changed, 308 insertions(+), 9 deletions(-) diff --git a/middleware/src/plugins/builder/codegen.ts b/middleware/src/plugins/builder/codegen.ts index f7705365..1157147f 100644 --- a/middleware/src/plugins/builder/codegen.ts +++ b/middleware/src/plugins/builder/codegen.ts @@ -586,9 +586,16 @@ function mapSetupFieldSpecToManifest( if (field.enum_values && field.enum_values.length > 0) { out['enum'] = field.enum_values.map((value) => ({ value, label: value })); } - // Spec 005 (#371) — forward type:oauth descriptor reference + scopes. - if (field.provider) out['provider'] = field.provider; - if (field.scopes && field.scopes.length > 0) out['scopes'] = field.scopes; + // Spec 005 (#371) — forward the descriptor reference + scopes for + // type:oauth fields only. The loader reads provider/scopes solely inside + // `if (type === 'oauth')`, so gating the forward here keeps the schema's + // documented "type:'oauth' only" contract honest — a stray provider on a + // non-oauth field is dropped (and rejected up-front by the linter) rather + // than emitted into a silently-malformed manifest. + if (field.type === 'oauth') { + if (field.provider) out['provider'] = field.provider; + if (field.scopes && field.scopes.length > 0) out['scopes'] = field.scopes; + } return out; } diff --git a/middleware/src/plugins/builder/manifestLinter.ts b/middleware/src/plugins/builder/manifestLinter.ts index 45608dc2..c75832dd 100644 --- a/middleware/src/plugins/builder/manifestLinter.ts +++ b/middleware/src/plugins/builder/manifestLinter.ts @@ -51,7 +51,8 @@ export type ViolationKind = | 'oauth_field_provider_unresolved' | 'oauth_provider_client_field_missing' | 'oauth_provider_unreferenced' - | 'oauth_provider_client_secret_not_secret'; + | 'oauth_provider_client_secret_not_secret' + | 'setup_field_provider_on_non_oauth'; export interface ManifestViolation { kind: ViolationKind; @@ -360,6 +361,29 @@ export function validateSpec( const field = f as { key?: unknown; type?: unknown }; if (typeof field.key === 'string') setupFieldTypes.set(field.key, field.type); } + // provider/scopes are the type:oauth wiring — the Zod schema makes both + // optional on every field type, so a non-oauth field can carry a stray + // provider. The loader only reads them inside `if (type === 'oauth')` and + // codegen now gates the forward, so such a field is silently inert. Reject + // it up-front rather than let a malformed field slip past. + setupFields.forEach((f, i) => { + if (!f || typeof f !== 'object') return; + const field = f as { type?: unknown; provider?: unknown; scopes?: unknown }; + if (field.type === 'oauth') return; + const hasProvider = + typeof field.provider === 'string' && field.provider.length > 0; + const hasScopes = Array.isArray(field.scopes) && field.scopes.length > 0; + if (hasProvider || hasScopes) { + violations.push({ + kind: 'setup_field_provider_on_non_oauth', + path: `/setup_fields/${i}/${hasProvider ? 'provider' : 'scopes'}`, + message: + `setup_fields[${i}] is type:'${String(field.type)}' but carries ` + + `${hasProvider ? 'a provider' : 'scopes'} — those belong to a ` + + "type:'oauth' field only. Set type:'oauth' or drop the field.", + }); + } + }); // type:oauth field → must reference a declared descriptor; collect referenced // ids so orphan descriptors can be flagged below. const referencedProviderIds = new Set(); diff --git a/middleware/test/builder/agentSpecOAuthProviders.test.ts b/middleware/test/builder/agentSpecOAuthProviders.test.ts index b9081621..4162d8a3 100644 --- a/middleware/test/builder/agentSpecOAuthProviders.test.ts +++ b/middleware/test/builder/agentSpecOAuthProviders.test.ts @@ -174,6 +174,72 @@ test('manifestLinter rejects a client_secret_field that is not type:secret', () ); }); +test('manifestLinter rejects provider/scopes on a non-oauth field (#371)', () => { + const skel = { + ...baseSpec(), + setup_fields: [ + ...credFields(), + { key: 'gmail_oauth', type: 'oauth', required: true, provider: 'google' }, + // stray provider on a plain string field — inert, must be rejected. + { key: 'api_base', type: 'string', required: false, provider: 'google' }, + ], + oauth_providers: [googleProvider()], + } as unknown as AgentSpecSkeleton; + const result = validateSpec(skel); + assert.equal(result.ok, false); + assert.ok( + result.violations.some( + (v) => + v.kind === 'setup_field_provider_on_non_oauth' && + v.path === '/setup_fields/3/provider', + ), + JSON.stringify(result.violations), + ); +}); + +test('manifestLinter rejects scopes on a non-oauth field (#371)', () => { + const skel = { + ...baseSpec(), + setup_fields: [ + ...credFields(), + { key: 'gmail_oauth', type: 'oauth', required: true, provider: 'google' }, + { key: 'region', type: 'string', required: false, scopes: ['read'] }, + ], + oauth_providers: [googleProvider()], + } as unknown as AgentSpecSkeleton; + const result = validateSpec(skel); + assert.equal(result.ok, false); + assert.ok( + result.violations.some( + (v) => v.kind === 'setup_field_provider_on_non_oauth', + ), + ); +}); + +test('manifestLinter accepts multiple type:oauth fields with distinct providers (#371)', () => { + const githubProvider = () => ({ + id: 'github', + authorize_url: 'https://github.com/login/oauth/authorize', + token_url: 'https://github.com/login/oauth/access_token', + token_auth_style: 'body_json', + client_id_field: 'github_client_id', + client_secret_field: 'github_client_secret', + }); + const skel = { + ...baseSpec(), + setup_fields: [ + ...credFields(), + { key: 'github_client_id', type: 'string', required: true }, + { key: 'github_client_secret', type: 'secret', required: true }, + { key: 'gmail_oauth', type: 'oauth', required: true, provider: 'google' }, + { key: 'github_oauth', type: 'oauth', required: true, provider: 'github' }, + ], + oauth_providers: [googleProvider(), githubProvider()], + } as unknown as AgentSpecSkeleton; + const result = validateSpec(skel); + assert.equal(result.ok, true, JSON.stringify(result.violations)); +}); + test('manifestLinter rejects duplicate oauth_providers ids', () => { const skel = { ...baseSpec(), diff --git a/middleware/test/builder/codegen.test.ts b/middleware/test/builder/codegen.test.ts index 38bd2880..31c66662 100644 --- a/middleware/test/builder/codegen.test.ts +++ b/middleware/test/builder/codegen.test.ts @@ -574,6 +574,95 @@ describe('codegen.generate', () => { ]); }); + it('does not forward provider/scopes for a non-oauth field (Spec 005, #371)', async () => { + // The Zod schema allows provider/scopes on any field type; codegen must + // only forward them for type:oauth, so a stray provider never lands in the + // manifest where the loader would never read it. + const { slots } = loadFixture(); + const spec = parseAgentSpec({ + id: 'de.byte5.agent.strayprovider', + name: 'Stray Provider', + description: 'non-oauth field carrying a stray provider', + category: 'other', + depends_on: [], + tools: [{ id: 'do_thing', description: 'x', input: { type: 'object' } }], + skill: { role: 'x' }, + playbook: { when_to_use: 'x', not_for: ['x'], example_prompts: ['x', 'y'] }, + network: { outbound: [] }, + setup_fields: [ + // provider/scopes on a plain string field — must be dropped by codegen. + { + key: 'api_base', + type: 'string', + required: false, + provider: 'google', + scopes: ['read'], + }, + ], + }); + const out = await generate({ spec, slots }); + const manifestText = out.get('manifest.yaml')!.toString('utf-8'); + const parsed = yaml.parse(manifestText) as { + setup?: { fields?: Array> }; + }; + const field = parsed.setup?.fields?.find((f) => f['key'] === 'api_base'); + assert.ok(field, 'api_base field present in manifest'); + assert.equal(field!['provider'], undefined); + assert.equal(field!['scopes'], undefined); + }); + + it('round-trips extra_authorize_params through codegen + loader (Spec 005, #371)', async () => { + // extra_authorize_params is descriptor data the broker replays onto the + // authorize URL (e.g. access_type=offline for a Google refresh token). + // Prove it survives codegen and the real loader untouched. + const { slots } = loadFixture(); + const spec = parseAgentSpec({ + id: 'de.byte5.agent.gmailparams', + name: 'Gmail Params', + description: 'summarises Gmail via the kernel OAuth broker', + category: 'communication', + depends_on: [], + tools: [{ id: 'do_thing', description: 'x', input: { type: 'object' } }], + skill: { role: 'x' }, + playbook: { when_to_use: 'x', not_for: ['x'], example_prompts: ['x', 'y'] }, + network: { outbound: ['gmail.googleapis.com'] }, + setup_fields: [ + { key: 'google_client_id', type: 'string', required: true }, + { key: 'google_client_secret', type: 'secret', required: true }, + { key: 'gmail_oauth', type: 'oauth', required: true, provider: 'google' }, + ], + oauth_providers: [ + { + id: 'google', + authorize_url: 'https://accounts.google.com/o/oauth2/v2/auth', + token_url: 'https://oauth2.googleapis.com/token', + token_auth_style: 'body_form', + extra_authorize_params: { access_type: 'offline', prompt: 'consent' }, + client_id_field: 'google_client_id', + client_secret_field: 'google_client_secret', + }, + ], + }); + const out = await generate({ spec, slots }); + const manifestText = out.get('manifest.yaml')!.toString('utf-8'); + + const dir = mkdtempSync(path.join(os.tmpdir(), 'omadia-oauth-params-')); + try { + const manifestPath = path.join(dir, 'plugin.manifest.yaml'); + writeFileSync(manifestPath, manifestText, 'utf-8'); + const entry = await loadManifestFromPath(manifestPath); + assert.ok(entry, 'loader accepted the generated manifest'); + const desc = entry!.plugin.oauth_providers?.[0]; + assert.ok(desc, 'descriptor survived the loader'); + assert.deepEqual(desc!.extra_authorize_params, { + access_type: 'offline', + prompt: 'consent', + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it('omits oauth_providers from the manifest when the spec declares none', async () => { const { slots } = loadFixture(); const spec = parseAgentSpec({ diff --git a/web-ui/app/_lib/builderTypes.ts b/web-ui/app/_lib/builderTypes.ts index a4c0054a..9c0dcbc6 100644 --- a/web-ui/app/_lib/builderTypes.ts +++ b/web-ui/app/_lib/builderTypes.ts @@ -140,7 +140,10 @@ export interface OAuthProvider { authorize_url: string; token_url: string; token_auth_style: 'body_form' | 'body_json' | 'basic'; - pkce?: boolean; + /** Required to mirror the kernel contract (admin-v1 `OAuthProviderDescriptor` + * + middleware Zod, which defaults it to true on parse). Served specs always + * carry it. */ + pkce: boolean; extra_authorize_params?: Record; client_id_field: string; client_secret_field: string; diff --git a/web-ui/app/store/builder/[id]/_components/SetupFieldsEditor.tsx b/web-ui/app/store/builder/[id]/_components/SetupFieldsEditor.tsx index 4cae8b20..2eded695 100644 --- a/web-ui/app/store/builder/[id]/_components/SetupFieldsEditor.tsx +++ b/web-ui/app/store/builder/[id]/_components/SetupFieldsEditor.tsx @@ -9,6 +9,7 @@ import { Plus, Trash2 } from 'lucide-react'; import { ApiError, patchBuilderSpec } from '../../../../_lib/api'; import type { JsonPatch, + OAuthProvider, SetupField, } from '../../../../_lib/builderTypes'; import { cn } from '../../../../_lib/cn'; @@ -18,6 +19,9 @@ interface SetupFieldsEditorProps { /** Server-canonical setup_fields. Re-renders flow through here via SSE * + Workspace re-fetch. */ fields: ReadonlyArray; + /** Spec 005 (#371) — declared `oauth_providers` descriptors. A `type:oauth` + * field's `provider` picks one by `id`; empty means none authored yet. */ + oauthProviders: ReadonlyArray; } const FIELD_TYPES: ReadonlyArray> = [ @@ -26,6 +30,7 @@ const FIELD_TYPES: ReadonlyArray> = [ 'url', 'number', 'boolean', + 'oauth', ]; const KEY_PATTERN = /^[a-z][a-z0-9_]*$/; @@ -44,6 +49,7 @@ const KEY_PATTERN = /^[a-z][a-z0-9_]*$/; export function SetupFieldsEditor({ draftId, fields, + oauthProviders, }: SetupFieldsEditorProps): React.ReactElement { const t = useTranslations('builder.spec.setupFields'); const safeFields = useMemo( @@ -108,6 +114,7 @@ export function SetupFieldsEditor({ updateAt(i, patch)} onRemove={() => removeAt(i)} /> @@ -129,16 +136,38 @@ export function SetupFieldsEditor({ function SetupFieldRow({ field, + oauthProviders, onChange, onRemove, }: { field: SetupField; + oauthProviders: ReadonlyArray; onChange: (patch: Partial) => void; onRemove: () => void; }): React.ReactElement { const t = useTranslations('builder.spec.setupFields'); const keyId = useId(); const labelId = useId(); + const providerId = useId(); + const scopesId = useId(); + // scopes is a comma-separated list — keep the raw text local so an + // in-progress separator ("profile," while typing the second scope) survives + // the per-keystroke PATCH→refetch round-trip that filters empty entries. + const canonicalScopes = (field.scopes ?? []).join(', '); + const [scopesText, setScopesText] = useState(canonicalScopes); + const [prevCanonicalScopes, setPrevCanonicalScopes] = useState(canonicalScopes); + // Adjust-state-on-prop-change (React idiom, no effect): re-sync only on an + // external change (agent edit / other tab), not on the echo of our own edit + // — compare canonical to what the local text parses back to. + if (canonicalScopes !== prevCanonicalScopes) { + setPrevCanonicalScopes(canonicalScopes); + const localParsed = scopesText + .split(',') + .map((s) => s.trim()) + .filter((s) => s.length > 0) + .join(', '); + if (canonicalScopes !== localParsed) setScopesText(canonicalScopes); + } return (
  • @@ -187,9 +216,16 @@ function SetupFieldRow({ + onChange({ provider: e.target.value || undefined }) + } + className="font-mono-num mt-0.5 w-full rounded-md border border-[color:var(--border)] bg-[color:var(--bg)] px-2 py-1 text-[12px] text-[color:var(--fg-strong)] focus:border-[color:var(--accent)] focus:outline-none" + > + + {oauthProviders.map((p) => ( + + ))} + + ) : ( +

    + {t('oauth.noProviders')} +

    + )} +
    +
    + + { + setScopesText(e.target.value); + const scopes = e.target.value + .split(',') + .map((s) => s.trim()) + .filter((s) => s.length > 0); + onChange({ scopes: scopes.length > 0 ? scopes : undefined }); + }} + placeholder={t('oauth.scopesPlaceholder')} + className="font-mono-num mt-0.5 w-full rounded-md border border-[color:var(--border)] bg-[color:var(--bg)] px-2 py-1 text-[12px] text-[color:var(--fg-strong)] placeholder:text-[color:var(--fg-subtle)] focus:border-[color:var(--accent)] focus:outline-none" + /> +
    + + ) : null}
  • ); } diff --git a/web-ui/app/store/builder/[id]/_components/SpecEditor.tsx b/web-ui/app/store/builder/[id]/_components/SpecEditor.tsx index d13abb97..a0cc20a4 100644 --- a/web-ui/app/store/builder/[id]/_components/SpecEditor.tsx +++ b/web-ui/app/store/builder/[id]/_components/SpecEditor.tsx @@ -305,7 +305,11 @@ export function SpecEditor({ draftId, spec, agentStuck }: SpecEditorProps): Reac - + diff --git a/web-ui/messages/de.json b/web-ui/messages/de.json index 299a46d3..0c40ed60 100644 --- a/web-ui/messages/de.json +++ b/web-ui/messages/de.json @@ -1201,6 +1201,13 @@ "validation": { "keyExists": "Key existiert bereits.", "keyFormat": "Key muss snake_case sein (a-z, 0-9, _; mit Buchstaben starten)." + }, + "oauth": { + "provider": "Provider", + "selectProvider": "— Provider wählen —", + "noProviders": "Zuerst einen oauth_providers-Deskriptor anlegen (den Builder-Agent fragen) — dann hier auswählen.", + "scopes": "Scopes", + "scopesPlaceholder": "kommagetrennt, z.B. profile, email" } } }, diff --git a/web-ui/messages/en.json b/web-ui/messages/en.json index b657cce5..00ae4db5 100644 --- a/web-ui/messages/en.json +++ b/web-ui/messages/en.json @@ -1201,6 +1201,13 @@ "validation": { "keyExists": "Key already exists.", "keyFormat": "Key must be snake_case (a-z, 0-9, _; start with a letter)." + }, + "oauth": { + "provider": "provider", + "selectProvider": "— select provider —", + "noProviders": "Declare an oauth_providers descriptor first (ask the builder agent) — then pick it here.", + "scopes": "scopes", + "scopesPlaceholder": "comma-separated, e.g. profile, email" } } },