/** * Admission policy for the extension-published Automation interface manifest. * * Like a setup entry, the interface manifest is data authored in a different * repository that instructs this host to render copy, build links, and address * requests, so the host decides what it is *permitted* to state. Admission is * all-or-nothing: one bad field rejects the whole manifest, and the host falls * back to its own defaults rather than rendering a partially-trusted mix. */ import type { AutomationAttributeName, InterfaceAttributeType, InterfaceRoutes, OverviewMetric, } from "./types"; import { DASHBOARD_FILTER_IDS, DASHBOARD_FILTER_VALUES, DASHBOARD_SORT_VALUES, INTERFACE_ICON_SLUGS, INTERFACE_SUB_PAGE_IDS, INTERFACE_VERSION, OVERVIEW_METRICS, OVERVIEW_TILE_PLACEHOLDERS, } from "./types"; /** Copy must never be able to inject markup into the host. */ const MARKUP_PATTERN = /<[A-Za-z/!]/; const SLUG_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/; /** The one URL the manifest may state, pinned to the product documentation. */ const DOCS_URL_PREFIX = "https://docs.openhands.dev/"; const FILE_KIND_PATTERN = /^[a-z][a-z-]*$/; const FILENAME_SUFFIX_PATTERN = /^\.[a-z][a-z.]*json$/; const EVENT_SOURCE_PATTERN = /^[a-z0-9][a-z0-9.-]*$/; /** Service-relative only: rooted, no traversal, no query, no fragment. */ const ENDPOINT_PATTERN = /^\/[A-Za-z0-9/{}_-]*$/; /** Exactly one `{id}` substitution and no other braces. */ const ID_ENDPOINT_PATTERN = /^[^{}]*\{id\}[^{}]*$/; const GIT_PROVIDERS = ["github", "gitlab", "bitbucket"] as const; /** * The attribute semantics this host's edit dialog implements: the control it * renders per property and the requiredness it enforces. Admission pins a * declared attribute to exactly these, so an admitted manifest can never * promise a control, a requiredness, or (below) a minimum the form would * silently ignore. */ const HOST_ATTRIBUTES: Record< AutomationAttributeName, { type: InterfaceAttributeType; required: boolean } > = { name: { type: "text", required: true }, prompt: { type: "textarea", required: false }, model: { type: "llm-profile", required: false }, timeout: { type: "number", required: false }, schedule: { type: "schedule", required: false }, }; const ATTRIBUTE_NAMES = Object.keys(HOST_ATTRIBUTES); const PLAIN_ENDPOINT_NAMES = [ "list", "health", "capabilities", "validate", "createPrompt", "createPlugin", ] as const; // Endpoints added after the block shipped. A manifest published before them is // still admitted - requiring them would 404 the whole surface for anyone // pinning an older package - so they are checked only when present, and a // bundle entry, which is the only thing that needs them, fails on its own if // its manifest predates them. const OPTIONAL_PLAIN_ENDPOINT_NAMES = ["createBundle", "uploads"] as const; const ID_ENDPOINT_NAMES = ["detail", "dispatch", "runs", "tarball"] as const; export interface InterfaceValidationContext { /** Ids of the published automation catalog, for the featured-list check. */ catalogIds: ReadonlySet; /** The routes this host has registrations for. A manifest must match them. */ mountedRoutes: InterfaceRoutes; } export interface InterfaceValidationResult { valid: boolean; errors: string[]; } type Rec = Record; function isRecord(value: unknown): value is Rec { return typeof value === "object" && value !== null && !Array.isArray(value); } function isOneOf( value: unknown, values: T, ): value is T[number] { return ( typeof value === "string" && (values as readonly string[]).includes(value) ); } class InterfaceChecker { readonly errors: string[] = []; fail(path: string, reason: string): false { this.errors.push(`${path}: ${reason}`); return false; } /** Literal user-visible copy. Carries no markup. */ copy(value: unknown, path: string): boolean { if (typeof value !== "string" || value.length === 0) { return this.fail(path, "must be a non-empty string"); } if (MARKUP_PATTERN.test(value)) { return this.fail(path, "must not contain markup"); } return true; } /** A closed object: every present key must be an expected one. */ closed(container: Rec, allowed: readonly string[], path: string): void { Object.keys(container) .filter((key) => !allowed.includes(key)) .forEach((key) => this.fail(`${path}.${key}`, "is not allowed")); } record(value: unknown, path: string): value is Rec { if (isRecord(value)) return true; this.fail(path, "must be an object"); return false; } } function checkRoutes( check: InterfaceChecker, routes: unknown, mounted: InterfaceRoutes, ): void { if (!check.record(routes, "routes")) return; check.closed(routes, ["list", "setup", "detail", "templates"], "routes"); // The host serves what it has registrations for, so a declared route must be // exactly the mounted shape — the manifest owns link construction, not the // router table. (["list", "setup", "detail"] as const).forEach((name) => { if (routes[name] !== mounted[name]) { check.fail(`routes.${name}`, `must be "${mounted[name]}"`); } }); if ( routes.templates !== undefined && routes.templates !== mounted.templates ) { check.fail( "routes.templates", mounted.templates === undefined ? "is not a route this host mounts" : `must be "${mounted.templates}"`, ); } } function checkSubPages(check: InterfaceChecker, subPages: unknown): void { if (!Array.isArray(subPages) || subPages.length === 0) { check.fail("navigation.subPages", "must be a non-empty array"); return; } const seen = new Set(); subPages.forEach((item: unknown, index) => { const path = `navigation.subPages[${index}]`; if (!check.record(item, path)) return; check.closed(item, ["page", "label", "icon"], path); const { page, label, icon } = item; if (!isOneOf(page, INTERFACE_SUB_PAGE_IDS)) { check.fail(`${path}.page`, "is not a sub-page this host serves"); } else if (seen.has(page)) { check.fail(`${path}.page`, "repeats a page"); } else { seen.add(page); } check.copy(label, `${path}.label`); checkIconSlug(check, icon, `${path}.icon`); }); } function checkIconSlug( check: InterfaceChecker, icon: unknown, path: string, ): void { if (!isOneOf(icon, INTERFACE_ICON_SLUGS)) { check.fail(path, "is not an icon this host ships"); } } function checkNavigation(check: InterfaceChecker, navigation: unknown): void { if (!check.record(navigation, "navigation")) return; check.closed( navigation, ["sidebar", "commandMenu", "subPages"], "navigation", ); if (check.record(navigation.sidebar, "navigation.sidebar")) { check.closed(navigation.sidebar, ["label"], "navigation.sidebar"); check.copy(navigation.sidebar.label, "navigation.sidebar.label"); } if (check.record(navigation.commandMenu, "navigation.commandMenu")) { const menu = navigation.commandMenu; check.closed( menu, ["title", "description", "keywords"], "navigation.commandMenu", ); check.copy(menu.title, "navigation.commandMenu.title"); check.copy(menu.description, "navigation.commandMenu.description"); check.copy(menu.keywords, "navigation.commandMenu.keywords"); } if (navigation.subPages !== undefined) { checkSubPages(check, navigation.subPages); } } function checkPages(check: InterfaceChecker, pages: unknown): void { if (!check.record(pages, "pages")) return; check.closed(pages, ["list", "detail", "edit", "templates"], "pages"); if (check.record(pages.list, "pages.list")) { check.closed( pages.list, ["title", "subtitle", "overview", "filters", "sort", "insights"], "pages.list", ); check.copy(pages.list.title, "pages.list.title"); check.copy(pages.list.subtitle, "pages.list.subtitle"); if (pages.list.overview !== undefined) { checkOverview(check, pages.list.overview); } if (pages.list.filters !== undefined) { checkFilters(check, pages.list.filters); } if (pages.list.sort !== undefined) { checkSort(check, pages.list.sort); } if (pages.list.insights !== undefined) { checkInsights(check, pages.list.insights); } } if (check.record(pages.detail, "pages.detail")) { check.closed(pages.detail, ["backLabel"], "pages.detail"); check.copy(pages.detail.backLabel, "pages.detail.backLabel"); } if (check.record(pages.edit, "pages.edit")) { check.closed(pages.edit, ["title"], "pages.edit"); check.copy(pages.edit.title, "pages.edit.title"); } if (pages.templates !== undefined) { checkTemplatesPage(check, pages.templates); } } function checkOverview(check: InterfaceChecker, overview: unknown): void { const path = "pages.list.overview"; if (!check.record(overview, path)) return; check.closed(overview, ["label", "tiles"], path); check.copy(overview.label, `${path}.label`); const { tiles } = overview; if (!Array.isArray(tiles) || tiles.length === 0) { check.fail(`${path}.tiles`, "must be a non-empty array"); return; } const seen = new Set(); tiles.forEach((tile: unknown, index) => { checkOverviewTile(check, tile, seen, `${path}.tiles[${index}]`); }); } function checkOverviewTile( check: InterfaceChecker, tile: unknown, seen: Set, path: string, ): void { if (!check.record(tile, path)) return; check.closed(tile, ["metric", "label", "detail", "zeroDetail", "icon"], path); const { metric, label, detail, zeroDetail, icon } = tile; check.copy(label, `${path}.label`); checkIconSlug(check, icon, `${path}.icon`); if (!isOneOf(metric, OVERVIEW_METRICS)) { check.fail(`${path}.metric`, "is not a metric this host computes"); check.copy(detail, `${path}.detail`); if (zeroDetail !== undefined) check.copy(zeroDetail, `${path}.zeroDetail`); return; } if (seen.has(metric)) { check.fail(`${path}.metric`, "repeats a metric"); } else { seen.add(metric); } checkTileCopy(check, detail, metric, `${path}.detail`); if (zeroDetail !== undefined) { checkTileCopy(check, zeroDetail, metric, `${path}.zeroDetail`); } } /** * Tile copy may embed `{{name}}` placeholders, but only the names the metric * exposes — plain substitution, never an expression. */ function checkTileCopy( check: InterfaceChecker, value: unknown, metric: OverviewMetric, path: string, ): void { if (!check.copy(value, path)) return; const exposed = OVERVIEW_TILE_PLACEHOLDERS[metric]; const unknownPlaceholder = exposed.length === 0 ? /\{\{/ : new RegExp(`\\{\\{(?!(?:${exposed.join("|")})\\}\\})`); if (unknownPlaceholder.test(value as string)) { check.fail(path, "uses a placeholder this metric does not expose"); } } function checkFilters(check: InterfaceChecker, filters: unknown): void { const path = "pages.list.filters"; if (!Array.isArray(filters) || filters.length === 0) { check.fail(path, "must be a non-empty array"); return; } const seen = new Set(); filters.forEach((filter: unknown, index) => { checkFilter(check, filter, seen, `${path}[${index}]`); }); } function checkFilter( check: InterfaceChecker, filter: unknown, seen: Set, path: string, ): void { if (!check.record(filter, path)) return; check.closed(filter, ["id", "label", "options"], path); const { id, label, options } = filter; check.copy(label, `${path}.label`); if (!isOneOf(id, DASHBOARD_FILTER_IDS)) { check.fail(`${path}.id`, "is not a filter this host implements"); return; } if (seen.has(id)) { check.fail(`${path}.id`, "repeats a filter"); } else { seen.add(id); } checkFilterOptions( check, options, DASHBOARD_FILTER_VALUES[id], `${path}.options`, ); } function checkFilterOptions( check: InterfaceChecker, options: unknown, values: readonly string[], path: string, ): void { if (!Array.isArray(options) || options.length < 2) { check.fail(path, "must offer at least two options"); return; } const seen = new Set(); options.forEach((option: unknown, index) => { const optionPath = `${path}[${index}]`; if (!check.record(option, optionPath)) return; check.closed(option, ["value", "label"], optionPath); const { value, label } = option; if (!isOneOf(value, values)) { check.fail(`${optionPath}.value`, "is not a value this host implements"); } else if (seen.has(value)) { check.fail(`${optionPath}.value`, "repeats a value"); } else { seen.add(value); } check.copy(label, `${optionPath}.label`); }); // "all" is the host's initial selection and what Clear filters resets to, // so a filter that does not offer it could never be neutral. if (!seen.has("all")) { check.fail(path, 'must offer the "all" option'); } } function checkSort(check: InterfaceChecker, sort: unknown): void { const path = "pages.list.sort"; if (!check.record(sort, path)) return; check.closed(sort, ["label", "options", "default"], path); const { label, options, default: defaultValue } = sort; check.copy(label, `${path}.label`); const declared = new Set(); if (!Array.isArray(options) || options.length === 0) { check.fail(`${path}.options`, "must be a non-empty array"); } else { options.forEach((option: unknown, index) => { const optionPath = `${path}.options[${index}]`; if (!check.record(option, optionPath)) return; check.closed(option, ["value", "label"], optionPath); if (!isOneOf(option.value, DASHBOARD_SORT_VALUES)) { check.fail(`${optionPath}.value`, "is not a sort this host implements"); } else if (declared.has(option.value)) { check.fail(`${optionPath}.value`, "repeats a value"); } else { declared.add(option.value); } check.copy(option.label, `${optionPath}.label`); }); } if (typeof defaultValue !== "string" || !declared.has(defaultValue)) { check.fail(`${path}.default`, "must be one of the declared option values"); } } /** Every insight state and stat the host renders must have a caption. */ const INSIGHTS_SHAPE = { health: ["healthy", "failing", "running", "disabled", "neverRun", "checking"], lastRun: ["label", "never", "justNow"], stats: ["runs", "recentSuccess", "averageDuration"], } as const; function checkInsights(check: InterfaceChecker, insights: unknown): void { const path = "pages.list.insights"; if (!check.record(insights, path)) return; check.closed(insights, Object.keys(INSIGHTS_SHAPE), path); (Object.keys(INSIGHTS_SHAPE) as (keyof typeof INSIGHTS_SHAPE)[]).forEach( (section) => { const sectionPath = `${path}.${section}`; const value = insights[section]; if (!check.record(value, sectionPath)) return; const keys = INSIGHTS_SHAPE[section]; check.closed(value, keys, sectionPath); keys.forEach((key) => { check.copy(value[key], `${sectionPath}.${key}`); }); }, ); } function checkTemplatesPage(check: InterfaceChecker, templates: unknown): void { const path = "pages.templates"; if (!check.record(templates, path)) return; check.closed(templates, ["title", "description"], path); check.copy(templates.title, `${path}.title`); check.copy(templates.description, `${path}.description`); } /** * The sub-page surface is declared whole or not at all. Navigation that points * at an unrouted page, or a dashboard with filters but no insight captions, * would be a partially-trusted mix, and the host refuses those wholesale. */ function checkSubPageGroup(check: InterfaceChecker, candidate: Rec): void { const routes = isRecord(candidate.routes) ? candidate.routes : {}; const navigation = isRecord(candidate.navigation) ? candidate.navigation : {}; const pages = isRecord(candidate.pages) ? candidate.pages : {}; const list = isRecord(pages.list) ? pages.list : {}; const pieces: [string, unknown][] = [ ["routes.templates", routes.templates], ["navigation.subPages", navigation.subPages], ["pages.templates", pages.templates], ["pages.list.overview", list.overview], ["pages.list.filters", list.filters], ["pages.list.sort", list.sort], ["pages.list.insights", list.insights], ]; const missing = pieces .filter(([, value]) => value === undefined) .map(([name]) => name); if (missing.length === 0 || missing.length === pieces.length) return; check.fail( "interface", `the sub-page surface must be declared whole; missing ${missing.join(", ")}`, ); } function checkAttribute( check: InterfaceChecker, host: { type: InterfaceAttributeType; required: boolean }, attribute: unknown, path: string, ): void { if (!check.record(attribute, path)) return; check.closed( attribute, ["type", "label", "help", "required", "constraints"], path, ); const { type, label, help, required, constraints } = attribute; if (type !== host.type) { check.fail( `${path}.type`, `must be "${host.type}", the control this host renders`, ); } check.copy(label, `${path}.label`); if (help !== undefined) check.copy(help, `${path}.help`); if (required !== host.required) { check.fail( `${path}.required`, `must be ${host.required}, what this host enforces`, ); } if (constraints !== undefined) { if (host.type !== "number") { check.fail( `${path}.constraints`, "is only allowed on a number attribute", ); } else if (check.record(constraints, `${path}.constraints`)) { check.closed(constraints, ["min", "max"], `${path}.constraints`); const { min, max } = constraints; // The form validates a positive integer, i.e. an effective minimum of 1; // any other declared minimum would be a promise it does not keep. if (min !== undefined && min !== 1) { check.fail( `${path}.constraints.min`, "must be the 1 this host enforces", ); } if ( max !== undefined && (!Number.isInteger(max) || (max as number) < 1) ) { check.fail(`${path}.constraints.max`, "must be a positive integer"); } } } } function checkAttributes(check: InterfaceChecker, attributes: unknown): void { if (!check.record(attributes, "attributes")) return; const names = Object.keys(attributes); if (names.length === 0) { check.fail("attributes", "must declare at least one attribute"); } names.forEach((name) => { if (!ATTRIBUTE_NAMES.includes(name)) { check.fail(`attributes.${name}`, "is not a settable attribute"); return; } checkAttribute( check, HOST_ATTRIBUTES[name as AutomationAttributeName], attributes[name], `attributes.${name}`, ); }); } function checkImportExport( check: InterfaceChecker, importExport: unknown, ): void { if (!check.record(importExport, "importExport")) return; check.closed( importExport, ["fileKind", "fileVersion", "filenameSuffix", "importDefaults"], "importExport", ); const { fileKind, fileVersion, filenameSuffix, importDefaults } = importExport; if (typeof fileKind !== "string" || !FILE_KIND_PATTERN.test(fileKind)) { check.fail("importExport.fileKind", "must be a lowercase slug"); } if (fileVersion !== 1) { check.fail("importExport.fileVersion", "must be 1"); } if ( typeof filenameSuffix !== "string" || !FILENAME_SUFFIX_PATTERN.test(filenameSuffix) ) { check.fail("importExport.filenameSuffix", "must be a .json suffix"); } if (check.record(importDefaults, "importExport.importDefaults")) { check.closed( importDefaults, ["repoProvider", "placeholderEventSource"], "importExport.importDefaults", ); if ( typeof importDefaults.repoProvider !== "string" || !(GIT_PROVIDERS as readonly string[]).includes( importDefaults.repoProvider, ) ) { check.fail( "importExport.importDefaults.repoProvider", "is not a supported provider", ); } if ( typeof importDefaults.placeholderEventSource !== "string" || !EVENT_SOURCE_PATTERN.test(importDefaults.placeholderEventSource) ) { check.fail( "importExport.importDefaults.placeholderEventSource", "must be a lowercase event source", ); } } } function checkEndpoints(check: InterfaceChecker, endpoints: unknown): void { if (!check.record(endpoints, "endpoints")) return; const allowed = [ ...PLAIN_ENDPOINT_NAMES, ...OPTIONAL_PLAIN_ENDPOINT_NAMES, ...ID_ENDPOINT_NAMES, ]; check.closed(endpoints, allowed, "endpoints"); const endpointAt = (name: string): string | null => { const value = endpoints[name]; if ( typeof value !== "string" || !ENDPOINT_PATTERN.test(value) || value.includes("//") ) { check.fail(`endpoints.${name}`, "must be a rooted service-relative path"); return null; } return value; }; const plainNames = [ ...PLAIN_ENDPOINT_NAMES, ...OPTIONAL_PLAIN_ENDPOINT_NAMES.filter((name) => name in endpoints), ]; plainNames.forEach((name) => { const value = endpointAt(name); if (value !== null && /[{}]/.test(value)) { check.fail(`endpoints.${name}`, "must not carry a substitution"); } }); ID_ENDPOINT_NAMES.forEach((name) => { const value = endpointAt(name); if (value !== null && !ID_ENDPOINT_PATTERN.test(value)) { check.fail(`endpoints.${name}`, "must carry exactly one {id}"); } }); } function checkSlugList( check: InterfaceChecker, value: unknown, path: string, ): string[] { if (!Array.isArray(value) || value.length === 0) { check.fail(path, "must be a non-empty array"); return []; } const slugs = value.filter((item): item is string => { if (typeof item === "string" && SLUG_PATTERN.test(item)) return true; check.fail(path, "must contain only lowercase slugs"); return false; }); if (new Set(slugs).size !== slugs.length) { check.fail(path, "must not repeat an id"); } return slugs; } const TOP_LEVEL_KEYS = [ "version", "routes", "navigation", "pages", "docsUrl", "attributes", "importExport", "endpoints", "featuredAutomationIds", "responderIntegrationIds", ]; /** * Decide whether this host will act on a published interface manifest. * * The version is checked first and fails closed: a format this host does not * recognise is refused rather than interpreted with today's rules. */ export function validateInterfaceManifest( candidate: unknown, context: InterfaceValidationContext, ): InterfaceValidationResult { if (!isRecord(candidate)) { return { valid: false, errors: ["interface: must be an object"] }; } if (candidate.version !== INTERFACE_VERSION) { return { valid: false, errors: [`interface.version: must be "${INTERFACE_VERSION}"`], }; } const check = new InterfaceChecker(); check.closed(candidate, TOP_LEVEL_KEYS, "interface"); checkRoutes(check, candidate.routes, context.mountedRoutes); checkNavigation(check, candidate.navigation); checkPages(check, candidate.pages); if ( typeof candidate.docsUrl !== "string" || !candidate.docsUrl.startsWith(DOCS_URL_PREFIX) ) { check.fail("docsUrl", `must start with ${DOCS_URL_PREFIX}`); } checkAttributes(check, candidate.attributes); checkImportExport(check, candidate.importExport); checkEndpoints(check, candidate.endpoints); checkSlugList( check, candidate.featuredAutomationIds, "featuredAutomationIds", ).forEach((id) => { if (!context.catalogIds.has(id)) { check.fail("featuredAutomationIds", `${id} is not a catalog entry`); } }); checkSlugList( check, candidate.responderIntegrationIds, "responderIntegrationIds", ); checkSubPageGroup(check, candidate); return { valid: check.errors.length === 0, errors: check.errors }; }