feat: operator UI for Teams agent identities + end-to-end smoke - #896
Merged
Conversation
…its choke point Adds the authenticated, read-only operator surface for the chat-context memory trees (epic #860, design #870 §2) — the W5 carry-over that lets an operator browse `/memories/contexts/...` in production. Until now `web-ui/app/memory/page.tsx` browsed those trees through `/bot-api/dev/memory/list`, i.e. `harness-memory/src/devMemoryRouter.ts`, which the memory plugin mounts only when `dev_memory_endpoints_enabled` is truthy — a flag the kernel forbids in production. That router is also unauthenticated and exposes the WHOLE `/memories` tree, so it could never become the production answer. This router is: session-gated, and structurally unable to leave `/memories/contexts`. THE GUARD IS THE FEATURE ------------------------ Between an authenticated listing and the rest of `/memories` — the agent tier, the shared kernel, every other context tree — stands nothing but the path guard, so it is layered twice: 1. `resolveContextPath` (pure, exported, unit-tested) normalises the operator's absolute `?path=` and rejects any `.`/`..` segment, any NUL byte, any relative or over-long path, and anything whose first two segments are not `memories/contexts`. That last test is segment-wise on purpose: `/memories/contextsX` is a different tree, and a `startsWith` check would have handed it over. 2. `createRootedMemoryAccessor` re-normalises the resulting relative path and cannot emit a store path outside its root even if step 1 were wrong. `createRootedMemoryAccessor` is new in `platform/memoryAccessor.ts`: the existing per-plugin/per-orchestrator accessor and this one now share one engine (`createScopedMemoryAccessor`), so the isolation rules live in exactly one place. `createMemoryAccessor` keeps its signature and behaviour byte-for-byte — the legacy `/memories/agents/<pluginId>` read-through is now expressed as an optional `legacyPrefix` on the scope, and `memoryAccessorIsolation.test.ts` still passes untouched. WIRE SHAPE — settled here ------------------------- `GET /list` answers `{ path, entries: [{ virtualPath, isDirectory, sizeBytes }] }`, byte-compatible with the dev router, so web-ui's existing `ListResponse`/`Entry` types and its self-entry filter carry over with only the URL changed. `GET /file` is included alongside the listing the spec asked for. The memory page has a second call site (`/dev/memory/file`), and shipping only the listing would have left the operator a tree whose files cannot be previewed in production. It is read-only and runs through the same guard; there is no write, delete or promote verb here. Promotion stays where it is, at `POST /api/v1/admin/memory/promotions/:slug`. The `src/index.ts` mount is deliberately NOT in this commit — the wiring unit owns it. Intended mount: `/api/v1/operator/memory/contexts` behind `requireAuth`. Follow-up worth noting, not in scope: the router repeats the session check that `requireAuth` already performs at mount time. That is deliberate belt-and-braces for a memory-reading endpoint, matching `memoryPromote.ts`.
…ams_bot formatter The Teams provisioning identity endpoint exposes `identity.last_error` as a free-form English sentence and `teams_bot` as a raw config projection. Neither can be rendered as-is: English on a German operator's screen violates the i18n rule, and a config block with no explanation leaves the operator guessing what to do with it. - `teamsIdentity.ts` mirrors the state vocabulary of `agentTeamsIdentityStore.ts` (migration 0049's CHECK constraint), validates the GET envelope into a camelCase view, and renders the `teams_bots` entry exactly as channel-teams' `parseTeamsBotsConfig` expects it. A value that is not a vault handle drops the whole block instead of reaching a copy-paste box. - `teamsIdentityErrors.ts` classifies `last_error` into consent_missing / arm_not_configured / throttled / unknown, capturing the missing scopes, the missing setup fields and the Retry-After hint as ICU arguments. The raw sentence is demoted to a secondary technical detail. - ArmNotConfigured reads as a halt, never a failure: the runner keeps the identity on `app_registered`, and the copy says registration-only is a valid place to stop. - The test reads the middleware sources, so a reworded producer sentence or a changed state vocabulary fails here instead of silently degrading the UI. Follow-up, not in scope: the job runner should persist a structured code from the start and the route project it as `last_error_detail`, which would demote this parser to a fallback for older rows. Automatic `teams_bots[]` sync into channel-teams is likewise still a follow-up — the copy says the paste is manual.
The W5 context browser fetched /bot-api/dev/memory/{list,file}, which the
memory plugin mounts only when dev_memory_endpoints_enabled resolves truthy
-- a flag the kernel forbids in production. The panel was therefore dead
exactly where an operator needs it, and its own error copy told them to set
DEV_ENDPOINTS_ENABLED, advice no production operator can act on.
Both call sites now go to GET /api/v1/operator/memory/contexts/{list,file}
(requireAuth, cookie session JWT -- the same gate as the Danger-Zone purge).
The wire shape is unchanged, so only the URL moves; the list handling, the
self-entry filter and the file/markdown preview carry over as they were.
That endpoint is structurally unable to read outside /memories/contexts, so
the page mirrors the scope instead of pretending: the root is CONTEXTS_ROOT,
breadcrumbs and "up" stop there, listDir treats an out-of-scope path as an
empty branch rather than a request, and the context tree no longer offers an
agent-tier node -- a node that always errors is worse than an absent one.
Promotion still targets the agent tier; that is a write on the audited
promote route, not a read here. The browser stays strictly read-only.
401 and 403 are ordinary answers on a gated route, so they get their own
copy (errorUnauthenticated / errorForbidden) rather than a bare "Listing
failed (HTTP 401)"; errorPathNotFound and errorOutOfScope replace the dev
endpoint's advice, and memory.errorDevEndpointUnavailable is gone.
Tests: MemoryContextBrowser.test.tsx pins the URL that actually goes out,
the path scope of every request, the file preview and the auth states, with
the real URL builders (importOriginal) so a stubbed builder cannot pass for
the wire. page.contexts.test.tsx now serves the operator endpoint only.
#860 W2a) Lift the channel-teams `teams_bots[]` entry out of the GET /operator/agents/:slug/teams-identity handler into one exported `projectTeamsBotConfig()` so every further team-to-agent route emits a byte-identical block. The entry is a config contract with channel-teams; a second, drifting copy would hand operators a config the plugin silently refuses to parse. Response bytes for the existing route are unchanged: null unless BOTH app_id and tenant_id are known, appType always 'SingleTenant', the bot password only as the opaque vault ref teams_bot_password:<appId>. Pasting the block into channel-teams stays a manual operator step; automatic config sync remains a follow-up. Additively, the GET now also returns `last_error_detail` - the identity's last_error decoded into { code, scopes?, fields?, retryAfterSeconds?, raw } so the operator UI renders from a code plus typed arguments through i18n instead of parsing English sentences. The classifier lives next to the only code that writes those sentences (services/teamsProvisioningJob.ts): changing a message and forgetting the decoder now breaks a colocated round-trip test rather than degrading the UI in production. An exhausted throttle budget gains a `throttled:` prefix and carries its Retry-After hint, so that code is actually producible. No schema change, no new route, no migration.
Adds the per-agent Teams bot identity section: create the identity (bot slug, display name, optional target team), start provisioning, and watch the state machine advance live against `GET /v1/operator/agents/:slug/teams-identity`. - AgentTeamsIdentity.tsx owns the data flow (fetch, poll, provision) and is mounted from AgentDetail.tsx, the single composition point for the Teams sections so the sibling Teams units extend one file. - All seven states render readably: the badge names the current state and an ordered chain marks progress. `failed` is a sink, not a chain step, so it never claims a position the run did not reach. - Polling is keyed on the current non-terminal state — it survives polls that return an unchanged state, restarts on a transition, and is torn down both at a terminal state (installed/failed) and on unmount. - Four non-errors stay non-alarming: 404 `teams_identity_not_found` is the create form's trigger, the two 503 capability gates render as informational notices, and a `last_error` on a non-terminal state keeps polling. 409 `bot_slug_taken` maps to localized copy with the operator's input preserved. - `last_error` is NOT parsed in web-ui. The panel renders the server-side classification (`identity.last_error_detail`) through i18n with ICU arguments and keeps the raw runner sentence as a secondary technical detail; the client only narrows the already-structured projection, and degrades to the localized fallback when the field is absent. - New i18n keys are additive under the existing `operatorAgents` namespace, en + de. Follow-up worth doing separately: the provisioning job runner should persist a structured error code from the start, rather than having the route classify the sentence it wrote.
…uilder Persona, tone and behaviour are designed in the native Agent Builder (app/store/builder/[id], PersonaPillar et al.). The operator agent detail page stays a capability-assignment surface and now just points there — no persona UI is duplicated, and no Kemia integration is involved. The builder route is keyed by a draft [id] while the operator page is keyed by an orchestrator slug, and no column joins the two directly. The published agent plugin does: a builder draft pins the plugin it published (published_agent_id) and an orchestrator carries that same plugin id in its assigned set. resolveBuilderDraftId() takes the assigned plugins whose catalog entry is kind === 'agent' and deep-links the draft that published one of them. The resolution is deliberately conservative: only an unambiguous single match produces /store/builder/<id>. No match, an ambiguous match, a catalog that has not resolved yet, or a failed drafts listing all fall back to the /store/builder overview, which always exists — a guessed id would 404 and picking one of several drafts would open the wrong persona. The drafts fetch is best-effort and never raises this page's error banner, which stays reserved for the writes it owns. Link label, hint and fallback notice go through next-intl (en + de).
Adds the operator surface for "which teams is this agent's app installed
in, and put it into one":
GET /v1/operator/agents/:slug/teams
POST /v1/operator/agents/:slug/teams
DELETE /v1/operator/agents/:slug/teams/:teamId -> 501
No schema change, no migration, no widening of the connector contract.
Everything the routes report is derived from the existing
`agent_teams_identities` row and projected through the wave's single
`projectTeamsBotConfig` / `classifyTeamsProvisioningError` choke points.
STOP-GATE FINDINGS (the reason the shape is what it is):
1. The read model can never be plural. Migration 0049 is
one-identity-per-agent with a SINGLE nullable `team_id`, documented as
"the install target of the last provisioning request". An install SET
needs N rows, i.e. a new table.
2. The connector cannot enumerate installs. `teamsProvisioner@1` exposes
createAppRegistration / createBot / buildAppPackage / uploadToCatalog /
getCatalogApp / installToTeam - no listing method. `getCatalogApp`
answers tenant-CATALOG presence, never a team install, so a "live
lookup" through it would assert something the connector never said.
3. There is no uninstall either. `installToTeam` has no counterpart, so
DELETE cannot do the thing its name promises. It answers 501 with the
reason instead of clearing `team_id`, which would only make the
middleware forget an install that is still live in Teams.
So the routes report exactly what the row proves, mark every entry
`evidence: 'identity_row'`, and ship the platform's limits as data
(`capabilities.{install,uninstall,enumerate,multi_team}` plus a reason per
false) so the operator UI can disable a control instead of discovering the
gap through a failed request. A team below state 'installed' is surfaced
as `pending_team_id`, never as an install. Consent status is read back
from the runner's recorded failure through its own classifier - the typed
connector guards are caught where they are thrown, at write time - and
`granted` is only claimed for a state the chain could not have reached
without consent.
POST records the target through the store's own gate and hands the chain
to the provisioning runner (the single writer of state); it is idempotent
for the team the agent is already in and answers 409 for a SECOND team
rather than overwriting the only team_id the schema has.
`OperatorTeamsIdentityRecord.teamId` is now required, pinned in the pg
suite: a store that stopped exposing it would leave GET /:slug/teams
reporting "no installs" forever instead of failing.
…#860 W2a) STAGE 2 of middleware/scripts/smoke-teams-e2e.ts (gitignored, hits byte5-internal endpoints) drives the live provisioning chain over HTTP: POST /api/v1/operator/agents/:slug/teams-identity (202), then polls GET .../teams-identity through pending -> app_registered -> bot_created -> package_built -> catalog_uploaded -> installed, asserts the teams_bot projection and teams_app_id are complete, and verifies the new bot's /api/teams/<botSlug>/messages route is live and rejects an unsigned payload. It runs on the environment STAGE 1 establishes. Production-write guard, fail-closed: a provisioning call persists an agent_teams_identities row and creates real Entra/Azure/Teams objects, so the stage has no default target. It skips entirely without an explicit opt-in token, requires the caller to echo the target host back, refuses known production hosts with no override, and aborts when the shell holds a non-scratch DATABASE_URL. Registration-only is a valid terminal outcome: with no ARM setup fields the chain stops at app_registered with arm_not_configured and the run reports success-with-caveat. Missing admin consent stays a hard stop. GET .../teams-identity now emits identity.last_error_detail alongside last_error - { code, scopes?, fields?, retry_after_seconds?, raw }. Additive; no schema change, no migration. The classifier lives next to the producers that write those sentences, with a round-trip test so rewording a message without updating the parser breaks a colocated test instead of silently degrading the operator UI. Clients render from the structured object and must not parse the English sentence. Docs: handoff section on pointing the smoke at a scratch tenant and what it needs, plus a CHANGELOG entry. Follow-up: the job runner should persist a structured code from the start; that needs a migration on agent_teams_identities and its own unit.
…tity-panel' into feat/w2a-operator-ui-e2e-w2a-operator-ui-e2e-teams-bot-config-block
, W2a) The last mile of Teams provisioning is not automated: the operator copies the resulting `teams_bots` entry into the channel-teams setup field by hand. Render that block on the Teams identity panel and say the manual step out loud instead of letting anyone assume a sync that does not exist. - `TeamsBotConfigBlock` renders the route's `teams_bot` object verbatim through `parseTeamsIdentityEnvelope` + `formatTeamsBotsConfig` from `_lib/teamsIdentity`, so the JSON array and its key order stay exactly what `parseTeamsBotsConfig` expects on paste. Nothing is reshaped. - `teams_bot` is null until app_id AND tenant_id exist (before the app_registered step): that gets an explanatory line, not an empty box. - `appPasswordSecretRef` is the opaque vault ref, never the password. A value that is not ref-shaped drops the whole block rather than printing it into a copy-paste box, and nothing logs the ref. - One-click copy with a soft clipboard failure: the block stays visible and selectable, no error banner. i18n: adds `operatorAgents.teamsIdentity.teamsBot.{heading,blockLabel, copy,copied}` in both locales; the manual-step, instructions, secret-ref and follow-up copy already came from the `_lib` unit. Merge fix, in the same namespace: this branch merges the `_lib` unit and the panel unit, and both had appended a `teamsIdentity` object to `operatorAgents` — a DUPLICATE JSON key that silently dropped the whole `_lib` block (JSON.parse keeps the last), failing its i18n coverage test. The two are consolidated into one object; the panel's route-error catalogue moves to `teamsIdentity.routeErrors.*` so it stops colliding with the `_lib` long-form provisioning-error catalogue at `teamsIdentity.errors.*`. Message text is unchanged. Follow-up worth its own issue: writing the `teams_bots[]` config into channel-teams automatically, and persisting a structured error code from the job runner instead of classifying the sentence after the fact.
Adds the operator surface for "which team is this orchestrator's app installed in, put it into one, and has the tenant consented" on the agent detail page, backed by the wave's derived install read model (GET/POST /v1/operator/agents/:slug/teams, DELETE /:slug/teams/:teamId). The panel is CAPABILITY-DRIVEN rather than hard-coded to today's platform limits. teamsProvisioner@1 publishes neither an installation listing nor an uninstall, and migration 0049 records ONE team_id per agent, so the route ships those limits as `capabilities.*` plus a reason per false. Every control reads that block: - uninstall renders DISABLED with a localized reason instead of a live button that answers 501, and the same control lights up unchanged the day the connector contract gains an uninstall; - install is refused once a team is tracked (multi_team false) and the 409 team_install_conflict maps to its own copy; - the derived-not-enumerated caveat hangs off `enumerate`, so it disappears rather than lying when a listing method appears; - an absent or partial capability block is parsed fail-closed at BOTH the network boundary (getAgentTeams) and the render boundary, so a middleware that never learned to report capabilities disables everything instead of enabling a lie. Consent is rendered from the route's server-derived verdict (consent.status + missing_scopes + what the verdict rests on). The remediation copy is NOT duplicated here - it belongs to the Teams identity panel, and this one points at it, so the wave keeps a single place with the consent instructions. The provisioning state vocabulary and last_error are likewise left to that panel. Writes disable the panel while in flight, uninstall sits behind a ConfirmDialog, and a success refreshes this panel and then the page through AgentDetail's router.refresh() convention. Every failure renders the localized copy for the machine code; the server's English `unsupported_reason` appears only as a secondary technical detail (web-ui i18n hard rule). New keys are additive under operatorAgents.teamsInstalls, en + de. Follow-up, not in scope: the job runner should persist a structured error code from the start rather than having the route classify the sentence it wrote, and an install SET (plus a real uninstall) needs both a schema change and a wider connector contract.
…emory-contexts-endpoint' into feat/w2a-operator-ui-e2e-w2a-operator-ui-e2e-wiring
…config-block' into feat/w2a-operator-ui-e2e-w2a-operator-ui-e2e-wiring
…nment-ui' into feat/w2a-operator-ui-e2e-w2a-operator-ui-e2e-wiring # Conflicts: # web-ui/app/operator/agents/[slug]/_components/AgentDetail.tsx # web-ui/messages/de.json # web-ui/messages/en.json
…der-link' into feat/w2a-operator-ui-e2e-w2a-operator-ui-e2e-wiring
…text-browser-operator-endpoint' into feat/w2a-operator-ui-e2e-w2a-operator-ui-e2e-wiring
…2E smoke, close en/de parity (#860 W2a) Wiring unit for wave W2a. It owns every hub file so the parallel feature units never collide on one. 1. middleware/src/index.ts - mount createOperatorMemoryContextsRouter at /api/v1/operator/memory/contexts behind requireAuth, next to its verified operator siblings (/api/v1/operator/agents, /api/v1/operator/channels) and with the same boot-log convention. Until now the switched memory browser in web-ui pointed at an endpoint nothing mounted; the dev router it replaces (@omadia/memory devMemoryRouter) is unauthenticated, exposes the whole /memories tree and is forbidden in production. The root memoryStore is passed in deliberately - the router's own rooted accessor is the scope choke point and cannot emit a path outside /memories/contexts. 2. middleware/package.json - register "smoke:teams-e2e", next to the existing smoke entries. The script itself is gitignored, like every other scripts/smoke-*.ts. 3. web-ui/messages/{en,de}.json - final parity pass over the 136 keys the Teams identity panel, the view types and error mapper, the config block, the installs panel, the builder link and the memory browser each added. The catalogues are the key-level union of those units' deltas: additions and updates applied, and the two keys the memory browser retired (memory.errorDevEndpointUnavailable, memory.contexts.agentTier) stay retired. Every new key has a real German translation; no ICU argument differs between the locales. AgentDetail is the single composition point for the Teams sections, so the identity and installs panels now mount together. AgentDetail.test.tsx stubs both - they fetch their own read models and raise their own role="alert" banners, which their own suites cover, and leaving them live made this suite's alert assertions ambiguous rather than about AgentDetail's own write errors. Gates: middleware typecheck + 7712 tests green; web-ui lint, typecheck, 963 tests, i18n:check (4048 keys) and i18n:literals (0 to translate) green.
Conflict resolution: one owner for identity.last_error_detail. The choke-point unit's camelCase TeamsProvisioningErrorDetail wins (it is what the merged web-ui and both middleware test suites already consume); the smoke unit's parallel snake_case teamsLastErrorDetail projection and its duplicate classifier are dropped. teamsProvisioningLastError.test.ts is rewritten onto the kept producers so the round-trip pin survives.
…d team_id, retarget guards
Blockers and majors from the per-unit reviews, fixed across the merged wave.
MIDDLEWARE
- One owner for `identity.last_error_detail`: the camelCase
`TeamsProvisioningErrorDetail` from `classifyTeamsProvisioningError`. The
parallel snake_case `teamsLastErrorDetail` projection and its duplicate
classifier are dropped; the round-trip test moves onto the kept producers.
- `GET /:slug/teams-identity` additionally returns `identity.team_id` — the
POST requires `team_id` and has no fall-back-to-stored path, so without it a
re-run could only ever 400.
- `refuseConflictingTeamRetarget()`: both POSTs answer 409 BEFORE any write
when the row is already `installed` in another team, or when a run is in
flight toward another team. `installToTeam` uses the teamId captured at
enqueue, so an accepted retarget installed into the OLD team while the row
claimed the new one — a read model asserting an install that never happened.
- `TeamsProvisioningJobRunner.runningTeamId()` exposes the in-flight target,
because the runner refuses a conflicting enqueue with a RESOLVED
`{status:'rejected'}` a fire-and-forget caller cannot observe in time.
- `startProvisioningRun()` records that refusal via `recordEnqueueFailure`
instead of dropping it in a `.catch()`-only handler.
- Route test now pins `deps.clientSecretRef` through the HTTP layer; the old
pin compared two calls that both collapsed to the default ref.
WEB-UI
- Create form requires the target team and the re-run resends the recorded
one; both hints corrected in en/de. No target recorded -> disabled button
with a reason, not an action that can only fail.
- Deleted `app/_lib/teamsIdentityErrors.ts`: a second sentence parser for
`last_error`, dead since the middleware started projecting the structured
detail. Its (better) failure copy is what the panel now renders, which also
fixes the throttle case: no `Retry-After` hint means no wait line, instead
of "retry in about 0 seconds" at the moment the runner gave up.
- Agent Builder link: a `draftsSettled` flag separates "still loading" from
"resolved to no match" — the page no longer states a false negative on every
load, and no click can drop the deep link mid-flight.
- Team assignment: install is gated on `pending_team_id`/`running`, not just
on `teams.length` (which is empty for every non-installed state); the
pending hint distinguishes a run under way from a chain that stopped.
- Memory browser: a 404 on the contexts ROOT (or one that is not the router's
own JSON) reads as an unreachable endpoint, not a missing path — a mounted
router can never 404 that path, and the old copy told operators their store
was empty when the router was simply not mounted.
Refs #866, part of #860.
This was referenced Aug 27, 2026
Weegy
added a commit
that referenced
this pull request
Aug 27, 2026
Operators had no single place that says how to get several omadia agents into Microsoft Teams as separate named bots. The knowledge was spread across three repos, two manifests, five migrations and a handful of PR descriptions, so every attempt rediscovered the same traps: consent that silently does not apply, ARM fields whose absence is a partial success rather than a failure, and a teams_bots block nothing syncs for you. The new guide walks the whole path and states the platform limits up front, because they decide the architecture: Teams cannot change a bot name per message, bots never see each other, and rate limits are per bot (which is an argument FOR separate identities, not against them). Every API path, field name, state and setup key is verified against main rather than carried over from the draft, which predated three waves -- the operator UI (#896), the memory ACL (#881) and the .template ingest fix (#880) all landed after it. The one claim that could not be grounded in code is marked as a VERIFY comment instead of asserted. Part of #860
This was referenced Aug 27, 2026
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Wave W2a of epic #860, integrated from 12 parallel units into one branch. The provisioning chain built in W1a had no operator surface — everything ran through
curl. This gives it one, and adds an end-to-end smoke that drives the real chain against a throwaway tenant.Refs #866, part of #860.
What the operator can do now
Create a Teams identity for an orchestrator. Bot slug and display name are optional (the server derives both from the agent); the target team is required, because the server requires it and provisioning installs the app into it.
Read the provisioning status. The state machine (
pending → app_registered → bot_created → package_built → catalog_uploaded → installed) renders as a live chain with a badge, polled while the run is non-terminal and torn down on a terminal state or unmount.Act on a
last_error. The middleware classifies the failure server-side, next to the only code that writes those sentences, and the route emitsidentity.last_error_detailalongside the unchangedlast_error. The panel renders what happened, the captured scopes or setup fields, and what to do next — plus a link to Microsoft's admin-consent step for a consent failure. The raw English sentence is a collapsed technical detail, never the message.arm_not_configuredonapp_registeredreads as a legitimate stop (registration-only), not as a broken agent.Copy the
teams_botblock — the channel-teamsteams_bots[]entry, verbatim inparseTeamsBotsConfigkey order, with the bot password present only as its opaque vault ref. The copy says plainly that pasting it into the plugin's setup field is a manual step and that nothing syncs it; automatic config sync stays a documented follow-up.Assign a team. Installed teams, consent status, install — and an honest
501for uninstall, becauseteamsProvisioner@1publishes none and clearingteam_idwould only make the middleware forget an install that is still live in Teams.Open the native Agent Builder. Persona and behaviour design stays there; the detail page deep-links to the draft that published this orchestrator's agent plugin, or to the overview when no single draft matches — never a guessed
[id].Browse chat-context memory outside dev.
/memorynow reads the new authenticatedGET /api/v1/operator/memory/contexts/{list,file}instead of the dev-only router. The endpoint's path guard normalizes every request into the/memories/contextssubtree segment-wise (astartsWithcheck would have handed over/memories/contextsX) and rejects traversal before the store is touched; a second, rooted accessor cannot emit an out-of-scope path even if the first layer were wrong. The agent tier and the shared kernel are deliberately not reachable through it.Findings fixed during integration
identity.last_error_detailon the same key with different wire shapes (camelCase vs snake_case); a merge keeping both would have produced a duplicate object key — legal JS, last one silently winsTeamsProvisioningErrorDetailfromclassifyTeamsProvisioningError. The duplicate snake_case projection and its second classifier are deleted; the round-trip test moves onto the kept producersteam_idwhen left empty, butTeamsIdentityProvisionSchemadeclares itz.string().min(1)— the hints literally invited an input that could only 400en/deonRerun={() => void provision({})}— the re-run action posted an empty body, so it 400'd in 100% of casesGET …/teams-identityadditionally returnsidentity.team_id; the re-run resends it. No recorded target → disabled button with a reason, not an action that cannot succeedPOST /:slug/teamswrote the newteam_idbefore enqueueing, and the runner refuses a conflicting enqueue with a resolved{status:'rejected'}— never a rejection — so the.catch()never fired and the route answered 202 while run #1 installed into the old teamrefuseConflictingTeamRetarget()answers 409 before any write, using the newTeamsProvisioningJobRunner.runningTeamId(). A refused enqueue is now recorded viarecordEnqueueFailureinstead of droppedPOST /:slug/teams-identityrewroteteam_idon an already-installedrow with no guard, while the runner returns early oninstalled— producing a read model that published an install that never happenedprojectTeamsBotConfig(row)against a router built without aclientSecretRef, so both sides collapsed to the default ref: dropping the second argument at the call site would have stayed green while operators pasted a config pointing at a nonexistent vault keyclientSecretRef, and the GET'sappPasswordSecretRefis asserted against iterrors.throttledhardcoded{seconds}withdetail.retryAfterSeconds ?? 0— a throttle that exhausts its budget without aRetry-Afterheader rendered "It can be retried in about 0 seconds", i.e. retry immediately at the exact moment the runner gave upbuilderDraftId === nullconflated "still loading" with "resolved to no match": the page stated a false negative on every load, and a click before both fetches settled dropped the deep linkdraftsSettledflag; while resolving, neither the hint nor a followable fallback href renderscanInstallgated oninstalled.length === 0, butteamsis empty for every state exceptinstalled— so the install control stayed enabled through the whole in-flight window, and the route's non-installed branch does not 409pending_team_id === null && !runningadded to the conjunctionpendingHintclaimed "a provisioning run is targeting team X" for every non-installed state, terminal failures included — a panel that renders neither state norlast_errorhad nothing to contradict itrunning; a stopped chain gets its own copy pointing at the Teams identity sectionlistDirturned a root 404 into an empty branch — so an unmounted router rendered as "No agent memory yet" over a store full of context trees{"error":"not_found"}JSON) reads as an unreachable endpoint;listDirsurfaces it instead of returning[]app/_lib/teamsIdentityErrors.tswas a secondlast_errorsentence parser, dead since the middleware started projecting the structured detailpollUntilTerminalhonouredlast_error_detailfrom the FIRST poll, but nothing clears a previous run'slast_erroron a re-run — the exact flow the handoff prescribes (fix the ARM fields, re-run) could report a green "registration only" for a run that had done nothingarm_not_configuredbranch is constrained toapp_registered/bot_created, as its own doc comment already claimedurl.host, which includes the port —https://app.omadia.ai:8443slipped past the guard documented as having no overrideurl.hostnameassertMessagingEndpointBoundaccepted anystatus >= 400 && !== 404as proof that Bot Framework auth is enforced, so a 500 from the deliberately malformed probe passed identically to a clean 401Two units were flagged clean (
smoke-hub-artifact-ingest,operator-memory-contexts-endpoint); the hub-artifact unit produced no tracked changes at all, since the smoke scripts are gitignored.The E2E smoke — not in this diff, on purpose
middleware/scripts/smoke-teams-e2e.ts(stage 1) andsmoke-teams-e2e-stage2.tsare matched bymiddleware/scripts/smoke-*.tsin.gitignoreand are deliberately absent from this PR — they hit byte5-internal endpoints.npm run smoke:teams-e2eis registered inmiddleware/package.json; stage 1 imports and calls stage 2.What it does. Stage 1 downloads the real published plugin artifact from the hub and runs it through the production
extractZipToDir. Stage 2 then drives the live chain:POST /api/v1/operator/agents/:slug/teams-identity(202), pollsGET …/teams-identitythrough the full state machine, asserts theteams_botprojection andteams_app_idare complete, and probes the new bot's/api/teams/<botSlug>/messagesroute with an unsigned payload to prove it is live and rejects it.How to run it against a throwaway environment.
It needs the M365 connector plugin installed and active in that environment, admin consent granted in the scratch tenant, and the ARM setup fields configured for the full chain — without them the run legitimately stops at
app_registeredand reports success-with-caveat.docs/middleware-agent-handoff.mdcarries the long form.Why it must never run against production. A provisioning call persists an
agent_teams_identitiesrow and creates real Entra app registrations, Azure bot resources and Teams catalog entries in the target tenant — none of which this script can undo, andteamsProvisioner@1publishes no uninstall. The guard is fail-closed and layered: no default target, an explicit opt-in, a host the caller has to echo back verbatim, a hostname-matched refusal of known production hosts with no override switch, and an abort when the shell carries a non-scratchDATABASE_URL.Known limitation. The smoke does not send a genuine Bot Framework turn — signing one needs the freshly created app's secret, which never leaves the connector's vault. The visible reply in Teams stays a one-line manual step the run prints out.
Gates
middleware && npm run typecheckmiddleware && npm testweb-ui && npm run lintweb-ui && npm run typecheckweb-ui && npm run testweb-ui && npm run i18n:checken+deOne flake was observed and is documented rather than papered over:
AgentToolGrants.test.tsx("shows the latest grant epoch in the heading summary") failed once in a full run and passed in every subsequent full run, in an isolated run, and in a run of its whole directory. This diff touches neither that component nor its test — the known cross-file pollution class, not a regression from this branch.Notes for review
identity.team_idandidentity.last_error_detailare both additive projections of columns that already exist.agent_teams_identitieskeeps ONEteam_id(migration 0049), documented as resume evidence. The installed-teams read model is therefore derived, never plural, and it refuses to publish a team the runner did not actually install into — which is what findings 4 and 5 were about.appPasswordSecretRefis the opaqueteams_bot_password:<appId>handle, and the shape check drops the whole block rather than render a value that is not ref-shaped.Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.