Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions middleware/src/plugins/builder/agentSpec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -435,6 +439,31 @@ export const EXTENDED_PERSONA_AXES = [
'philosophy',
] as const satisfies ReadonlyArray<keyof PersonaAxes>;

// --- 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,
Comment thread
ConnysCode marked this conversation as resolved.
// 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<typeof OAuthProviderSchema>;

// --- AgentSpec ------------------------------------------------------------

export const AgentSpecSchema = z
Expand Down Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions middleware/src/plugins/builder/codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -580,6 +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 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;
}

Expand Down
127 changes: 126 additions & 1 deletion middleware/src/plugins/builder/manifestLinter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,14 @@ 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'
| 'setup_field_provider_on_non_oauth';

export interface ManifestViolation {
kind: ViolationKind;
Expand Down Expand Up @@ -321,6 +328,124 @@ 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<Record<string, unknown>>)
: [];
const providerIdIndices = new Map<string, number[]>();
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<string, unknown>();
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);
}
// 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<string>();
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
Expand Down
4 changes: 4 additions & 0 deletions middleware/src/plugins/builder/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down
Loading
Loading