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
40 changes: 40 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,46 @@ entry. See `CONTRIBUTING.md` § Releases & changelog.

## [Unreleased]

### Added — plugin-contributed navigation (#470, phase 1 of the Dev Platform extraction)

- New plugin capability `ctx.uiRoutes.registerNav({ navId, href, cluster?,
order?, label })` lets an installed plugin contribute entries to the
operator navigation. Backed by `UiRouteCatalog.registerNav()` /
`listNav(locale)` and served by a new session-gated route
`GET /api/v1/ui/navigation?locale=<l>`, which returns labels **already
resolved** for the requested locale — the browser never receives the
per-locale map, so the web UI stays on next-intl's single i18n clock.
- The web-ui shell (`Nav.tsx`) now merges its static nav with the
contributed entries, fetched server-side in the root layout. An entry
joins the cluster it names; an unknown or absent cluster promotes it to
top level; an href colliding with a static one is dropped so a plugin
cannot shadow a core destination. Every plugin-supplied field is
validated as untrusted input (canonical in-app hrefs only; labels
length-capped and screened for control, bidi and zero-width codepoints).
- Dev Platform is the first consumer: its menu entry and its `/admin` grid
card now come from that registration instead of being hardcoded, so
disabling the feature removes both with no frontend rebuild. Removes the
now-unused `nav.devPlatform` key from `messages/{en,de}.json`.
- Rationale and the remaining extraction phases:
`specs/470-dev-platform-plugin/plan.md`.

### Fixed — deactivated tool plugins kept serving their Express routes

- `ToolPluginRuntime.deactivate()` stopped background jobs and disposed UI
routes but never called `pluginRouteRegistry.disposeBySource()`, although
it held that dependency and threaded it into every plugin context
(`DynamicAgentRuntime` already did). Express cannot unmount, so an
uninstalled or hot-upgraded plugin's routers stayed live and — because
Express matches first-mount-wins — kept answering and shadowed anything
later mounted at the same prefix.
- Disposal now also runs **before** the plugin-controlled `close()` is
awaited; previously a plugin whose `close()` hung kept its routes and menu
entry live for the full 5s budget after the operator triggered
deactivation. `activate()` additionally rolls back its own route/nav/job
registrations when a plugin registers and then throws or times out —
such a plugin never reaches the active set, so `deactivate()` could never
clean it up and the orphan survived for the life of the process.

### Added — Conductor generic webhook support, inbound + outbound (#437)

- **Inbound**: `POST /api/hooks/:endpointId` (unauthenticated mount, raw-body
Expand Down
48 changes: 48 additions & 0 deletions docs/middleware-agent-handoff.md
Original file line number Diff line number Diff line change
Expand Up @@ -785,6 +785,54 @@ schrieb der Import-Pfad unter der kanonischen id, während der Query-Pfad die
rohe id las, sodass ein Channel-User sein eigenes gerade importiertes Dataset
nie wiederfinden konnte.

### Plugin-contributed Navigation (#470, Phase 1 der Dev-Platform-Extraktion)

Damit ein Feature wirklich *installierbar* ist, muss sein Menü-Eintrag mit
dem Plugin mitreisen — bisher war die Navigation ein eingefrorenes Literal in
`web-ui/app/_components/Nav.tsx`. Neue Plugin-Fähigkeit:

```ts
ctx.uiRoutes.registerNav({ navId, href, cluster?, order?, label })
```

Bewusst getrennt von `ctx.uiRoutes.register()`: ein uiRoute-Descriptor
adressiert relativ zum `/p/<pluginId>`-Mount des Plugins, ein Nav-Eintrag
adressiert einen absoluten In-App-Pfad. Beides in einen Descriptor zu falten
würde eines der zwei Pfad-Felder zur Lüge machen. Beide teilen sich denselben
Lifecycle in `UiRouteCatalog` (`disposeBySource` räumt beide ab).

Neue Route: **`GET /api/v1/ui/navigation?locale=<l>`**
(`src/routes/uiNavigation.ts`), gemountet unter `/api` und zusätzlich
explizit hinter `requireAuth` — die Einträge verraten, welche Features
installiert sind. Antwort ist `no-store` und enthält **bereits aufgelöste**
Labels: der Browser bekommt die Locale-Map nie zu sehen, dadurch bleibt das
Web-UI auf genau einer i18n-Uhr (next-intl) statt auf zwei, die beim
Sprachwechsel auseinanderlaufen.

Die Shell holt die Einträge **server-seitig im Root-Layout** (`fetchNavEntries`
in `web-ui/app/_lib/navigation.ts`, 2s-Timeout, degradiert lautlos auf die
statische Navigation) und merged sie in `Nav.tsx`. Merge-Regeln: Eintrag
landet im benannten Cluster; unbekannter/fehlender Cluster wird zum
Top-Level-Eintrag (statt still verschluckt zu werden); ein href-Konflikt mit
einem statischen Eintrag wird verworfen, damit ein Plugin kein Core-Ziel
überschatten kann.

Jedes vom Plugin gelieferte Feld gilt als **untrusted input**, weil es im
vertrauenswürdigen Header gerendert wird: `href` nur in kanonischer In-App-Form
(kein `//host`, keine Dot-Segments, keine Query/Fragment/Prozent-Kodierung —
sonst wäre die „Core gewinnt"-Regel per Alias umgehbar), Labels längenbegrenzt
und gegen Control-, Bidi- und Zero-Width-Codepoints geprüft (Trojan-Source-
Spoofing benachbarter Core-Einträge). Dazu Obergrenzen für href-/navId-Länge,
Locale-Map-Größe und Einträge pro Plugin, weil der Katalog in jede
Root-Layout-RSC-Antwort serialisiert wird.

Erster Consumer ist die Dev Platform selbst: ihr Eintrag wird aus dem
bestehenden `DEV_PLATFORM_ENABLED`-Block in `index.ts` registriert
(`core:dev-platform`), nicht mehr in `Nav.tsx` hardcodiert. Wenn das Plugin-
Package landet, wird daraus `ctx.uiRoutes.registerNav(...)` in dessen
`activate()` — an der Shell ändert sich dabei nichts. Vollständiger Plan und
die verbleibenden Phasen: `specs/470-dev-platform-plugin/plan.md`.

---

## 4. Migration Managed Agents → Lokal
Expand Down
68 changes: 68 additions & 0 deletions middleware/packages/plugin-api/src/pluginContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -708,6 +708,22 @@ export interface UiRoutesAccessor {
* into the catalogue.
*/
register(descriptor: UiRouteDescriptorInput): () => void;

/**
* Publish a top-level navigation entry for the operator web UI.
*
* This is deliberately separate from `register()`: a uiRoute
* descriptor addresses a plugin-served surface *relative to* the
* plugin's `/p/<pluginId>` mount, whereas a nav entry addresses an
* absolute in-app route. A plugin whose UI ships as compiled
* web-ui pages (a built-in package) has a nav entry and no uiRoute;
* a plugin serving its own HTML has both.
*
* Returns a dispose handle the plugin MUST call from its `close()`.
* The kernel additionally drops every entry by source on deactivate,
* so a leaked handle cannot outlive the plugin.
*/
registerNav(entry: UiNavEntryInput): () => void;
}

export interface UiRouteDescriptorInput {
Expand All @@ -733,6 +749,58 @@ export interface UiRouteDescriptor extends UiRouteDescriptorInput {
readonly pluginId: string;
}

/**
* A navigation entry contributed by a plugin to the operator web UI.
*
* Labels are plugin-owned and localized here rather than in web-ui's
* `messages/*.json`, because the shell cannot know a third-party
* plugin's strings at build time. The kernel resolves the label for
* the requested locale before it ever reaches the browser, so the UI
* never has to merge a second message catalogue at runtime.
*/
export interface UiNavEntryInput {
/** Stable id within the plugin. Combined with pluginId as the key. */
readonly navId: string;
/**
* Absolute in-app path (e.g. `/admin/dev-platform`). Must start with
* exactly one `/` — protocol-relative (`//host`) and scheme-bearing
* values are rejected so a manifest cannot point the nav off-origin.
*/
readonly href: string;
/**
* Optional cluster to nest under (e.g. `adminCluster`). Rendered as a
* top-level entry when omitted, or when the shell has no cluster by
* that key.
*/
readonly cluster?: string;
/** Ordering hint within the cluster — lower comes first. Default 100. */
readonly order?: number;
/**
* Locale code → label. An `en` entry is required as the fallback for
* locales the plugin does not translate.
*/
readonly label: Readonly<Record<string, string>>;
}

/** Catalogue-resolved nav entry — pluginId injected by the kernel. */
export interface UiNavEntry extends UiNavEntryInput {
readonly pluginId: string;
}

/**
* A nav entry flattened for one locale. This is the shape the HTTP
* surface returns and the web UI consumes; `label` is already resolved,
* so no locale negotiation happens in the browser.
*/
export interface ResolvedUiNavEntry {
readonly pluginId: string;
readonly navId: string;
readonly href: string;
readonly cluster?: string;
readonly order: number;
readonly label: string;
}

/**
* Cross-channel notifications. Plugins emit outbound events through
* `send()`; channel plugins register inbound handlers via
Expand Down
2 changes: 1 addition & 1 deletion middleware/src/devplatform/githubApp/installationTokens.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { DevJob } from '../types.js';
import { mintAppJwt } from './appJwt.js';
import { mintAppJwt } from '../../services/githubAppJwt.js';

/**
* Epic #470 W2 — scoped, uncached, revocable GitHub App installation tokens.
Expand Down
49 changes: 49 additions & 0 deletions middleware/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,7 @@ import { NotificationRouter } from './platform/notificationRouter.js';
import { PluginStatusRegistry } from './platform/pluginStatusRegistry.js';
import { OAuthReadinessTracker } from './plugins/oauth/oauthReadinessTracker.js';
import { UiRouteCatalog } from './platform/uiRouteCatalog.js';
import { createUiNavigationRouter } from './routes/uiNavigation.js';
import { CanvasOutputRegistry } from './platform/canvasOutputRegistry.js';
import { EventCatalogRegistry } from './platform/eventCatalogRegistry.js';
import { DeterministicActionRegistry } from './platform/deterministicActionRegistry.js';
Expand Down Expand Up @@ -333,6 +334,17 @@ interface Microsoft365AccessorShim {

/** Escape a value for safe inclusion inside a double-quoted XML/HTML attribute
* (used for the <mcp-auth-required> chat block, #459 W9). */
/**
* Locales the operator web UI ships message catalogues for
* (`web-ui/messages/*.json`). Used to validate `?locale=` on the
* navigation endpoint before it reaches label resolution — an unknown
* locale renders English chrome rather than an error. Keep in sync with
* web-ui's catalogue; a missing entry here only costs a fallback to
* English, never a failure.
*/
const WEB_UI_LOCALES = ['en', 'de'] as const;
const WEB_UI_DEFAULT_LOCALE = 'en';

function xmlAttr(value: string): string {
return value
.replace(/&/g, '&amp;')
Expand Down Expand Up @@ -2213,6 +2225,23 @@ async function main(): Promise<void> {
// travels with it.
app.use('/api/chat', requireAuth, createChatSessionsRouter({ getStore: getChatSessionStore }));

// Plugin-contributed navigation. The web-ui shell renders a static nav for
// its own compiled surfaces and merges this for everything a plugin adds,
// which is what makes a feature genuinely installable: deactivate its
// plugin and the menu entry is gone without a frontend rebuild.
// `requireAuth` is defence-in-depth over the `/api` mount — the entry list
// discloses which features an operator has installed.
app.use(
'/api',
requireAuth,
createUiNavigationRouter({
catalog: uiRouteCatalog,
supportedLocales: WEB_UI_LOCALES,
defaultLocale: WEB_UI_DEFAULT_LOCALE,
}),
);
console.log('[middleware] ui navigation endpoint ready at /api/v1/ui/navigation');

// In-app "Create Issue" button: operator connects their own GitHub
// account via the device flow (only a public client id, no secret — so
// omadia ships the OAuth App baked in), the primary LLM reformulates the
Expand Down Expand Up @@ -2737,6 +2766,26 @@ async function main(): Promise<void> {
`[middleware] dev platform ENABLED — worker running (max ${String(config.DEV_PLATFORM_MAX_CONCURRENT_JOBS)} concurrent, ${String(wiredDevPlatform.backends.length)} backend(s))`,
);

// Contribute the operator menu entry instead of hardcoding it in the
// web-ui shell. This is the first consumer of the nav catalogue and the
// reason it exists: dev-platform is being extracted into a plugin
// (specs/470-dev-platform-plugin/plan.md), and its menu entry has to
// travel with it. Registering from here — still core, still inside the
// DEV_PLATFORM_ENABLED gate — proves the whole loop before any code
// moves. When the plugin package lands, this call becomes
// `ctx.uiRoutes.registerNav(...)` inside its activate() and nothing
// else about the shell changes.
//
// The `core:` prefix marks a kernel-registered source; a real plugin's
// entries are keyed by its plugin id, which the kernel injects.
uiRouteCatalog.registerNav('core:dev-platform', {
navId: 'devPlatform',
href: '/admin/dev-platform',
cluster: 'adminCluster',
order: 50,
label: { en: 'Dev Platform', de: 'Dev-Plattform' },
});

// W5 data lifecycle — the daily retention sweep (two-tier event prune). The
// per-job event cap + artifact ceiling are enforced inline at write time; this
// cron only prunes aged rows. Terminal-job purge stays operator-driven via
Expand Down
3 changes: 3 additions & 0 deletions middleware/src/platform/pluginContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -691,6 +691,9 @@ export function createPluginContext(
register(input) {
return opts.uiRouteCatalog.register(agentId, input);
},
registerNav(input) {
return opts.uiRouteCatalog.registerNav(agentId, input);
},
};

// OB-29-1 — SubAgentAccessor: present iff the manifest declares
Expand Down
Loading
Loading